fix(security): make single-use token primitives atomic - #736
Conversation
Authorization codes and refresh tokens were validated by a read followed by a separate delete, so concurrent redemption handed the same credential to every caller. Reproduced: 39/40 concurrent code redemptions returned the code to both callers, and two simultaneous refreshes both minted an independent rotating family, defeating OAuth 2.1 6.1 reuse detection. DeleteOAuthStateByKey and DeleteSessionTokenByUserIDAndKey now report whether THIS call removed the row, decided by one atomic operation per backend: SQL RowsAffected, Mongo DeleteOne, Arango REMOVE RETURN OLD, Cassandra DELETE IF EXISTS (LWT), DynamoDB conditional delete, Couchbase KV Remove. Callers gate issuance on winning the claim. Also fixed: - DB-backed memory store kept its cache in a process-local map, so SAML assertion replay defence, RFC 7523 jti replay and OTP lockout counters were per-replica. Moved into authorizer_session_tokens, which has an indexed (user_id, key_name), a real expires_at and an existing reaper. - client_credentials ignored RFC 8707 resource, so every service account token shared one aud and was valid at every internal service. Auth0 requires audience here; Keycloak binds it via mappers. - token-exchange accepted any opaque string as resource where /authorize enforced absolute-URI. - Session expiry was exclusive on the DB store only, keeping entries alive one second past expiry. - Warn at startup when --url is unset (GHSA-m82j-rq33-qjx2): reset links are then built from attacker-controllable Host headers. Known gaps left documented in place, each needing a design change rather than a patch: stale roles on refresh (needs effective-role re-derivation; narrowing to user.Roles strips org-group-derived roles and locks out SCIM users), delegated-token revocation (needs a resource registry), concurrent AutoMigrate on a fresh shared database. Verified: go vet, lint, SQLite suite, memory_store with Redis, test-all-db across 7 backends, e2e 75/75, smoke.
Review passCI green across all 5 required checks (Go tests, golangci-lint, lint & breaking, release smoke, govulncheck). Hygiene: no secrets, no debug statements, no stray On the three Highest-risk area for a reviewer to focus on: the storage-interface signature change touches all seven backends, and only SQL is covered by the default CI job. Second focus area: Not approved by me — I authored this, and self-approval is both blocked by GitHub and inappropriate for a security-sensitive diff of this size. Two changes in an earlier pass of this same work were wrong (documented in the description); that is the argument for a genuine second reader rather than a formality. |
Why
Authorization codes and refresh tokens were validated by a read followed by a
separate delete. Concurrent redemption therefore handed the same credential to
every caller. Both reproduced against a real build:
code to both callers (RFC 6749 §4.1.2 single-use defeated).
200and each minted anindependent, separately-rotating token family from one refresh token
(OAuth 2.1 §6.1 reuse detection defeated).
Neither was reachable by the existing suite: every replay assertion in the repo
is sequential (
awaitfirst, then send second), which a read-then-delete passesperfectly, and there was not one
Promise.allanywhere in the e2e specs.What changed
Atomic single-use claims.
DeleteOAuthStateByKeyandDeleteSessionTokenByUserIDAndKeynow return whether this call removed therow, decided by one atomic operation per backend:
RowsAffectedDeleteOne+DeletedCountREMOVE … RETURN OLDDELETE … IF EXISTS(Paxos LWT)attribute_existsRemove+ErrDocumentNotFoundRedis and in-memory were already correct.
TokenHandlergates issuance onwinning the claim; the loser gets the same opaque
invalid_granta replay does.DB-backed cache was process-local. The store selected for any deployment
with a database and no
REDIS_URLkept its cache in a package-level map, soSAML assertion single-use, RFC 7523
jtireplay and OTP lockout counters wereper-replica — N replicas granted N× the attempts. Moved into
authorizer_session_tokens, which has an indexed(user_id, key_name), a realexpires_at, and an existing reaper. (oauth_stateshas no reaper at all.)M2M audience binding.
client_credentialsignored RFC 8707resource, soevery service-account token carried the same
audand was valid at everyinternal service. Auth0 makes
audiencemandatory on this grant; Keycloak bindsit via audience mappers. Omitting
resourcekeeps the previous behaviour.Also: token-exchange accepted any opaque string as
resourcewhere/authorizeenforced absolute-URI; DB-store session expiry was exclusive(entries lived one second past expiry) where in-memory and Redis are inclusive;
startup now warns when
--urlis unset (GHSA-m82j-rq33-qjx2 — reset links areotherwise built from attacker-controllable
Hostheaders).New coverage for the blind spots
internal/memory_store/single_use_test.go— concurrent claim contract, runagainst every provider. The
dbprovider was declared in the test matrixbut never exercised; adding it immediately surfaced the expiry off-by-one.
e2e-playground/tests/concurrency.spec.ts— fires redemptions withPromise.allinstead of sequentially. This is what caught the refresh race.e2e-playground/tests/replica-shared-state.spec.ts+ two authorizer replicasover a shared Postgres — the first place two instances share state at all, so
cross-replica cache coherence is testable.
internal/parsers/host_header_injection_test.go— pins both halves ofGHSA-m82j-rq33-qjx2 (mitigated with
--url, vulnerable without).make e2e-playgroundnever rebuilt the Playwright image, so any spec editwas silently ignored and the stale suite re-ran. Added
--build.Known gaps, documented in place — not fixed here
Each needs a design decision rather than a patch:
stripped role keeps being minted. Do not intersect with
user.Roles: SSOand SAML build session roles as
unionRoles(splitRoles(user.Roles), orgGroupDerivedRoles(...)), where thederived half comes from the FGA engine. Intersecting strips every
org-group-derived role and, for SCIM users (created with no
Rolesfield),rejects the grant outright. Verified:
["user","org_admin"] ∩ "user" → ["user"].Correct fix re-derives effective roles at refresh, which needs org context in
the token plus an FGA lookup.
only bound; a prior comment claiming
/oauth/introspectenforced it wasfalse and is corrected. Real revocation needs a resource registry.
AutoMigratekills a replica on a fresh shared database.Worked around in the e2e compose with a comment; wants an advisory lock.
Fault tolerance
Fail-closed on security gates, fail-open on availability aids — matching the
existing philosophy. A claim error returns a retryable
503, notinvalid_grant, so a store fault is never reported as permanently-badcredentials. On error the claim bool is always
falseeven if a row was alreadyremoved (fail-safe direction), written into the interface contract.
Residual, all documented and all absent when
REDIS_URLis set: the DB cachefails open on read faults (the storage layer cannot distinguish "no row" from
"read failed" — 7 different error types),
SetCacheis delete-then-insert so apartial failure loses the key, and
IncrementCachecan undercount under exactcross-replica concurrency. Multi-instance production should use Redis, not
just a shared database.
Verification
All run on the final tree:
go build/go vetmake lintgo test ./...(SQLite)make test-all-db(7 backends)make e2e-playgroundmake smoketest-all-dbearned its keep: the first run failed on ScyllaDB only, becauseScanCAS()with no destinations cannot read an LWT result set that carries therow's columns (
have 1 want 8). Every claim errored tofalse, which wouldhave made Cassandra/ScyllaDB deployments unable to redeem any code or refresh
token at all. Fixed with
MapScanCAS, matching the existing LWT usage inorg_domain.go.Review notes
Two changes in an earlier pass of this work were wrong and were reverted or
reworked after review — the role narrowing above, and the cache initially landing
in
oauth_states(a table with no reaper, scanned on every successful OTPverification). Both are called out because the diff is large and
security-sensitive; the storage-interface changes in particular touch all seven
backends.