A Cloudflare Worker that issues RS256 JWTs to authenticated bridge clients. Used by ACP Bridge instances to obtain short-lived tokens for calling cf-push-relay without embedding long-lived secrets in request bodies.
Push relay requests must be tied to a specific bridge identity so that device tokens are isolated per bridge (no cross-bridge notification leakage). cf-token provides:
- M2M OAuth2-style token issuance — bridges exchange
client_id/client_secretfor a short-lived RS256 JWT - RS256 public key verification — the relay verifies JWTs against a published JWKS, with no shared secret between the two workers
- Client lifecycle management — admin endpoints to create and revoke bridge clients
Bridge ──POST /token──→ [ cf-token Worker ] ──→ RS256 JWT (1h TTL)
{client_id, │
client_secret} │
KV: CLIENT_REGISTRY (hashed secrets)
Secret: RS_PRIVATE_KEY (RSA-2048 PEM)
cf-push-relay ──GET /.well-known/jwks.json──→ [ cf-token Worker ]
(on first request or cache miss)
Health check. No auth required.
{ "ok": true, "status": "healthy", "timestamp": "2024-01-01T00:00:00.000Z" }Public RS256 key set. No auth required. Cache-Control: public, max-age=3600.
{ "keys": [{ "kty": "RSA", "use": "sig", "alg": "RS256", "kid": "...", "n": "...", "e": "AQAB" }] }Issue a JWT. No auth header required — credentials are in the body.
{ "client_id": "bridge-home-office", "client_secret": "<secret>" }Response:
{ "access_token": "<rs256-jwt>", "token_type": "Bearer", "expires_in": 3600 }- Admin client (
ADMIN_CLIENT_ID) → scope"admin", verified againstADMIN_CLIENT_SECRET - KV clients → scope from stored record (
"push:write"or"admin"), verified via PBKDF2-SHA256 - On failure:
401 { "ok": false, "error": "invalid_client" }
Create a bridge client. Requires admin JWT (Authorization: Bearer <admin_jwt>).
{ "client_id": "bridge-home-office", "scope": "push:write" }Response (201):
{ "ok": true, "client_id": "bridge-home-office", "client_secret": "<hex-secret>" }The client_secret is shown once — store it immediately in common.toml [push_relay].
Revoke a client. Requires admin JWT.
{ "ok": true, "message": "Client deleted" }- Node.js >= 18
- Wrangler CLI (
npm i -g wrangler) - A Cloudflare account
cd cf-token
npm installnode scripts/generate-keys.mjsCopy the printed PEM private key — you'll need it in step 4.
wrangler kv namespace create CLIENT_REGISTRYCopy the ID into wrangler.toml.
wrangler secret put RS_PRIVATE_KEY # paste PEM from step 2
wrangler secret put ADMIN_CLIENT_SECRET # choose a strong random secretEdit wrangler.toml:
[vars]
TOKEN_ISSUER = "https://token.aptove.com"
TOKEN_AUDIENCE = "https://push.aptove.com"
ADMIN_CLIENT_ID = "admin" # change to a non-guessable value for productionnpm run deploy
# or: wrangler deployAfter deployment, use the admin credentials to register each bridge:
# 1. Get admin JWT
ADMIN_JWT=$(curl -s -X POST https://token.aptove.com/token \
-H "Content-Type: application/json" \
-d '{"client_id":"admin","client_secret":"<ADMIN_CLIENT_SECRET>"}' \
| jq -r .access_token)
# 2. Create a bridge client
curl -s -X POST https://token.aptove.com/clients \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $ADMIN_JWT" \
-d '{"client_id":"bridge-home-office","scope":"push:write"}'Copy the returned client_id and client_secret into the bridge's common.toml:
[push_relay]
url = "https://push.aptove.com"
token_url = "https://token.aptove.com"
client_id = "bridge-home-office"
client_secret = "<secret from above>"# Create .dev.vars
cat > .dev.vars << 'EOF'
RS_PRIVATE_KEY=<paste PEM here>
ADMIN_CLIENT_SECRET=local-dev-secret
EOF
npm run devRequired GitHub Secrets:
| Secret | Description |
|---|---|
CLOUDFLARE_API_TOKEN |
Scoped API token — use "Edit Cloudflare Workers" template |
CLOUDFLARE_ACCOUNT_ID |
Your Cloudflare account ID (32-char hex) |
npm test # run all tests
npm run test:watch # watch mode
npm run typecheck # TypeScript type checkingTests generate a fresh RSA key pair via crypto.subtle.generateKey — no external services needed.
- The RSA-2048 private key lives only in Cloudflare Secrets (
RS_PRIVATE_KEY) — never in source code or KV - Client secrets are stored hashed (PBKDF2-SHA256, 100k iterations, 16-byte random salt) — the plaintext is never persisted
- Admin credentials are compared byte-by-byte in constant time to prevent timing attacks
- JWTs expire after 1 hour; the bridge fetches a new one when < 60 seconds remain
- Revoking a client prevents new JWTs but does not invalidate already-issued ones (stateless JWTs); for immediate revocation, also rotate the RSA key pair
- Generate a new key pair:
node scripts/generate-keys.mjs - Update the secret:
wrangler secret put RS_PRIVATE_KEY - Redeploy:
npm run deploy - cf-push-relay automatically detects the new
kidon the next request, invalidates its JWKS KV cache, and fetches the updated JWKS — zero downtime required