fix(auth): revoke entire token family on refresh token reuse detection#586
fix(auth): revoke entire token family on refresh token reuse detection#586Srejoye wants to merge 3 commits into
Conversation
The POST /refresh handler checked storedToken.revokedAt and returned 401, but never invalidated the rest of the family. This allowed an attacker who stole a refresh token and used it first to fork the session: the attacker obtained a live descendant token while the legitimate client's next request got a 401, with no server-side signal that theft had occurred. The attacker's forked session could persist for up to 90 days.
Fix: in the revokedAt branch, before returning 401, issue:
prisma.refreshToken.updateMany({
where: { family: storedToken.family, revokedAt: null },
data: { revokedAt: new Date() },
})
This kills all live descendants in the family, forcing full re-authentication for both the attacker and the legitimate user. A WARN log is emitted with the family ID and userId for alerting.
Normal (non-reuse) rotation is unchanged — updateMany is only called in the revoked-token branch.
Tests added (apps/backend/src/__tests__/refresh.test.ts):
- Normal rotation: revokes old token, creates new in same family, 200.
- Missing / unrecognised / expired token → 401, no mutation.
- Revoked token → updateMany fires with correct family + revokedAt:null filter; no new token issued.
- Full A→B→C chain: step-1 rotation succeeds; step-2 stale-A reuse kills B; step-3 legitimate-B attempt gets 401 confirming family kill.
- updateMany DB failure → 500, no token issued.
Affected: apps/backend/src/routes/auth.ts (POST /refresh)
Tests: apps/backend/src/__tests__/refresh.test.ts
|
@Srejoye is attempting to deploy a commit to the Prashantkumar Khatri's projects Team on Vercel. A member of the Team first needs to authorize it. |
|
Hi @Srejoye, Thanks for opening this pull request. This PR has been automatically classified based on the files modified. Applied Labels
Primary Review Area
Reviewer@Harxhit has been identified as the primary reviewer for this pull request. If you have any questions regarding the affected area or implementation details, feel free to reach out to the assigned reviewer. Thank you for your contribution! |
CI — All Checks PassedBackend — PASS
Mobile — SKIP
Web — SKIP
Last updated: |
Harxhit
left a comment
There was a problem hiding this comment.
Checked this out and tested locally. This is a textbook implementation of refresh-token reuse detection — revoking the entire family on replay is exactly the recommended response (OAuth 2.0 Security BCP / RFC 6819). Clean, surgical change.
Verified ✅
npm run typecheck→ passesnpx vitest run src/__tests__/refresh.test.ts→ 8/8 passeslinton both changed files → clean- Change is confined to the
if (storedToken.revokedAt)branch — normal rotation, missing/invalid/expired paths are untouched. where: { family, revokedAt: null }makes theupdateManyidempotent against an already-partially-revoked family. Correct.findUniquealready loadsfamilyanduserId, so the WARN log fields are populated. Good for SIEM/alerting.
Notes / suggestions
1. (Consideration) Access tokens outlive the family revocation. This correctly kills all live refresh tokens, but any short-lived access JWT the attacker already obtained stays valid until it expires (there's a Redis blocklist used elsewhere for logout). Revoking the refresh family is the right fix for #565 and I wouldn't block on this — but for full session invalidation on confirmed theft, blocklisting the family's active access tokens would close the residual window. Reasonable as a follow-up.
2. (Nit) Test count. The description says "9 cases"; the suite runs 8 it() blocks (all passing). Just a doc/reality mismatch.
3. (Minor, no change needed) Check ordering. revokedAt is evaluated before expiresAt, so a token that is both revoked and expired triggers family revocation rather than returning "expired". That's the desirable precedence for a theft signal — flagging only so it's a conscious choice.
Overall: correct, well-tested, and appropriately scoped. No blockers from me — item #1 is the only thing worth considering, and it's optional/follow-up.
Reviewed locally on the head of fix/565-refresh-token-reuse-detection.
Summary
Closes #565.
POST /auth/refreshrotated tokens correctly during normal use but treated "a revoked token was presented" as a simple 401 with no further action. This left the door open for indefinite session forking: an attacker who uses a stolen token first gets a live descendant while the legitimate user silently loses their session with no server-side signal of compromise.Root cause
Presenting an already-revoked token in a rotating-token system means two parties hold copies of the same chain. The correct response is to treat this as a confirmed compromise and nuke the entire family.
Fix
One targeted addition in the
revokedAtbranch — before the 401, revoke all live tokens in the family and emit a WARN log:The
revokedAt: nullfilter ensures only live descendants are touched — already-revoked tokens are skipped, so theupdateManyis safe to call even if the family was partially killed earlier.Normal rotation is completely unchanged —
updateManyis only called in the revoked-token branch.Files changed
apps/backend/src/routes/auth.tsif (storedToken.revokedAt)apps/backend/src/__tests__/refresh.test.tsTests
Normal rotation:
updateManynever calledRefresh token missingInvalid refresh tokenRefresh token expired, no writesReuse-detection:
updateManyfires with{ family, revokedAt: null }, no new token issuedupdateManyDB failure → 500, no token issuedChecklist
revokedAtbranch is modified — happy path is untouchedrevokedAt: nullfilter makes theupdateManyidempotent against already-killed familiesfamilyanduserIdfor security alerting / SIEM integration