Skip to content

fix(auth): keep a password-change token when a routine refresh lands first - #9599

Merged
lstein merged 3 commits into
invoke-ai:mainfrom
MohammadHijjawi97:fix/auth-accept-epoch-replacement-after-slide
Sep 27, 2026
Merged

lstein merged 3 commits into
invoke-ai:mainfrom
MohammadHijjawi97:fix/auth-accept-epoch-replacement-after-slide

Conversation

@MohammadHijjawi97

Copy link
Copy Markdown
Contributor

Summary

Fix for the second drop path described in #9598. When the refresh throttle is idle and a routine sliding-window refresh of the same session commits while a password change is in flight, the password change's epoch-advancing replacement R is dropped. That happens because shouldAcceptRefreshedToken requires localStorage.auth_token to be byte-equal to the token the request was sent with. The stored token keeps the revoked epoch, and the next request 401s and signs the user out.

This follows the approach suggested in the issue:

  • shouldAcceptRefreshedToken(requestToken, requestGeneration, refreshedToken?) still accepts when the stored token is byte-equal to requestToken.
  • It also accepts when refreshedToken is the same user's epoch-advancing replacement and the stored token has the same getTokenSessionKey (user id + epoch) as requestToken. In that case the stored token is only a routine slide of the token the request was sent with, and R is strictly newer than both.
  • The auth_generation check is unchanged and still runs first, so logins, logouts and other-tab adoptions are still rejected.
  • The epoch-replacement predicate is factored out as isEpochReplacement and shared with shouldThrottleRefreshedToken, which keeps the same behavior.
  • acceptRefreshedToken passes refreshedToken at all three checks (the pre-lock check, the in-lock check and the commit check).

I left out the media-cookie round-trip observation from the issue. It narrows the window but doesn't close it, and it seemed better as its own change.

Related Issues / Discussions

Closes #9598. Follow-up to #9541 / #9560.

QA Instructions

New cases in authTokenRefresh.test.ts:

  • accepts a password-change replacement after a routine refresh of the same session committed first: the issue's sequence (request T0, stored T0' at the same epoch, replacement R at a new epoch). It fails on main and passes with this change.
  • still rejects a routine refresh superseded by a newer refresh of the same session: a same-epoch refresh is still gated on byte equality.
  • rejects an epoch replacement once the stored token is no longer the same session: the stored token is already on a newer epoch, belongs to another user, or has been removed (logged out).
  • rejects an epoch replacement after a login transition started: the generation check still wins.

I ran:

  • vitest --no-watch: 176 files, 2339 tests passed
  • prettier --check and eslint --max-warnings=0 on the changed files, and tsc --noEmit: clean

Merge Plan

N/A

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • ❗Changes to a redux slice have a corresponding migration (no slice changes)
  • Documentation added / updated (if applicable) (N/A)
  • Updated What's New copy (if doing a release after this PR) (N/A)

…first

shouldAcceptRefreshedToken required the stored token to be byte-equal to
the token the request was sent with. If a routine sliding-window refresh
of the same session committed while a password change was in flight, the
password change's epoch-advancing replacement was dropped, the stored
token kept the revoked epoch, and the next request logged the user out.

Also accept an epoch-advancing replacement when the stored token is the
same session (user id and epoch) as the request token. The
auth_generation check is unchanged, so logins, logouts and user switches
are still rejected.

Closes invoke-ai#9598
@github-actions github-actions Bot added the frontend PRs that change frontend files label Sep 25, 2026
@lstein lstein self-assigned this Sep 27, 2026
@lstein lstein added the 6.14.2 label Sep 27, 2026
…fresh

The unit tests exercise shouldAcceptRefreshedToken directly, so dropping
refreshedToken from any of the three acceptRefreshedToken call sites went
unnoticed. Drive acceptRefreshedToken through both orderings from invoke-ai#9598:
the routine refresh commits before the replacement arrives (the check
before the lock), and the replacement queues on the lock behind it (the
check inside the lock). Reverting any one call site now fails a test.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@lstein lstein left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this. It closes #9598, and I reviewed it adversarially against the sequences in the issue. No blockers.

What I verified

  • Both orderings are fixed. With the real acceptRefreshedToken, the password-change replacement survives both when the routine refresh commits before it arrives (the check before the lock) and when it waits on the lock behind that refresh (the check inside the lock).
  • "Strictly newer than both" holds on the backend. A routine refresh from SlidingWindowTokenMiddleware can never move to a new epoch. resolve_authorized_user refuses a stale epoch, and the new token is minted from that same record. So the only tokens that change the epoch come from _issue_replacement_token (PATCH /auth/me and an admin resetting their own password).
  • Tokens that should be rejected still are.
    • Another user's token, or a logout (no stored token), fails the session-key comparison.
    • A login in any tab is caught by the shared auth_generation check.
    • A stale routine refresh that arrives after the replacement is committed is still rejected, because it isn't an epoch replacement and its bytes don't match.
    • Legacy tokens with no epoch claim are read as epoch 0 on both sides, and tokens with no user id fall back to byte equality as before.

The commit I pushed (a9cff3c)
The new tests call shouldAcceptRefreshedToken directly, so I checked whether the wiring in services/api/index.ts is covered. It wasn't: removing refreshedToken from any one of the three acceptRefreshedToken call sites still passed every test. I added two tests to services/api/endpoints/auth.test.ts that run acceptRefreshedToken through both orderings. Reverting any one call site now fails at least one of them. The queued test covers the check inside the lock specifically: it still passes when only the check before the lock is reverted. Both tests also pass with navigator.locks removed, so the fallback lock path is covered too.

Non-blocking

  • The rule is "the stored token is still the request's session", not "the newest epoch wins". Say two password changes are submitted at once and both authenticate at epoch 0 (the update is token_epoch + 1). If the epoch-1 replacement arrives before the epoch-2 one, the revoked epoch-1 token stays stored. main behaves the same way and the timing is unlikely, so I'm only noting it.
  • As you say in the description, the other half of #9598 is still open: any request sent with the old token that gets a 401 in the gap triggers sessionExpiredLogout, and the media-cookie request on that path is redundant. That can be its own PR.

@lstein
lstein enabled auto-merge (squash) September 27, 2026 15:25
@lstein
lstein merged commit e927a2e into invoke-ai:main Sep 27, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

6.14.2 frontend PRs that change frontend files

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Password-change replacement token is dropped when a routine refresh commits first (throttle idle)

2 participants