feat(relayauth): per-token revocation via jti denylist + revoke endpoint

Merged
KH
2mo ago
+233-5

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

  • jti at mint — unchanged, reused. issueTokenPair already sets jti: createTokenId("relay_ag_") per token. A new test asserts mint→jti present and access≠refresh.
  • Store — reused the existing revoked_tokens denylist (SQLite/D1 revoked_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 by storage.revocations.isRevoked. Both the server-side token-verification path and the proactive-runtime WS handshake authorizer verify via the SDK TokenVerifier, whose checkRevocation calls 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_failed and rejects the token — matching RelayAuth's existing revocation-check posture.
  • Discovery/wiring fix: advertise the endpoint as revocation_check_endpoint in agent-configuration and have ai/adapter.ts prefer it over revocation_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 of tokenId (a minted token's jti is 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 → jti present & unique (access≠refresh)
  • GET /v1/tokens/revocation: non-revoked → {revoked:false}, revoked → {revoked:true}, missing jti → 400, works unauthenticated
  • POST /v1/tokens/revoke by { 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

https://claude.ai/code/session_015WYmTdAm9bL6JEUV8R73oL