From d227e0041905d81c12941db7b09ad76591d29940 Mon Sep 17 00:00:00 2001 From: wunianze666-netizen Date: Mon, 31 Aug 2026 00:51:23 +0800 Subject: [PATCH 1/3] fix(auth): accept epoch-changing token refreshes --- .../auth/store/authTokenRefresh.test.ts | 25 ++++++++++ .../features/auth/store/authTokenRefresh.ts | 14 +++++- .../src/services/api/endpoints/auth.test.ts | 50 ++++++++++++++++++- .../frontend/web/src/services/api/index.ts | 12 +++-- 4 files changed, 96 insertions(+), 5 deletions(-) diff --git a/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.test.ts b/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.test.ts index e1e99c97792..fb91c6164b3 100644 --- a/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.test.ts +++ b/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.test.ts @@ -4,11 +4,16 @@ import { beginAuthTransition, captureAuthGeneration, createMediaAuthLock, + markTokenRefreshAccepted, runWithMediaAuthLock, shouldAcceptRefreshedToken, shouldEndSessionForUnauthorized, + shouldThrottleRefreshedToken, } from './authTokenRefresh'; +const tokenFor = (userId: string, nonce: number, epoch: number) => + `header.${btoa(JSON.stringify({ user_id: userId, nonce, token_epoch: epoch }))}.signature`; + describe('refreshed token acceptance', () => { beforeAll(() => { const values = new Map(); @@ -60,6 +65,26 @@ describe('refreshed token acceptance', () => { expect(beginAuthTransition()).toBe(1); }); + it('does not throttle the replacement token that advances the current user revocation epoch', () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(100_000); + markTokenRefreshAccepted(); + + expect(shouldThrottleRefreshedToken(tokenFor('user', 1, 0), tokenFor('user', 2, 1))).toBe(false); + + now.mockRestore(); + }); + + it('keeps routine, cross-user, and unreadable replacements throttled', () => { + const now = vi.spyOn(Date, 'now').mockReturnValue(200_000); + markTokenRefreshAccepted(); + + expect(shouldThrottleRefreshedToken(tokenFor('user', 1, 1), tokenFor('user', 2, 1))).toBe(true); + expect(shouldThrottleRefreshedToken(tokenFor('user-a', 1, 0), tokenFor('user-b', 2, 1))).toBe(true); + expect(shouldThrottleRefreshedToken('opaque-old', 'opaque-new')).toBe(true); + + now.mockRestore(); + }); + it('serializes media-cookie writes', async () => { const calls: string[] = []; let releaseFirst: (() => void) | undefined; diff --git a/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.ts b/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.ts index 15f5be32850..844b3f69bf6 100644 --- a/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.ts +++ b/invokeai/frontend/web/src/features/auth/store/authTokenRefresh.ts @@ -1,4 +1,4 @@ -import { tokensBelongToSameUser } from 'features/auth/store/authSlice'; +import { getTokenSessionKey, tokensBelongToSameUser } from 'features/auth/store/authSlice'; const AUTH_GENERATION_KEY = 'auth_generation'; const MEDIA_AUTH_LOCK = 'invokeai-media-auth'; @@ -22,6 +22,18 @@ export const markTokenRefreshAccepted = () => { lastTokenRefreshAcceptedAt = Date.now(); }; +export const shouldThrottleRefreshedToken = (requestToken: string, refreshedToken: string): boolean => { + if (!isTokenRefreshThrottled()) { + return false; + } + // An epoch-changing replacement is the only credential that remains valid after revocation. + // Keep every other replacement on the normal sliding-refresh throttle. + return !( + tokensBelongToSameUser(requestToken, refreshedToken) && + getTokenSessionKey(requestToken) !== getTokenSessionKey(refreshedToken) + ); +}; + type FallbackLockTicket = { choosing: boolean; expiresAt: number; diff --git a/invokeai/frontend/web/src/services/api/endpoints/auth.test.ts b/invokeai/frontend/web/src/services/api/endpoints/auth.test.ts index b3f35c79bc2..5f556e1b766 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/auth.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/auth.test.ts @@ -1,8 +1,11 @@ import { configureStore } from '@reduxjs/toolkit'; +import type { BaseQueryApi } from '@reduxjs/toolkit/query'; +import { tokenRefreshed } from 'features/auth/store/authSlice'; +import { markTokenRefreshAccepted } from 'features/auth/store/authTokenRefresh'; import { authApi } from 'services/api/endpoints/auth'; import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; -import { api } from '..'; +import { api, buildV1Url, dynamicBaseQuery } from '..'; /** * `dynamicBaseQuery` reads the bearer token out of localStorage, and `getDeploymentBaseUrl` @@ -33,6 +36,51 @@ const buildStore = () => middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(api.middleware), }); +const tokenFor = (nonce: number, epoch: number) => + `header.${btoa(JSON.stringify({ user_id: 'user-1', nonce, token_epoch: epoch }))}.signature`; + +describe('refreshed token acceptance', () => { + it('accepts an epoch-changing replacement inside the routine refresh throttle window', async () => { + const requestToken = tokenFor(1, 0); + const refreshedToken = tokenFor(2, 1); + localStorage.setItem('auth_token', requestToken); + markTokenRefreshAccepted(); + + const dispatch = vi.fn(); + const fetchMock = vi.fn((input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.endsWith('/api/v1/auth/media-cookie')) { + expect(new Headers(init?.headers).get('Authorization')).toBe(`Bearer ${refreshedToken}`); + return Promise.resolve(new Response(null, { status: 204 })); + } + return Promise.resolve( + new Response('{}', { + headers: { 'content-type': 'application/json', 'X-Refreshed-Token': refreshedToken }, + }) + ); + }); + vi.stubGlobal('fetch', fetchMock); + + await dynamicBaseQuery( + buildV1Url('images/i/example.png'), + { + dispatch, + getState: () => ({}), + signal: new AbortController().signal, + abort: () => {}, + endpoint: 'getImageDTO', + type: 'query', + forced: false, + extra: undefined, + } as unknown as BaseQueryApi, + {} + ); + + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenCalledWith(tokenRefreshed(refreshedToken)); + }); +}); + describe('getCurrentUser', () => { it('does not let a replacement session read the 401 of the token it replaced', async () => { // The sequence this exists for: a tab page-loads with an expired token and asks who it is; diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index 36574d66723..e68eb65cbe8 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -12,12 +12,12 @@ import { sessionExpiredLogout, tokenRefreshed } from 'features/auth/store/authSl import { beginAuthTransition, captureAuthGeneration, - isTokenRefreshThrottled, markTokenRefreshAccepted, MEDIA_COOKIE_SYNC_TIMEOUT_MS, runWithMediaAuthLock, shouldAcceptRefreshedToken, shouldEndSessionForUnauthorized, + shouldThrottleRefreshedToken, } from 'features/auth/store/authTokenRefresh'; import queryString from 'query-string'; import stableHash from 'stable-hash'; @@ -174,11 +174,17 @@ export const acceptRefreshedToken = async ( requestGeneration: number, dispatch: (action: ReturnType) => unknown ): Promise => { - if (isTokenRefreshThrottled() || !shouldAcceptRefreshedToken(requestToken, requestGeneration)) { + if ( + shouldThrottleRefreshedToken(requestToken, refreshedToken) || + !shouldAcceptRefreshedToken(requestToken, requestGeneration) + ) { return; } await runWithMediaAuthLock(async () => { - if (isTokenRefreshThrottled() || !shouldAcceptRefreshedToken(requestToken, requestGeneration)) { + if ( + shouldThrottleRefreshedToken(requestToken, refreshedToken) || + !shouldAcceptRefreshedToken(requestToken, requestGeneration) + ) { return; } try { From 00a27c396aeb4077899e970efe330492642c3169 Mon Sep 17 00:00:00 2001 From: wunianze666-netizen Date: Sun, 13 Sep 2026 15:21:45 +0800 Subject: [PATCH 2/3] test(auth): preserve routine refresh throttling --- .../src/services/api/endpoints/auth.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/invokeai/frontend/web/src/services/api/endpoints/auth.test.ts b/invokeai/frontend/web/src/services/api/endpoints/auth.test.ts index 5f556e1b766..e8777d26870 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/auth.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/auth.test.ts @@ -79,6 +79,41 @@ describe('refreshed token acceptance', () => { expect(fetchMock).toHaveBeenCalledTimes(2); expect(dispatch).toHaveBeenCalledWith(tokenRefreshed(refreshedToken)); }); + + it('keeps a same-epoch replacement inside the routine refresh throttle window', async () => { + const requestToken = tokenFor(1, 1); + const refreshedToken = tokenFor(2, 1); + localStorage.setItem('auth_token', requestToken); + markTokenRefreshAccepted(); + + const dispatch = vi.fn(); + const fetchMock = vi.fn(() => + Promise.resolve( + new Response('{}', { + headers: { 'content-type': 'application/json', 'X-Refreshed-Token': refreshedToken }, + }) + ) + ); + vi.stubGlobal('fetch', fetchMock); + + await dynamicBaseQuery( + buildV1Url('images/i/example.png'), + { + dispatch, + getState: () => ({}), + signal: new AbortController().signal, + abort: () => {}, + endpoint: 'getImageDTO', + type: 'query', + forced: false, + extra: undefined, + } as unknown as BaseQueryApi, + {} + ); + + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(dispatch).not.toHaveBeenCalled(); + }); }); describe('getCurrentUser', () => { From 5cad31203baa631c55a67fbd09681559b158d89c Mon Sep 17 00:00:00 2001 From: wunianze666-netizen Date: Tue, 15 Sep 2026 09:17:26 +0800 Subject: [PATCH 3/3] test(auth): cover legacy epochless token refresh --- .../src/services/api/endpoints/auth.test.ts | 85 ++++++++++--------- 1 file changed, 46 insertions(+), 39 deletions(-) diff --git a/invokeai/frontend/web/src/services/api/endpoints/auth.test.ts b/invokeai/frontend/web/src/services/api/endpoints/auth.test.ts index e8777d26870..0efa7f1d3f2 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/auth.test.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/auth.test.ts @@ -36,49 +36,56 @@ const buildStore = () => middleware: (getDefaultMiddleware) => getDefaultMiddleware().concat(api.middleware), }); -const tokenFor = (nonce: number, epoch: number) => - `header.${btoa(JSON.stringify({ user_id: 'user-1', nonce, token_epoch: epoch }))}.signature`; +const tokenFor = (nonce: number, epoch?: number) => + `header.${btoa( + JSON.stringify({ user_id: 'user-1', nonce, ...(epoch === undefined ? {} : { token_epoch: epoch }) }) + )}.signature`; describe('refreshed token acceptance', () => { - it('accepts an epoch-changing replacement inside the routine refresh throttle window', async () => { - const requestToken = tokenFor(1, 0); - const refreshedToken = tokenFor(2, 1); - localStorage.setItem('auth_token', requestToken); - markTokenRefreshAccepted(); - - const dispatch = vi.fn(); - const fetchMock = vi.fn((input: string | URL | Request, init?: RequestInit) => { - const url = input instanceof Request ? input.url : input.toString(); - if (url.endsWith('/api/v1/auth/media-cookie')) { - expect(new Headers(init?.headers).get('Authorization')).toBe(`Bearer ${refreshedToken}`); - return Promise.resolve(new Response(null, { status: 204 })); - } - return Promise.resolve( - new Response('{}', { - headers: { 'content-type': 'application/json', 'X-Refreshed-Token': refreshedToken }, - }) + it.each([ + ['an explicit epoch-zero token', tokenFor(1, 0)], + ['a legacy token without an epoch claim', tokenFor(1)], + ])( + 'accepts an epoch-changing replacement for %s inside the routine refresh throttle window', + async (_, requestToken) => { + const refreshedToken = tokenFor(2, 1); + localStorage.setItem('auth_token', requestToken); + markTokenRefreshAccepted(); + + const dispatch = vi.fn(); + const fetchMock = vi.fn((input: string | URL | Request, init?: RequestInit) => { + const url = input instanceof Request ? input.url : input.toString(); + if (url.endsWith('/api/v1/auth/media-cookie')) { + expect(new Headers(init?.headers).get('Authorization')).toBe(`Bearer ${refreshedToken}`); + return Promise.resolve(new Response(null, { status: 204 })); + } + return Promise.resolve( + new Response('{}', { + headers: { 'content-type': 'application/json', 'X-Refreshed-Token': refreshedToken }, + }) + ); + }); + vi.stubGlobal('fetch', fetchMock); + + await dynamicBaseQuery( + buildV1Url('images/i/example.png'), + { + dispatch, + getState: () => ({}), + signal: new AbortController().signal, + abort: () => {}, + endpoint: 'getImageDTO', + type: 'query', + forced: false, + extra: undefined, + } as unknown as BaseQueryApi, + {} ); - }); - vi.stubGlobal('fetch', fetchMock); - await dynamicBaseQuery( - buildV1Url('images/i/example.png'), - { - dispatch, - getState: () => ({}), - signal: new AbortController().signal, - abort: () => {}, - endpoint: 'getImageDTO', - type: 'query', - forced: false, - extra: undefined, - } as unknown as BaseQueryApi, - {} - ); - - expect(fetchMock).toHaveBeenCalledTimes(2); - expect(dispatch).toHaveBeenCalledWith(tokenRefreshed(refreshedToken)); - }); + expect(fetchMock).toHaveBeenCalledTimes(2); + expect(dispatch).toHaveBeenCalledWith(tokenRefreshed(refreshedToken)); + } + ); it('keeps a same-epoch replacement inside the routine refresh throttle window', async () => { const requestToken = tokenFor(1, 1);