fix(oauth): make device code and refresh token single-use#203
Merged
Conversation
- Consume the device code atomically before issuing tokens so a concurrent exchange cannot mint a second valid token pair - Revoke the rotated refresh token via a compare-and-swap guard so two concurrent refreshes cannot both succeed - Reject the losing concurrent caller with a clear error instead of issuing duplicate credentials - Skip token-family revocation on same-instant refresh races to avoid penalizing legitimate client retries - Return rows-affected from device code deletion and add an active-only refresh revoke method - Cover the races with store-level and concurrent service-level tests Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Codecov Report❌ Patch coverage is 📢 Thoughts on this report? Let us know! |
Contributor
There was a problem hiding this comment.
Pull request overview
Fixes two TOCTOU concurrency races on /oauth/token so that an authorized device code and a rotation-mode refresh token are truly single-use. Both flows now consume the credential via a compare-and-swap DB update (rows-affected) before issuing new tokens; the losing concurrent caller gets a clear OAuth error and no new token chain is minted.
Changes:
- Device code is deleted atomically before token issuance in
ExchangeDeviceCode;DeleteDeviceCodeByIDnow returns(rowsAffected, error)and a 0 rows-affected result maps toErrAccessDenied. - Refresh-token rotation now uses a new CAS store method
RevokeRefreshTokenIfActive(WHERE id=? AND status='active') inside the transaction; the loser rolls back and returnsinvalid_grant(deliberately no family revoke for same-instant races). - Interface, mocks, and tests updated; new concurrency tests (
token_single_use_test.go) plus store-level CAS/delete tests assert exactly-one-winner semantics.
Reviewed changes
Copilot reviewed 9 out of 10 changed files in this pull request and generated no comments.
Show a summary per file
| File | Description |
|---|---|
| internal/core/store.go | Updates DeleteDeviceCodeByID signature and adds RevokeRefreshTokenIfActive to the writer interface. |
| internal/store/token.go | Implements the CAS RevokeRefreshTokenIfActive (active → revoked guarded update). |
| internal/store/device_code.go | DeleteDeviceCodeByID now returns rows-affected for single-use enforcement. |
| internal/services/token_exchange.go | Consumes device code via atomic delete before token issuance; rejects 0-rows as ErrAccessDenied. |
| internal/services/token_refresh.go | Rotation path revokes old refresh token via in-transaction CAS; loser returns ErrInvalidRefreshToken. |
| internal/services/device.go | Adapts DeleteDeviceCodeByID call sites to the new two-value signature. |
| internal/services/token_single_use_test.go | New concurrent tests proving exactly-one-winner for device-code exchange and refresh rotation. |
| internal/store/token_test.go | Adds unit tests covering active/revoked/disabled/missing branches of RevokeRefreshTokenIfActive. |
| internal/store/device_code_test.go | Adds rows-affected assertions and an "already deleted" case. |
| internal/mocks/mock_store.go | Regenerated mocks for the changed/new store methods. |
Files not reviewed (1)
- internal/mocks/mock_store.go: Language not supported
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- Clarify that device codes are consumed at token exchange and reject concurrent duplicate exchanges - Document compare-and-swap revocation that makes refresh-token rotation atomic single-use under concurrency - Note that same-instant refresh retries are not treated as token theft Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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.
Summary
Closes two concurrency races that let a single credential be redeemed more than once on
/oauth/token. A refresh token in rotation mode and an authorized device code are now both truly single-use: concurrent redemptions of the same credential result in at most one new token pair, and the losing caller gets a clear OAuth error instead of a second set of valid credentials.AI Authorship
make generate && make fmt && make lint && make test(all green), plus an automated multi-agent simplify/cleanup pass.Change classification
This sits on the OAuth token issuance/refresh hot path and changes a shared store interface (
DeviceCodeStore.DeleteDeviceCodeByIDsignature) used across services.Plan reference
Implements the goal of
plan.md(batch B, issues #4 + #13): make refresh-token rotation and device-code exchange atomic single-use.UPDATE … WHERE id=?, and theIsActive()status check ran in-memory outside the transaction. Two concurrent refreshes with the same active token both passed the check and each minted a valid token chain.ExchangeDeviceCodeissued tokens first and deleted the device code last (swallowing the delete error), so two concurrent exchanges both issued tokens.What changed
token_exchange.go): the device code is now consumed via an atomic delete that returns rows-affected, before token issuance.RowsAffected == 0→ reject withaccess_denied. Mirrors the existing auth-code flow (MarkAuthorizationCodeUsedruns before issuance).DeleteDeviceCodeByIDnow returns(int64, error).token_refresh.go): the old refresh token is revoked inside the transaction via a new compare-and-swap store methodRevokeRefreshTokenIfActive(UPDATE … WHERE id=? AND status='active'). The loser seesRowsAffected == 0, rolls back its transaction, and returnsinvalid_grant.!IsActive()→revokeTokenFamilyWithAuditpath, which is unchanged.UpdateTokenStatusis intentionally left unconditional — the admin enable/disable/revoke paths depend on it, which is why a separate CAS method was added rather than guarding the shared one.Verification
RevokeRefreshTokenIfActive(active→1, revoked/disabled/missing→0) andDeleteDeviceCodeByID(exists→1, already-deleted→0)-raceENABLE_TOKEN_ROTATION=true, fire two concurrentcurlrequests with the same refresh token at/oauth/token→ expect one200and one4xx.Verifiability check
WHEREclauses + rows-affected handling and the concurrency testsSecurity check
/oauth/token(unchanged)invalid_grant/access_denied), no internal leakageRisk & rollback
DeleteDeviceCodeByIDsignature would fail to compile (caught by build).git revertfully restores prior behavior with no data-compatibility concerns.Reviewer guide
internal/store/token.go(RevokeRefreshTokenIfActiveCAS),internal/services/token_refresh.go(transaction CAS + rollback semantics, no-family-revoke decision),internal/services/token_exchange.go(consume-before-issue ordering),internal/store/device_code.go(signature change).internal/mocks/mock_store.go(regenerated by mockgen, not hand-edited),internal/core/store.go(interface signatures), the test files (verify the concurrency assertions match the intended invariants).🤖 Generated with Claude Code