Skip to content

fix(auth): accept epoch-changing token refreshes - #9560

Merged
lstein merged 7 commits into
invoke-ai:mainfrom
wunianze666-netizen:codex/fix-token-epoch-refresh
Sep 25, 2026
Merged

lstein merged 7 commits into
invoke-ai:mainfrom
wunianze666-netizen:codex/fix-token-epoch-refresh

Conversation

@wunianze666-netizen

Copy link
Copy Markdown
Contributor

Summary

  • bypass the routine one-minute token refresh throttle only when a replacement token belongs to the same user and carries a different revocation epoch
  • apply the same epoch-aware decision before and inside the cross-tab media-auth lock, preserving the existing race checks
  • cover the throttle boundaries and the full dynamicBaseQuery -> media-cookie sync -> Redux dispatch path

Related Issues / Discussions

Closes #9541

QA Instructions

  • pnpm exec vitest run (170 files, 2,227 tests)
  • pnpm run lint:tsc
  • pnpm run lint:dpdm
  • pnpm exec eslint --max-warnings=0 src/features/auth/store/authTokenRefresh.ts src/features/auth/store/authTokenRefresh.test.ts src/services/api/index.ts src/services/api/endpoints/auth.test.ts
  • pnpm exec prettier --check src/features/auth/store/authTokenRefresh.ts src/features/auth/store/authTokenRefresh.test.ts src/services/api/index.ts src/services/api/endpoints/auth.test.ts

The integration regression marks a routine refresh as recently accepted, returns a same-user token with an incremented token_epoch, and verifies that the media cookie is synchronized before tokenRefreshed is dispatched. Unit coverage keeps same-epoch, cross-user, and unreadable replacements throttled.

Merge Plan

No special merge handling is required.

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated
  • No Redux slice state change; migration is not applicable
  • Documentation is not applicable to this behavioral bug fix
  • Updated What's New copy (not a release PR)

@github-actions github-actions Bot added the frontend PRs that change frontend files label Aug 30, 2026

Copy link
Copy Markdown
Contributor Author

Synced this branch with current main (ef832d1aa) in merge commit 6cb39486d. The PR diff remains limited to the same four auth refresh files. I re-ran the focused validation against the updated dependency lock: 15/15 Vitest tests passed with no type errors, ESLint passed with zero warnings, and Prettier check passed. No code changes beyond the upstream sync were needed.

@wunianze666-netizen

Copy link
Copy Markdown
Contributor Author

Added an end-to-end negative regression in 00a27c396 for the other half of the epoch-refresh contract: while an epoch-changing replacement must bypass the one-minute throttle, a routine same-user/same-epoch replacement must still make only the original API request, skip the media-cookie write, and avoid dispatching a redundant token update.

Validation: 25/25 focused auth/API tests passed with no TypeScript errors; targeted ESLint, Prettier, and git diff --check passed. The production implementation is unchanged; this commit strengthens the boundary against accidentally disabling routine refresh throttling while fixing password-change recovery.

@wunianze666-netizen

Copy link
Copy Markdown
Contributor Author

Added a backward-compatibility regression for sessions carrying JWTs minted before the token_epoch claim existed (commit 5cad312). The end-to-end refresh test now covers both explicit epoch 0 and an omitted epoch claim, and verifies that an epoch 1 replacement is accepted inside the normal one-minute throttle, synchronizes the media cookie, and dispatches tokenRefreshed. Validation: 6 auth test files / 40 tests passed with no type errors; targeted ESLint and Prettier checks passed; git diff --check passed.

Copy link
Copy Markdown
Contributor Author

The new openapi-checks and typegen-checks failures are runner-timeout cancellations during dependency installation, before either schema/type generation step ran. Both jobs resolved the environment and then spent the full ~14-minute install window downloading the uncached CUDA/PyTorch stack (including 528.9 MiB torch, 527.5 MiB cuDNN, 403.5 MiB cuBLAS, and other NVIDIA wheels); GitHub cancelled them while uv was still downloading. The auth-relevant frontend-checks and frontend-tests passed, as did both Windows matrices and the remaining Python checks. I attempted to rerun the two cancelled workflows, but fork authors do not have the required repository admin permission. A maintainer rerun of only those two checks should be sufficient; no production or generated-file change is indicated by these logs.

@lstein lstein self-assigned this Sep 21, 2026
@lstein lstein added the 6.14.2 label Sep 21, 2026

@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. The fix is correct for the case #9541 describes, and I couldn't break the bypass itself:

  • Only the password-change reply can skip the throttle. The sliding-window middleware issues new tokens from the database's current epoch, and only after resolve_authorized_user has accepted the request token. A routine refresh therefore always has the same session key as the token it replaces. The only reply that can carry a different epoch comes from _issue_replacement_token.
  • Old tokens without an epoch claim read as epoch 0 on both the client and the server, so they stay throttled.
  • A replacement for a different user, or one that can't be decoded, stays throttled, and shouldAcceptRefreshedToken still has to pass as well.
  • Both password-change screens (PATCH /auth/me and an admin resetting their own password) go through dynamicBaseQuery.
  • The new tests do protect the fix. Undoing the check inside the lock fails 2 tests; dropping the tokensBelongToSameUser condition fails 1.

Requested change: test that routine refreshes still work outside the throttle window

This PR moves the throttle into shouldThrottleRefreshedToken, and nothing tests the path most likely to break: a routine refresh with the same epoch, arriving after the 60s window. This mutation passes the entire frontend suite (173 files, 2,289 tests):

 export const shouldThrottleRefreshedToken = (requestToken: string, refreshedToken: string): boolean => {
-  if (!isTokenRefreshThrottled()) {
+  if (false) {
     return false;
   }

With that change every same-epoch refresh is throttled forever, so the sliding session window stops working and every session hard-expires. Please add a test for it. A unit test for shouldThrottleRefreshedToken(tokenFor('user', 1, 1), tokenFor('user', 2, 1)) returning false once Date.now() is more than 60s past the last accepted refresh would be enough. It would be even better to also add a dynamicBaseQuery test showing a same-epoch replacement is committed outside the window, to go with your existing inside-the-window test.

Non-blocking

1. The replacement can still be lost when the throttle is idle. I reproduced this with a throwaway test. It's the other side of #9541:

  1. The tab holds T0, and no refresh has been accepted recently.
  2. A background mutating request (for example the client-state POST in driver.ts) was sent with T0 before the password change and processed before the epoch changed. The middleware returns a routine refresh with the old epoch.
  3. That refresh gets the media-auth lock first and commits, so auth_token now holds the refreshed token.
  4. The password-change reply's acceptRefreshedToken(replacement, T0, ...) passes the throttle check, but shouldAcceptRefreshedToken requires localStorage.auth_token === T0 exactly, so the replacement is dropped.
  5. The stored token has the old epoch, so the next request gets a 401 and sessionExpiredLogout fires.

The exact-match check in shouldAcceptRefreshedToken is the second place a replacement gets dropped. One option: when the replacement advances the epoch, also accept it if the stored token has the same getTokenSessionKey as requestToken. The stored token is then just a routine refresh of the token the request was sent with. This isn't caused by this PR, so it can go in a follow-up.

2. A 401 in the gap before the replacement is saved. This comes from reading the code; I haven't reproduced it. The server changes the epoch before the client saves the replacement. In that gap, any request sent with T0 gets a 401 while T0 is still the stored token, so shouldEndSessionForUnauthorized(T0) is true. The request can come from a background refetch in this tab or from another tab, since tabs share localStorage. sessionExpiredLogout then deletes auth_token, and the replacement is dropped. The gap includes a client-side media-cookie request that isn't needed on this path, because the route already sets the cookie with _set_media_cookie. This existed before the PR and is just for awareness.

3. Test details.

  • The PR description says the integration test verifies that the media cookie is synchronized before tokenRefreshed is dispatched. The test only checks that there were two fetches and one dispatch, not their order. Recording the order in a shared array would cover that.
  • vi.stubGlobal('fetch', ...) in the new auth.test.ts tests is never undone, so the stub carries into the getCurrentUser tests after it. Those pass today, but calling vi.unstubAllGlobals() for fetch in an afterEach (and then re-stubbing localStorage/window as needed) would keep the tests independent.

Copy link
Copy Markdown
Contributor Author

Addressed the requested throttle-window coverage in eed8f3586.

  • Added a unit regression proving a routine same-user/same-epoch replacement is no longer throttled once 60 seconds have elapsed.
  • Added the matching dynamicBaseQuery regression proving the replacement performs the media-cookie sync and dispatches tokenRefreshed outside the window.
  • Verified the review mutation (if (false)) makes both new tests fail: the unit helper returns true, and the integration path performs only the original fetch instead of committing the replacement.
  • Tightened the existing epoch-change test to assert media-cookie sync occurs before dispatch, and isolated global fetch/storage/window stubs between tests.

Validation: 6 auth test files / 38 tests passed with no type errors; targeted ESLint and Prettier checks passed; git diff --check passed. I kept the separate lost-replacement race out of this review fix because it changes behavior beyond the blocking request and is better handled as a focused follow-up.

@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 the quick turnaround. This addresses the requested change, and I verified the claims independently at eed8f3586.

  • The production diff against main is byte-identical to the version I reviewed at 5cad31203, so the fix itself stands as reviewed.
  • The outside-window coverage is now there at both levels (the shouldThrottleRefreshedToken unit test and the dynamicBaseQuery commit test). I re-applied the if (false) mutation from my review: it now fails exactly those two tests, where before it passed the whole suite.
  • The new ordering assertion is load-bearing. Moving dispatch(tokenRefreshed(...)) ahead of the media-cookie sync fails both epoch-change tests.
  • Per-test stub isolation resolves the fetch stub leaking into the getCurrentUser test.
  • 48/48 auth tests pass locally, and every CI check on the head commit is green.

Agreed that the idle-throttle race belongs in a follow-up rather than here; I've filed it as #9598 with the triggering sequence and a suggested fix, for whoever picks it up.

Closes #9541.

@lstein
lstein enabled auto-merge (squash) September 25, 2026 12:41
@lstein
lstein merged commit adb0623 into invoke-ai:main Sep 25, 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.

Changing your own password signs you out when a token refresh was accepted in the last 60s

2 participants