Auth: consume refresh tokens atomically to close a replay window (closes #147) - #148
Conversation
#147) refresh() read the token row, then deleted it, then issued new tokens. Two requests with the same token — a real client and a replayed stolen one — could both read the row before either delete (the second delete hits 0 rows without erroring), so both minted a fresh access+refresh pair. Rotation therefore failed to neutralize a captured token. Replaced the SELECT-then-delete with a single atomic DELETE ... WHERE token_hash = ? AND expires_at > ? RETURNING user_id, so only the racer whose delete hits the row proceeds and the other gets 401 — mirroring the consumeNonce pattern already used in this file for the same class of TOCTOU replay. Portable across Postgres and SQLite. The existing testRefreshRotatesToken (rotation + sequential-reuse rejection) still holds. Rule 5 (session management): opens as draft pending a security verdict. Found by the 2026-07-11 full-repo audit (M2). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T
|
Security review (rule 5) — BLOCK @ Classification: security-sensitive — refresh-token rotation / session management. Fix: correct. The atomic Blocker — missing regression for the bug class. Required before APPROVE: a concurrency regression that fires ≥2 simultaneous Staying draft; no — orchestrator/reviewer, 2026-07-12 |
Security review (rule 5) noted testRefreshRotatesToken only covers sequential reuse — the old SELECT-then-delete code passes it too — so it can't catch the concurrent replay that #147 fixes. Added a test that fires 8 simultaneous POST /auth/refresh with the same token and asserts exactly one 200 and the rest 401. Atomic DELETE … RETURNING makes that deterministic here; the pre-fix code let multiple racers mint sessions. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T
|
Added the required concurrency regression at Generated by Claude Code |
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T
The concurrency regression from 75e7f91 didn't build: its afterResponse closure was synchronous, so overload resolution picked Application.test's sync variant, which is unavailable from an async context (the enclosing task-group closure). Mark the closure `{ res async in }` — the same disambiguation every other async test in this file uses — so the async overload is selected. Verified the regression now does its job: against the pre-fix SELECT-then-delete code all 8 concurrent refreshes return 200 (0×401), and it fails reliably (5/5 runs); against the atomic DELETE … RETURNING fix exactly one returns 200 and seven return 401 (5/5). Full server suite: 86 tests, 0 failures. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
Re-review requested @ Flagging the head the verdict must land on, because the regression added at Fixed in - }, afterResponse: { res in
+ }, afterResponse: { res async inVerified empirically that the regression catches the bug class (the rule-5 blocker):
Scope note: production code is unchanged since your Staying draft; not self-approving (rule 5). Requesting the rule-5 verdict re-issued at |
|
Thanks for the compile catch and the empirical pre/post verification — the sync Generated by Claude Code |
|
Security review: APPROVE @ 5f8b867 Root-agent review (CLAUDE.md rule 5). The atomic |
Summary
Closes #147 (audit finding M2). Rule-5 security-sensitive (session/token management) — draft pending a security verdict.
refresh()rotated the token with a read-then-delete:Two requests with the same token (a real client and a replayed stolen token) can both read the row before either deletes, so both mint a valid access+refresh pair — rotation fails to neutralize a captured token.
Fix: a single atomic consume, mirroring the
consumeNoncehelper already in this file (which solved the identical TOCTOU for Apple nonces):Only the racer whose DELETE hits the row gets a
user_idand proceeds; the other gets 401. Expired tokens don't match (can't be exchanged; cleanup reaps them). Portable across Postgres and SQLite 3.35+.Behavior change worth noting for review: an expired token now returns a generic "invalid or expired" 401 instead of a distinct "expired" message, and is left for cleanup rather than deleted on the failed exchange — no test depended on the old distinction.
Test plan
swift testinChessKit/passes (untouched)swift testinchess-server/passes — server lane is the verifier (this session can't build Swift locally)testRefreshRotatesToken(rotation + sequential-reuse rejection) still holds. The concurrency guarantee is provided by the atomicDELETE … RETURNING— the same mechanism already reviewed and tested forconsumeNonce— rather than a new concurrency test, since the existing test harness runs requests sequentially.Security review
Rule 5 verdict requested from the root session at head — this stays draft until
Security review: APPROVE @ <head>.🤖 Generated with Claude Code
https://claude.ai/code/session_01E9dkcpbwWWHrdGzpvWZd1T
Generated by Claude Code