Skip to content

Introspection and revocation trust only verified, persisted state

The shared introspect/revoke handler (ClientCredentialsGrant.tokenFromRequest and its two callers) parsed presented tokens with jwt.decode() — no signature check — then spread the unverified payload last over the DB-derived response fields, dispatched the refresh-token lookup only when token_type_hint=refresh_token was sent, never consulted the revocation seams, and could not resolve opaque refresh tokens at all (audit findings 1–4). We decided the trust model wholesale: a presented token's claims are trusted only after the configured JwtInterface.verify succeeds (with expiry and not-before ignored at parse time, since active derives from stored state and an expired token must still be revocable); the token's type is identified from the verified payload shape (jti ⇒ access, refresh_token_id ⇒ refresh — disjoint by construction), making the Token Type Hint purely advisory; non-JWT strings resolve through the grant's RefreshTokenEncoder only when useOpaqueRefreshTokens is set, so the rebuilt payload keeps revoke's ownership check intact; the introspection response spreads the verified payload first so persisted fields (active, scope, client_id, token_type) always win; and Active means: record found, stored expiry in the future, and not revoked per isRefreshTokenRevoked (refresh) / the optional isAccessTokenRevoked (access). Repository lookups treat rejection as not-found (.catch(() => undefined)), so an unknown token introspects as active: false instead of surfacing a 500 (RFC 7662 §2.2).

Considered Options

  • Keep decode() but build the response solely from the persisted entity — rejected: it fixes response spoofing and survives key rotation, but revoke's ownership check would still compare an unverified cid/client_id, letting an attacker who can authenticate as any client forge ownership and cross-revoke another client's token.
  • Strict verification (no ignoreExpiration/ignoreNotBefore) — rejected: an expired access token whose refresh token still lives must remain revocable, and RFC 7009 wants revocation of expired tokens to succeed silently rather than fail parsing.
  • Hint-first dispatch with fallthrough to the other type — rejected: with disjoint payload shapes the second lookup can never succeed when the first failed for shape reasons; it adds dead repository calls and a more convoluted branch structure for the same observable behavior.
  • Route introspection's verification through the OIDC AccessTokenVerifier seam (ADR 0004) — deferred, not adopted here: that verifier pins typ: at+jwt/RS256 and rejects expired and refresh tokens, so it cannot serve the non-OIDC HS256 path or refresh JWTs without modification. JwtService.verify already pins the algorithm; the DB-wins response ordering backstops a weak consumer-supplied verifier.

Consequences

  • Key rotation now has teeth: a token signed by a retired key is indistinguishable from a forged one — it introspects as active: false and cannot be revoked by presenting the JWT. Operators rotating signing keys must revoke or drain outstanding tokens server-side. This is the deliberate price of fail-closed (ADR 0004); decode()'s rotation-tolerance was inseparable from its forgeability.
  • A consumer-supplied JwtInterface must honor VerifyOptions.ignoreExpiration, or revocation of already-expired tokens silently no-ops (introspection is unaffected — expired is inactive either way). Documented in the upgrade guide.
  • The introspection suite's hardcoded JWT fixtures did not verify against the test secret — the old tests passed because of the vulnerability. Fixtures are signed at runtime from here on.
  • Opaque refresh tokens become revocable and introspectable, making the docs' "full OAuth 2.0 compliance" claim for useOpaqueRefreshTokens true.
  • Flag-based revocation deployments (live row, future expiry, revoked marker) are honored by introspection via the same seams the refresh grant and UserInfo already use; deployments that delete rows or zero expiry see no change.