Replies: 1 comment
|
Regarding the storage, can we generalise the storage layer for that we can store any revoked key and value. So we don't need to introduce new tables and new sql queries or Redis queries. |
0 replies
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Related Feature Issue
Token Revocation, design discussion #3321.
tfidis the prerequisite for the M3-b tranche of implicit revocation (grant-scoped behaviours), building on the merged deny-list work (#3727, #3803, #3839) and the in-review single-use rotation #3872 (M3-a).Problem Summary
ThunderID issues stateless JWT access and refresh tokens, each with its own
jti, and the revocation deny-list is keyed only byjti— one token at a time. Three revocation events in the authorization_code + refresh_token flows (the only flows that issue refresh tokens) must drop a whole login's tokens at once:The real requirement: revoke one authorization grant — the tokens from a single login+consent — precisely, without over-revoking the user's other logins or other apps. Today nothing links a token to its login (decoded tokens share only
sub,client_id,scope,grant_type,iat), so matching onsub+client_id+scopeover-revokes across independent grants.This design adds
tfid(token family id), a stable identifier for one grant's tokens, so revocation can target a whole family. (Backed by RFC 7009 §2.1 and RFC 9700 §4.14.2.)Why not
grant_idIn FAPI Grant Management,
grant_idis a client-facing resource (GET/DELETE /grants/{id}, consentmerge/replace). An internal, non-manageablegrant_idwould squat on that term and collide with real Grant Management later.tfidis internal, revocation-only, not exposed to clients. (family_idis avoided as it reads like a user attribute.)High-Level Approach
tfidis an internal JWT claim identifying a token's family, plus a family dimension on the deny-list. No issued-token store — tokens stay stateless.tfid(UUIDv7) during the login flow, at the step where the session participant is already recorded (see Logout) — so theSESSION_ID → tfidlink is written with no extra DB call.tfid→ the authorization code stores it → the token builder stamps it onto the access and refresh tokens.tfid; AS + RS enforcement reject any token carrying it, alongside the existingjticheck.Unblocks the M3-b toggles (#1, #2, #5, #6).
What
tfididentifiesA per-authorization-event UUIDv7, not
hash(client_id + subject). The same user at the same client can authorize multiple times, each independently revocable:RevokeFamily(F1)kills login #1 only. A hashed id would collapse both. The sametfidrides every token of the authorization and every rotation; a new one is minted only for a new grant.Invariant: generate once, never fork
Rotation must be atomic (check-and-invalidate the presented refresh token under one lock) so it can't be redeemed twice into two live children. This keeps 1 grant : 1 family; a fork would split a grant across two families and let revocation miss a branch.
Revocation hierarchy — one claim, coarser scopes resolve to families
The SSO session is 1:N with grants — one session (grouped by
FlowID) authorizes many apps:The token carries only
tfid. Coarser scopes need no extra claim: the AS resolves them to a set of families at revoke time from persisted relationships. Logout enumerates the session's families and revokes eachtfid. The session id is deliberately not on the token — one session backs many apps, so a session-valued claim would over-revoke on a single app's replay and would expose a session identifier (C-High) to resource servers.jtitfidtfidstfidsub/client_id/roleEnforcement matches only
jtiandtfid(plus Phase-2 attributes); session revocation adds no new match dimension, it just writes moretfidrows. Exact-jtilives inREVOKED_TOKEN; everything else shares one criteria table.Architecture Overview
ClaimTokenFamilyID(tfid) ininternal/oauth/oauth2/constants.tfidis minted in the flow at the participant-write step, and theSESSION_ID → tfidmapping is stored on that same existing write (SSO_SESSION_PARTICIPANTis upserted on every login), so there is no extra login round-trip.tfid→ stored on theAUTHORIZATION_CODErecord → the back-channel token exchange (no session context) stamps it onto AT/RT →RefreshTokenClaimscarries it so rotation copies it (no regeneration).tfid; replay is already detected (errAuthorizationCodeAlreadyConsumed,authz/service.go), so the handler reads thetfidand callsRevokeFamily(tfid), closing the existing revoke-on-replay TODO.tfidrevocation is a row in the criteria-based revocation table (a generalized attribute+time deny-list also backing subject/client/consent revocation):CRITERION_TYPE='token_family',CRITERION_VALUE=<tfid>. A revoked family is terminal, so membership alone suffices (the genericiat < revoked_atpredicate is always true here):EXPIRY_TIME = REVOKED_AT + max_refresh_token_lifetime; one prune covers all criterion types. Exact single-jtirevocation stays inREVOKED_TOKEN.revocationservice and RSrevocationcacheconsult the revoked-tfidset; thesecuritymiddleware surfacestfidalongsidejti.flowchart LR subgraph AS["Authorization Server"] GC["Login flow<br/>mint tfid F<br/>write participant SESSION_ID to F"] -->|"F via assertion then code"| TB["Token builder<br/>stamps tfid F"] TB --> AT["Access token<br/>jti and tfid F"] TB --> RT["Refresh token<br/>jti and tfid F"] RT -->|"refresh / rotation"| RH["Refresh handler<br/>copies F onto new AT and RT"] RH --> TB EV["Revoke event<br/>explicit / reuse<br/>code-replay / logout"] --> RG["Revoke-by-family<br/>write seam"] end RG -->|"criterion row token_family=F"| DB[("Runtime-persistent DB<br/>REVOCATION_CRITERIA and REVOKED_TOKEN")] DB -.->|"AS reads, RS cache syncs"| ENF["EnsureNotRevoked jti tfid"] AT -.present.-> ENF RT -.present.-> ENF ENF -->|"jti OR tfid revoked"| REJ["reject 401 / invalid_grant"] ENF -->|"neither revoked"| OK["allow"]sequenceDiagram autonumber participant C as Client participant AS as Authorization Server participant DB as Runtime-persistent DB (deny-list) participant RS as Resource Server (cache + middleware) Note over AS: Login flow (front-channel) AS->>AS: mint tfid F, write participant SESSION_ID to F Note over AS: Authorization-code exchange (back-channel) C->>AS: code + client auth AS->>AS: read tfid F from the code record AS-->>C: AT1 tfid=F, RT1 tfid=F Note over AS: Refresh (rotation) C->>AS: refresh_token = RT1 AS->>DB: revoke RT1.jti (M3-a single-use) AS-->>C: AT2 tfid=F, RT2 tfid=F Note over AS: RT1 replayed, reuse detected C--xAS: refresh_token = RT1 (stolen copy) AS->>AS: RT1.jti already revoked, reuse AS->>DB: RevokeFamily(F), one criterion row Note over RS,DB: Enforcement afterwards C->>RS: request with AT2 (tfid F) RS->>RS: EnsureNotRevoked(jti, F), F revoked RS-->>C: 401 invalid_tokenLogout and session revocation
The SSO session is persisted (
SSO_SESSION+ per-appSSO_SESSION_PARTICIPANT,runtime_persistent), established during login before any token exists: session → participant → code → token. Becausetfidis minted at the participant write, theSESSION_ID → tfidmapping (1:many, for in-session re-consent) is captured with no extra DB call, and no session id ever goes on the token. Session revocation is then resolve the session to its families, then revoke each:exp. Expiry deletes the session without revoking unlessoauth.revocation.session.cascadeis on (then the sweep runs the same revoke-then-delete).This deliberately avoids both obvious alternatives and their costs: a separately written server-side mapping (an extra login DB call) and a
sidclaim on tokens (a C-High session identifier exposed to resource servers, and off-label — RFC 9068 defines nosidfor access tokens; OIDC scopessidto ID/Logout tokens). Minting at the participant write pays neither cost.Where families exist
Only authorization_code (and its refresh continuations) issues a refresh token, so it is the only flow with a true family and the only flow with an SSO session. The rest are access-token-only, so logout/session revocation doesn't apply; they revoke by
jtior by attribute:tfidor stay independent.jtiorclient_id. Default: notfid.tfid.Security Considerations
tfid; the session id stays server-side (C-High per WSO2 data classification), so tokens leak no session grouping to resource servers and one app's revocation never touches another's.tfidkeep 1 grant : 1 family, so revocation can't miss a branch.tfidpropagation must not widen a rotated token's scope (RFC 6749 §6).tfid; family revocation is a bounded, expiry-limited no-op for them.Impacted Areas
flow/session(minttfid+ storeSESSION_ID → tfidon the participant write; revoke-on-logout, optional revoke-on-expiry) · flow assertion (carrytfid) ·authz/{service,auth_code_store,model}(storetfidon the code; revoke-on-replay) ·tokenservicebuilder +RefreshTokenClaims·granthandlers/{refresh_token,authorization_code,token_exchange}.go·revocation(write + AS enforcement) ·revocationcache+securitymiddleware ·dbscripts/runtime_persistent(newREVOCATION_CRITERIA;tfidonAUTHORIZATION_CODEand the participant mapping) · config (oauth.*M3-b toggles +oauth.token_exchange.family+oauth.revocation.session.cascade) · docs.Alternatives Considered
sub+client_id+scope— over-revokes concurrent grants, can't walk a rotation chain. Rejected.jtilineage chain — helps only the RT chain, O(n) walk. Rejected.sid(session id) on tokens for O(1) logout — exposes a C-High session identifier to resource servers / shared infrastructure (cross-app correlation), and is off-label for access tokens (RFC 9068 / OIDC scopesidto ID and Logout tokens). Rejected in favour of mintingtfidat the participant write — no session id on the token and no extra login write.grant_id. Adopt the internal identifier now, revisit FAPI separately.Questions for Community Input
tfid(notgrant_id, reserved by FAPI; notfamily_id). Agree?tfidasCRITERION_TYPE=token_familyin the shared criteria table; keep exact-jtiinREVOKED_TOKENor fold it in? Final table name?EXPIRY_TIME= longest-lived token (≈ refresh validity), or a fixed max-family-lifetime?token_exchangeinherit vs independent? Doclient_credentials/cibacarry atfidat all?tfidtokens un-revocable by family until expiry, or backfill?tfidat the participant write (mapping piggybacks an existing login write; no session id on the token) over a separate mapping write or asidclaim; andoauth.revocation.session.cascadedefault off?All reactions