Skip to content

fix(auth): answer a refresh burst with one successor token - #2018

Merged
DEENUU1 merged 3 commits into
mainfrom
fix/refresh-burst-reissue
Oct 6, 2026
Merged

DEENUU1 merged 3 commits into
mainfrom
fix/refresh-burst-reissue

Conversation

@DEENUU1

@DEENUU1 DEENUU1 commented Oct 6, 2026

Copy link
Copy Markdown
Member

Replaces #2007 by @henfircreo, carried on a vstorm-co branch because maintainer edits cannot reach a branch on an organization-owned fork. The commit is theirs, unchanged; the only addition is a merge of main resolving CHANGELOG.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

  • One successor per spent token. successor_refresh_token derives the next refresh token from the spent one: jti is an HMAC (keyed with SECRET_KEY) of the spent token's hash, exp is the row's own expiry, and the payload has no iat. The same spent token therefore always produces the same successor, byte for byte. Within the window, SessionService.reissue_within_grace answers 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.
  • The grace answer is refused when the row has rotated past that successor (a later refresh on the new token) or the account's credential version has moved. Reuse detection outside the window is unchanged, and so are upstream's guards against a rotation stamped in the future.
  • A 401 from the refresh logs the console out (api-client.ts), so AuthGuard sends the person to sign in. A rate limit, a 5xx or an ended impersonation is still not treated as the end of the session.
  • The chat socket's /auth/me recovery tolerates an empty answer.
  • Docs (configuration, security in 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.
  • Locally: these backend tests, tests/api/test_auth.py (51 passed), and api-client/use-chat/auth-lock vitest (185 passed). ruff is clean, and check_docs_i18n.py reports every translation current.

Limitations

The tradeoff is the same as the existing window: a stolen refresh token replayed within REFRESH_REUSE_GRACE_SECONDS of 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

henfircreo and others added 2 commits October 5, 2026 20:50
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.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 6, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-06T11:13:33.415577Z f1d9367 PR opened
🔒 Security Review ✅ Completed 2026-10-06T11:16:41.095415Z f1d9367 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@DEENUU1
DEENUU1 merged commit 1d2ef6e into main Oct 6, 2026
14 checks passed
@DEENUU1
DEENUU1 deleted the fix/refresh-burst-reissue branch October 6, 2026 12:23
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants