fix(auth): decide a single winner when two refreshes race one token - #296
Merged
Conversation
Rotation read the session, checked it had not been rotated, created the replacement and linked the two, in four statements with no transaction, row lock or conditional write. Two refreshes carrying the same token both passed the check and both wrote the link, and the second write won. Both callers ended up with working refresh tokens, and one replacement was live while reachable from nothing, so revokeSessionChain walked past it. That is the case reuse detection exists for. Someone who copied a refresh token and raced the legitimate client kept a session the revocation triggered by that theft could not reach, while the trail recorded refresh_token_suspicious and reported it had fired. claimSessionRotation writes the link conditional on it still being unset, in one statement the database serialises. No affected rows is the same condition an already rotated token presents, and is answered the same way: the replacement this request made is revoked, the session is reloaded so the chain walk follows the winner's link, the chain is revoked from there, and the caller gets 401 refresh_token_reused. Two legitimate refreshes racing each other now end the session chain, the same as presenting a rotated token twice in sequence. Closes #279
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 #279 (P1).
What was wrong
Rotation reads the session, checks it has not already been rotated, creates the replacement and links the two, in four statements with no transaction, row lock or conditional write. Two refreshes carrying the same token both pass the reuse check and both write the link. The second write wins.
Both callers then hold working refresh tokens, and the replacement belonging to the first is live while reachable from nothing. When the token is presented again,
revokeSessionChainstarts at the old session and followsreplacedBySessionIdforward, so it reaches the winner and stops. The other session survives.That is the case reuse detection exists for. Someone who copied a refresh token and raced the legitimate client kept a session that the revocation triggered by that theft could not reach, while the trail recorded
refresh_token_suspiciousand reported that it had fired.The fix
claimSessionRotationwrites the link conditional on it still being unset (and the session not revoked), in one statement the database serialises:No affected rows means another rotation got there first, which is the same condition an already rotated token presents, and it is answered the same way:
rotation_race_lost, since it is reachable from nothing and its refresh token was never returned to anyone,refresh_token_suspiciousis recorded, and the caller gets401 refresh_token_reused.The reload in step 2 is the part that is easy to leave out and would quietly reproduce the original defect.
The tradeoff, stated
Two legitimate refreshes racing each other now end the session chain, the same as presenting a rotated token twice in sequence does. A client that fires concurrent refreshes of one token signs its user out.
That is the direction this has to fail. The alternative, letting the loser fail quietly while the winner keeps its session, means whoever wins the race keeps a session, and the winner is not always the client that should have it: if the attacker wins, the theft goes undetected entirely. The changeset and
docs/security-posture.mdboth say so.Noted, not fixed here
The security review turned up one pre-existing imperfection this change does not touch:
revokeSessionChainreadscurrent.replacedBySessionIdfrom the in-memory instance aftersave(), so a rotation of a chain member committing between that node'sfindByPkand itssavewould be missed. Exploiting it requires a full network round trip plus several queries to fit inside one localUPDATE, and any second loser's walk re-reads the link and catches it, so it is theoretical rather than practical. Happy to file it separately if you want it tracked.Tests
Three added. The controller test fails on
main:The other two cover
claimSessionRotationdirectly: thewhereclause it issues, and that it leaves the instance alone when it loses.Checks
npm run typecheck,npm run lint,npm run format:check,npm run build: cleannpm run test:run: 113 files, 1336 passed, 1 skipped, 1 todonpm run coverage: 98.75% statements, 96.02% branches, 98.96% functions, 98.95% lines, all above threshold/security-reviewon the branch: no findings. It confirmed Sequelize 6.37.8 emitsIS NULLrather than= NULLhere, thatModel.updatereturns a realrowCountwith no hooks onSession, and thatclaimSessionRotationis now the only writer ofreplacedBySessionIdinsrc/.