security(oauth): make revocation visible at introspect and revoke - #774
Merged
Conversation
The session entry is this codebase's only revocation record — logout, password reset, admin session-wipe and /oauth/revoke all revoke by deleting it — and neither token endpoint consulted it. /oauth/introspect checked signature, exp, iss, aud and the user's RevokedTimestamp, so a token ValidateAccessToken already rejected still answered active:true and disclosed sub, scope and aud with it. Any resource server trusting the endpoint accepted a logged-out token for the rest of its TTL (RFC 7662 §2.2). /oauth/revoke accepted token_type_hint=access_token as supported, then only ever looked up refresh_token_<nonce>; an access token matched nothing and got a 200 with nothing revoked. RFC 7009 §2.2 mandates that 200 either way, so no client could tell. The hint now orders the lookup rather than restricting it, per §2.1. Both endpoints read the entry through one helper so the two cannot drift, and id_token is carved out by token type: it is never registered in the store, so requiring an entry would report every one inactive. Notes for reviewers: - A store outage now answers inactive, matching validateStatefulAccessToken. subjectLiveness argues the other way for exactly this reason; distinguishing absent from unavailable needs GetUserSession to grow a known-flag across every provider. Follow-up. - Resource-bound access tokens stay unrevocable here: the ownership guard rejects them before the lookup, as before. - Revoking an access token drops the browser session too — DeleteUserSession clears all three entry types for the nonce. Already true for refresh tokens; per-type deletion needs an interface change. - setupIntrospectTest never registered a session, so TestIntrospectActiveAccessToken was asserting an unregistered token is active. Corrected. - TestDeprovisionedUserRevocation deletes sessions before introspecting, so its inactive result no longer proves the RevokedTimestamp branch runs. That assertion moves to the companion test that keeps the session live.
This was referenced Aug 15, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Found by a full-repo security/protocol review. Two defects, both on the
revocation path, both proven by a test that fails without the fix.
The shared cause
The memory-store session entry is this codebase's only revocation record.
Logout, password reset, admin session-wipe and
/oauth/revokeall revoke bydeleting it, and
validateStatefulAccessTokenreads it on every authenticatedrequest. Neither token endpoint did.
F1 — introspection reported revoked tokens as active
/oauth/introspectvalidated signature,exp,iss,audand the user'sRevokedTimestamp— never the session entry. A tokenValidateAccessTokenalready rejected answered
active:trueand disclosedsub,scopeandaudwith it. Any resource server trusting the endpoint accepted a logged-out or
explicitly revoked token for the rest of its TTL. RFC 7662 §2.2 defines
activeas "has not been revoked".The handler's own doc comment stated the property it broke: "Always returns
{"active": false}for any inactive, invalid, or unknown token."F2 — revocation silently ignored access tokens
/oauth/revokeacceptedtoken_type_hint=access_tokenas supported, then onlyever looked up
refresh_token_<nonce>. An access token matched nothing and gota
200 {}with nothing revoked. RFC 7009 §2.2 mandates that 200 either way, sono client could tell. The hint now orders the lookup rather than restricting it,
per §2.1 ("MAY ignore the hint").
Shape of the fix
One helper (
session_lookup.go) read by both endpoints, so the two cannotdrift.
id_tokenis carved out by token type — it is never registered in thestore, so requiring an entry would report every one inactive.
Reviewer notes — things this deliberately does not do
inactive. MatchesvalidateStatefulAccessToken.subjectLivenessargues the other way for exactly this reason: anunreachable DB must not become "not active" and deny the fleet at once.
Distinguishing absent from unavailable needs
GetUserSessionto grow aknownflag across every provider — out of scope, worth a follow-up issue.rejects them before the lookup, unchanged. "Access tokens are now revocable"
would be a false changelog line.
DeleteUserSessionclears all three entry types for the nonce. Already truefor refresh tokens; per-type deletion needs a memory-store interface change
across all six providers.
Test expectations corrected
setupIntrospectTestnever registered a session, soTestIntrospectActiveAccessTokenwas asserting an unregistered token isactive. Fixed to mirror the real login flow.
TestDeprovisionedUserRevocationdeletes sessions before introspecting, soafter this change its
inactiveresult is satisfied by the session check andno longer proves the
RevokedTimestampbranch runs. That assertion moves tothe companion test that keeps the session live.
Verification
Each new test confirmed failing with the source reverted and the tests kept.
make test-all-dbnot run: no storage provider touched, memory-store interfaceused rather than changed.