Repository navigation
fix(auth): answer a refresh burst with one successor token - #2018
Merged
Merged
Conversation
The reuse grace window rotated the session again on every grace refresh, so in a burst of three refreshes on one cookie the third matched no previous hash, got a 401, and its response cleared the cookie the other two had just set. The session stayed active and the browser lost it. - The successor refresh token is now derived from the spent one (jti is an HMAC of its hash, exp is the row's expiry), so a grace refresh is answered with the token the row already holds instead of rotating again. Every request in the burst gets the same token. - A 401 from the refresh logs the console out, so AuthGuard sends the person to sign in instead of leaving a page where every request fails. - The chat socket's /auth/me recovery tolerates an empty answer.
…urst # Conflicts: # CHANGELOG.md
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
…urst # Conflicts: # CHANGELOG.md
This was referenced Oct 6, 2026
Merged
DEENUU1
added a commit
that referenced
this pull request
Oct 6, 2026
### Summary Release 0.0.521: version, lock and changelog. ### Changes - `backend/pyproject.toml`, `frontend/package.json` and `backend/uv.lock` move to 0.0.521. - `CHANGELOG.md`: the `[Unreleased]` block becomes `[0.0.521] - 2026-10-06`, with a fresh empty `[Unreleased]` above it. - Ships #2018 (from #2007): - a burst of refreshes on one cookie is answered with the one successor token the session holds, so the cookie converges instead of being cleared; - a refused refresh logs the console out and sends the person to sign in.
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.
Replaces #2007 by @henfircreo, carried on a
vstorm-cobranch because maintainer edits cannot reach a branch on an organization-owned fork. The commit is theirs, unchanged; the only addition is a merge ofmainresolvingCHANGELOG.md. Reviewed: no changes requested.Problem
This follows up the refresh-token grace window that shipped in 0.0.515 (
REFRESH_REUSE_GRACE_SECONDS,0102_session_rotated_at). Inside the window, a spent token rotated the session again. In a burst of refreshes on one cookie (every tab's timer firing after a laptop wakes), the first request rotates the row and the second rotates it again inside the grace window. The third then matches no previous hash and gets a 401, and its response clears the cookie the other two had just set. The session stays active and the browser loses it. In production we still saw sign-outs after the grace window landed.Second, a refused refresh left the console signed in: every request answered 401, and the chat socket reconnected on a dead token until the person did a full reload.
Change
successor_refresh_tokenderives the next refresh token from the spent one:jtiis an HMAC (keyed withSECRET_KEY) of the spent token's hash,expis the row's own expiry, and the payload has noiat. The same spent token therefore always produces the same successor, byte for byte. Within the window,SessionService.reissue_within_graceanswers with the token the row already holds instead of rotating again. Every request in the burst gets the same token, so the cookie ends up the same whichever response lands last. The row never stores a credential.api-client.ts), soAuthGuardsends the person to sign in. A rate limit, a 5xx or an ended impersonation is still not treated as the end of the session./auth/merecovery tolerates an empty answer.configuration,securityin en/pl/de/es) and the CHANGELOG describe the reissue.Verification
tests/test_session_refresh_grace.py: the successor is deterministic, differs between spent tokens and carries the given expiry and version. The reissue is refused once the row has moved on or the credential version has changed.tests/integration/test_session_revocation.py, through the real route: a lost response gets back the token it missed; a burst of three gets 200 and the same token on every request; once the successor has rotated, the spent token gets a 401; and a password change closes the window.api-client.test.ts: a 401 refresh logs out, and a 429 or 5xx does not.tests/api/test_auth.py(51 passed), andapi-client/use-chat/auth-lockvitest (185 passed). ruff is clean, andcheck_docs_i18n.pyreports every translation current.Limitations
The tradeoff is the same as the existing window: a stolen refresh token replayed within
REFRESH_REUSE_GRACE_SECONDSof the victim's own refresh is answered rather than detected. The difference is that it now receives the token the victim also holds, rather than a fresh rotation.Closes #2007