Skip to content

security(auth): a refresh-token reuse can leave the winner’s new session alive (race) #725

Description

@alex-dembele

Problem

TestRefresh_ConcurrentRotation_OneWinner (backend/internal/auth/token_test.go) fails
about one run in five, in isolation, with -count=1:

token_test.go:208: Error: Not equal: expected: 0, actual: 1
                   Messages: concurrent reuse revokes the family

The flake is real and it points at the code, not at the test.

Why

TokenManager.RefreshTokenPair (backend/internal/auth/token.go) does, in order:

  1. claim the row atomically (UPDATE … WHERE id = ? AND rotated_at IS NULL);
  2. resolve the session scope;
  3. GenerateTokenPair, which inserts a new refresh row in the same family.

A concurrent loser takes the RowsAffected != 1 branch and calls
revokeFamily(familyID) immediately. Nothing orders these two: when the loser's
DELETE … WHERE family_id = ? runs before the winner's INSERT, the winner's
brand-new refresh token is written after the family was revoked and survives.

So a token-reuse event — the signal that a refresh token leaked — can leave a live
session behind, which is exactly what family revocation exists to prevent. The
window is small, but it is the window an attacker replaying a stolen token races.

Acceptance criteria

  1. Given N concurrent rotations of one refresh token, when exactly one wins and at least one is flagged as reuse, then no refresh row of that family survives — asserted by the existing test, run with -count=50 -race and green every time.
  2. The winner's new token is not written after a revocation of its family (for instance: claim + insert in one transaction, and revoke by family in the same serialized path; or re-check the family after insert and delete).
  3. No behaviour change for the ordinary, uncontended refresh path; internal/auth tests stay green.

Definition of Done

  • go test ./internal/auth/ -run TestRefresh -count=50 -race pasted in the issue comment.
  • The ordering argument written down in the code, next to the claim.

Context

Found while verifying #718/#719/#720 in the running app: the full go test ./... failed
here once, and the test then failed 1 run in 5 in isolation on an untouched checkout.
This is not caused by those branches; none of them touch internal/auth.

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions