Repository navigation
fix(oauth2): revoke tokens issued from a replayed authorization code - #93
Merged
Merged
Conversation
Every store deleted an authorization code as it consumed it. A replay then looked exactly like an unknown code, and nothing was left to tell the server which client the code was issued to or what its first exchange had issued, although RFC 6749 §4.1.2 says the tokens issued from a replayed code SHOULD be revoked. The memory store also kept the raw code in its record, which the model says no store may persist. Consuming a code now flags it instead of deleting it. The record stays, without the raw code, at least until the code expires, and every later consumption gets nil and an oauth2.CodeReplayError carrying it: a new error type that reads and matches as ErrCodeAlreadyUsed, so callers that test err first, IsCode and the wire response are unchanged. An unknown code still gets the bare ErrCodeAlreadyUsed, and exactly one caller gets the record with a nil error, as before. The SQL store flags the row write-first in one transaction (inTx, so READ COMMITTED on PostgreSQL): an UPDATE ... WHERE consumed = 0 picks the winner through RowsAffected, the record is then read in the same transaction, and losers roll back. oauth2_auth_codes gains a consumed SMALLINT column, which Migrate does not add to an existing table. The Redis script flags the code's JSON with SET ... KEEPTTL, so the record expires with the code, and records a per-call nonce: a script go-redis resends after a lost reply finds its own mark and still wins, where the former GET+DEL script made such a resend fail the exchange with invalid_grant. The script compares the flag with true because cjson decodes a JSON null as a truthy value. The storage contract, the SQL cleanup guidance (consumed and abandoned codes stay until an operator deletes the rows past expires_at, which deserves an index), the PostgreSQL READ COMMITTED requirement, which now covers code consumption, and the memory store's growth are documented; MIGRATION.md lists what custom stores, callers, SQL deployments and rolling upgrades need. The grant still refuses a replay without revoking anything: that comes next. Refs #80.
RFC 6749 §4.1.2 says the tokens previously issued from a replayed
authorization code SHOULD be revoked, and RFC 9700 §4.2.4 relies on it
against stolen codes. The grant refused a replay but left the tokens of
the first exchange alive: an attacker who redeemed an intercepted code
first kept an access token and a refresh token it could rotate at will.
Nothing linked a code to its tokens either: issuance picked a random
token family after the code was consumed.
The authorization_code grant now derives the token family from the code
hash — a domain-separated SHA-256, 22 characters like a random family
ID — before consuming the code, so the first exchange and every replay
agree on it without storing the link. A replay the store reports with
its retained record goes through the validation an exchange goes
through (expiry, client, redirect_uri, PKCE, grant type, in their
existing order). Only when it passes, i.e. when the request would itself
have been accepted (OAuth 2.1 §4.1.3, §7.5.3), does the grant revoke
the family, on a context detached from the request; a failure is
reported to grant.Config.OnError ("revoke token family failed after
authorization code replay"), and the next proven replay retries it. A
public client_id is no secret, so unknown codes, other clients and
missing, wrong or malformed verifiers revoke nothing. The replay still
answers the bare ErrCodeAlreadyUsed.
Revoking the family revokes every token the code issued and every later
rotation. Since the family is known before the code is consumed, a
proven replay landing between the first exchange's consumption and its
issuance revokes the family first, and the family gate of SaveTokenPair
then makes that exchange fail with invalid_grant ("token family
revoked") instead of issuing — as long as the store keeps the revoked
family record. The legacy password grant, which has no code, keeps a
random family.
storetest drives the real grant over every backend: a replayed code's
tokens become unusable, while another code's tokens and a family-less
token stay usable. The storage, grant and package docs, the security
considerations (with the offline JWT caveat and the deployment
requirements the race guarantee rests on), observability, architecture,
LIMITATIONS and the example README describe the behavior. Refs #80.
…contract
Review follow-ups for the authorization-code replay revocation.
An unproven replay — another client, a missing, wrong or malformed
verifier, another redirect_uri, an expired code, a client no longer
allowed the grant — now has its answer pinned to the bare
ErrCodeAlreadyUsed ("authorization code already consumed"), the answer
of any replay and of an unknown code, so the response tells nothing
about which check failed. The former pins only checked the invalid_grant
code, which a grant returning the validation error would have passed.
The revocation a replay triggers is now checked to be bounded by the
detached revocation timeout, not only detached from the request, and
the replay guard also refuses a typed-nil CodeReplayError, which used to
panic.
The storage contract now says that a given CodeHash is saved at most
once — the Redis store overwrites, so a consumed code saved again
becomes redeemable again — and that a decorator of
ConsumeAuthorizationCode returns the store's error unchanged or wrapped
with %w, since normalizing it to ErrCodeAlreadyUsed silently disables
replay revocation. The docs describe both outcomes of a replay racing
the first exchange — it fails that exchange or revokes its tokens — say
that a late replay is refused like any replay, and cite RFC 9700
§4.2.4. The Redis comments explain what the null-flag test checks and
the non-string script reply, and the test doubles lose their dead
nolint directives. Refs #80.
Coverage Report for CI Build 38016983323Coverage increased (+0.3%) to 94.768%Details
Uncovered Changes
Coverage RegressionsNo coverage regressions found. Coverage Stats
💛 - Coveralls |
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.
Closes #80.
Problem
RFC 6749 §4.1.2: if an authorization code is used more than once, the
authorization server MUST deny the request and SHOULD revoke all tokens
previously issued based on that code. The MUST held — consumption is atomic
in every store — but nothing implemented the SHOULD:
delete), SQL (DELETE),Redis (
GET+DELscript): a replay looked exactly like an unknown code,and nothing was left to say which client the code was issued to.
family after the code was consumed.
the first exchange alive. This is the case RFC 6749 §10.5 and RFC 9700
§4.2.4 guard against: an attacker who redeems an intercepted code first
keeps an access token and a refresh token it can rotate at will, and the
legitimate client, whose own exchange is refused, has no way to cut it off.
Reproductions — the red phase of the committed tests, run on the pre-fix
base:
invalid_grant, but/introspectstill answers{"active":true,…}for the first access and refresh token, andLookupAccessTokenfinds the access tokeninvalid_grant(the resentGET+DELfound nothing)Also found on the way: the memory store kept the raw code in its record,
which the model says no store may persist; and the
Storage/ model godocclaimed the storage layer hashes the code, while the authorization endpoint
sets
CodeHash.Fix
The family is derived from the code.
codeFamilyID(hash)=base64url(SHA-256(
"oauth2/code-family:"+ hash)[:16]) — 22 characters likea random family ID, domain-separated — is computed before the code is
consumed. The first exchange and every replay agree on it with no storage for
the link, and a replay can revoke it even before the first exchange has
issued into it. The legacy password grant, which has no code, keeps a random
family.
Consumed codes are retained. A store flags a consumed code instead of
deleting it and keeps the record — hash only, never the raw code — at least
until
ExpiresAt. A replay getsniland a new*oauth2.CodeReplayErrorcarrying the record; it reads and matches as
ErrCodeAlreadyUsed(
errors.Is,IsCode, the wire). An unknown code still gets the baresentinel, and exactly one caller gets the record with a nil error.
oauth2_auth_codes.consumed SMALLINTcolumn. Consumption iswrite-first in one transaction (
inTx, hence READ COMMITTED on PostgreSQL,now true of every store transaction):
UPDATE … SET consumed = 1 WHERE code_hash = ? AND consumed = 0picks the winner throughRowsAffected,the record is read in the same transaction, losers roll back. Rows stay
until the operator's cleanup.
GET,cjson.decode; a payload whoseconsumed == trueis returned as is (a replay); otherwise the script setsconsumedandconsumed_by= the call's nonce (crypto/rand.Text()) withSET … KEEPTTL— the key expires with the code — and returns the payloadas it found it. The nonce lets a go-redis resend after a lost reply
recognize its own win instead of reading as a replay; the explicit
== truematters because cjson decodes a JSON null as a truthy value.A replay revokes only when it proves possession (OAuth 2.1 §4.1.3,
§7.5.3).
validateExchange— expiry, client,redirect_uri, PKCE (#79'sverifyPKCE, unchanged), grant type, in their existing order — runs for theexchange and for the replay; the code's family is revoked only when it
passes. Unknown codes, other clients, missing, wrong or malformed verifiers,
another
redirect_uri, an expired code and a client no longer allowed thegrant revoke nothing: a public
client_idis no secret, and anyone holdinga leaked code could otherwise cut the client off. No oracle: every replay,
proven or not, answers the bare
ErrCodeAlreadyUsed("authorization codealready consumed"), the answer of an unknown code, so the response never
tells which check failed.
Detached revocation. The revocation runs on #82's detached context
(
context.WithoutCancelplus a 10s timeout), so a client hanging up cannotabort it. A failure reaches
grant.Config.OnErroras oneserver_error(
revoke token family failed after authorization code replay, its causenaming the family); the code record is retained, so the next proven replay
retries. A replay without a record — a
CodeReplayErrorwithout one, or atyped nil — is refused like an unknown code.
Race outcomes. The consumption and the family record are independently
linearizable; #82's family gate orders the replay's revocation V and the
first exchange's
SaveTokenPairS:ErrTokenFamilyRevokedand persists nothing: thefirst exchange answers 400
invalid_grant"token family revoked".Either way no token of the code stays usable — the second outcome as long as
the store keeps the revoked family record (I5, I8), which the deployment
requirements #82 introduced protect (Redis
noeviction, no SQL family rowdeleted before its
expires_at).Docs: the
AuthorizationCodeStorecontract (retention,CodeReplayError,save each
CodeHashat most once, wrappers propagate the error), the"Token families" note on
Storage, the grant flow, the SQL and Redis packagedocs (consumed flag, cleanup and an
expires_atindex, KEEPTTL, evicted codekeys, resend safety),
CHANGELOG.md,MIGRATION.md,LIMITATIONS.md,docs/security-considerations.md(new "Authorization-code replay" bullet;offline JWTs are out of reach),
docs/observability.md,docs/architecture.md, the example README.Breaking changes
Nothing is tagged yet, but code tracking
masteris affected — seeMIGRATION.md.AuthorizationCodeStorecontract —ConsumeAuthorizationCodekeepsthe consumed record at least until
ExpiresAtand answers a replay withniland a*oauth2.CodeReplayError; an unknown code still getsErrCodeAlreadyUsed. Callers testerrfirst and never issue from arecord that comes with an error; decorators must return the store's error
unchanged or wrapped with
%w(normalizing it disables replayrevocation). Custom stores must pass
storetest. New exported type:oauth2.CodeReplayError.oauth2_auth_codesgainsconsumed SMALLINT NOT NULL DEFAULT 0,which
Migratedoes not add to an existing table: runALTER TABLE oauth2_auth_codes ADD COLUMN consumed SMALLINT NOT NULL DEFAULT 0(PostgreSQL, MySQL, SQLite) or drop the table and
Migrateagain. Untilthen every code exchange fails closed with
server_error. Consumed codesstay until the operator deletes the rows past
expires_at.consumed/consumed_by; a payloadwithout them is an unconsumed code. Redis ≥ 6.0 and
maxmemory-policy noevictionare already required by oauth2/store: make refresh-family revocation reliable across races and storage failures #82.Code.build ignores the flag and would accept a code an upgraded instance already
consumed.
invalid_grant; a first exchange losingthe race to a proven replay now gets 400
invalid_grant("token familyrevoked") instead of 200; tokens issued from a replayed code become
inactive.
Tests
storetest — run by memory, the suite itself, SQLite and Redis: a
consumed code comes back without its raw value; every replay — sequential,
and every loser of 50 concurrent consumers — gets a
CodeReplayErrorcarrying the full record (all fields compared), never a code; an unknown
code is no replay; an empty hash is refused. A new (linted)
storetest/code_replay.godrives the realauthorization_codegrantover each store: the replayed code's tokens become unusable while another
code's tokens and a family-less token stay usable — reproduction (b), per
backend.
SQL (
store_code_test.go) — the consumed row is kept, flagged; thedocumented cleanup drops expired codes (their replay reads as unknown) and
keeps the others recognizable; a read failing after the write rolls the
consumption back.
Redis (
store_code_test.go) — the consumed key keeps its TTL and expireswith the code (
FastForward); payloads without the flag or with a null oneare consumed once; an undecodable payload is an error; a consume script
resent by a hook (same nonce) still wins — reproduction (d).
Grant (
authorization_code_replay_test.go, a decorator over memory) —the issue's reproduction (G1) including a refresh refused afterwards;
rotated tokens and access-only issuance revoked; a proven replay injected
between consumption and issuance refuses the exchange, which persists
nothing; eight unproven-replay rows each revoke nothing and answer exactly
ErrCodeAlreadyUsed, then the positive control revokes; isolation betweentwo codes (distinct 22-character families); a failed revocation reported
once, tokens still usable, then revoked by the next proven replay; a
canceled request still revokes, on a context bounded by the 10s timeout;
record-less and typed-nil replay errors; 16 concurrent redemptions (at most
one success, every error
invalid_grant, every loser revokes, a winner endsup revoked); the password grant keeps fresh families.
HTTP (
code_replay_test.go) — reproduction (a) over/token,/introspect, the store and a refresh; five unproven replays of a publicclient's code (a second public client, no or a wrong verifier, a
confidential client, an unknown code) keep the token active and answer the
exact "authorization code already consumed"; the losing first exchange
answers "token family revoked" with no
access_token, and the hooks see twoinvalid_grant, noserver_error. PlusCodeReplayErrorunit tests.Red phase — the tests were written first and run against the previous
behavior; the failures are the reproductions (a)–(d) plus: memory kept the
raw code, replays carried no record, rows and keys were deleted, no
revocation and no hook report, the losing exchange issued. The review-round
pins (the no-oracle answer, the timeout) hold on the fix and were checked
against their mutants instead; the typed-nil case was red (a panic).
Mutation — my runs killed: revoke without proof, a random family,
revocation on the request context, no Redis nonce check, no nil / typed-nil
guard, a replay answering with the validation error, a revocation without
timeout. Reviewer A ran 19 mutants, 16 on the stores and 3 on the grant (17
caught; the two misses are a truthy null-flag check, which miniredis cannot
show and which was verified on a real Redis, and a defensive guard with no
observable effect); reviewer B ran 17 grant/HTTP mutants, and the survivors
it reported (the validation-error answer, the missing timeout) are killed by
the last commit.
Real engines (reviewer A) — the conformance suite and concurrent-consume
races on PostgreSQL 17 (database default isolation READ COMMITTED,
REPEATABLE READ and SERIALIZABLE; the store pins READ COMMITTED), MySQL 9.6
(REPEATABLE READ, READ COMMITTED, SERIALIZABLE), Redis 8.8 and a
multi-connection file SQLite; a consumer blocked behind a holder that
commits or rolls back; on Redis, cjson's truthy null, the null-flag payload
and a lost-reply retry (go-redis retries 0 and 3); the
ALTER TABLEmigration on PostgreSQL and MySQL (an unaltered table fails closed).
Stability — the race-sensitive tests pass with
-race -count=30(memory, storetest, grant, HTTP, SQLite, Redis).
Checks
make build— OK.make test— every package passes with-race:oauth2oauth2/grantoauth2/pkceoauth2/storage/memoryoauth2/storetestoauth2/store/sqloauth2/store/redisThe storetest figure moves by a few tenths between runs with the outcomes
of the token-family race cases. What remains uncovered in the new code is
unreachable: the moved
newFamilyIDerror branch (crypto/randcannotfail), SQL
RowsAffected, a non-string Redis script reply.make lint— 0 issues;gofmt -lclean on every changed file.Follow-ups (out of scope)
ServerConfig.OnErrorcannot tell a validated replay — a possible codetheft — from a garbage code: both are an
invalid_grant. A dedicatedsecurity-event signal could follow.
/authorizestores the resolved redirect URI when the request omittedredirect_uri, and/tokenthen requires it, while RFC 6749 §4.1.3requires it only if it was sent.
client checks (pre-existing, RFC-compliant); this PR guarantees that such
a burn revokes nothing.
exercised by the review, not by a job).