feat(relayauth): per-token revocation via jti denylist + revoke endpoint
Problem: durable customer keys had no per-token kill switch
We now hand customers a durable, long-lived relay_ag_ relayfile token (cloud #3247 mints it at ~90d via the delegated-token path; SDK relayfile#454 reads its wks/sub claims; cloud #3246 gives it relayfile:agent-runtime scope so the realtime WS accepts it).
The blast radius: that token lives for ~90 days, yet the only revocable thing was the source Workspace API Key, and rotating that key does not invalidate an already-minted token. A single leaked relay_ag_ token could therefore stream against the proactive-runtime WS until natural expiry, with no way to kill it individually.
What already existed
The mint path already stamps a unique jti on every relay_ag_/relay_pa_ token, a durable revoked_tokens denylist store already exists (keyed by jti, TTL'd by the token's expiry via pruneExpiredRevocations), POST /v1/tokens/revoke already writes it (with audit + refresh-reuse cascade), and the SDK/core TokenVerifier already supports a checkRevocation option.
The gap: the GET revocation-check endpoint those verifiers poll was advertised in discovery (token_revocation_list → /v1/tokens/revocation, "Publishes token revocation state for verifiers") but was never implemented, and ai/adapter.ts wired the verifier's revocationUrl to discovery.revocation_endpoint — the POST /v1/tokens/revoke route, which can't answer a GET. So revocation was durable in storage but never actually enforced at token-use time.
The jti-denylist design & both enforcement points
jtiat mint — unchanged, reused.issueTokenPairalready setsjti: createTokenId("relay_ag_")per token. A new test asserts mint→jti present and access≠refresh.- Store — reused the existing
revoked_tokensdenylist (SQLite/D1revoked_tokens(jti, expires_at); TTL cleanup already in place). No new store. - Enforcement (one endpoint covers both points): added
GET /v1/tokens/revocation?jti=→{ jti, revoked }, backed bystorage.revocations.isRevoked. Both the server-side token-verification path and the proactive-runtime WS handshake authorizer verify via the SDKTokenVerifier, whosecheckRevocationcalls exactly this endpoint after signature/exp/scope checks — so a revoked token is refused on the next (re)connect, and a single endpoint enforces both points. - Fail-closed: a store outage propagates as 5xx here, which the SDK verifier already treats as
revocation_check_failedand rejects the token — matching RelayAuth's existing revocation-check posture. - Discovery/wiring fix: advertise the endpoint as
revocation_check_endpointin agent-configuration and haveai/adapter.tsprefer it overrevocation_endpoint(falling back for older servers), so verifiers poll the GET check rather than the POST revoke.
The revoke endpoint + its authz
Extended POST /v1/tokens/revoke (authz unchanged: relayauth:token:manage:*, same as the mint endpoints) to accept, in addition to the existing tokenId/identityId/sessionId:
{ jti }— alias oftokenId(a minted token'sjtiis its stored id), so a caller holding the durable token can revoke it by the identifier on the token.{ workspaceId, agentName }— resolves the agent identity (org-scoped) and revokes all its active tokens at once.
Returns 204 on success / 404 when nothing matched (preserving the existing contract).
Store choice & cleanup
Reused the existing revoked_tokens denylist keyed by jti with an expires_at; rows past exp are pruned on read, so a denylist entry never outlives the token it blocks.
Auth surface
GET /v1/tokens/revocation is intentionally unauthenticated (added to PUBLIC_PATHS): it reveals only a boolean for a caller-supplied jti and carries no secret, like JWKS/discovery. The POST /v1/tokens/revoke mutation stays behind relayauth:token:manage:*.
Tests
All green (packages/server 492, packages/ai 35, packages/sdk 152; types/server/ai typecheck clean). Added:
- mint →
jtipresent & unique (access≠refresh) GET /v1/tokens/revocation: non-revoked →{revoked:false}, revoked →{revoked:true}, missingjti→ 400, works unauthenticatedPOST /v1/tokens/revokeby{ jti }alias, and by{ workspaceId, agentName }(all active agent tokens land on the denylist), plus 404 when the agent has no active tokens- Existing revoke / refresh-reuse-cascade / introspect suites still pass
Notes / follow-ups
- Ties to cloud #3246 / #3247 and sdk relayfile#454 — this unblocks non-sandbox use of the durable customer key by giving it an individual kill switch.
- For streaming clients, revocation takes effect on the next (re)connect: the WS handshake re-checks the denylist, but an already-open socket is not torn down mid-stream by this change.
- The dashboard "Revoke" button is cloud-side and out of scope; cloud should call
POST /v1/tokens/revoke(with{ jti }or{ workspaceId, agentName }).
🤖 Generated with Claude Code