diff --git a/ui/auth.config.test.ts b/ui/auth.config.test.ts index 7125ce9845..4c212a87fa 100644 --- a/ui/auth.config.test.ts +++ b/ui/auth.config.test.ts @@ -45,6 +45,33 @@ const ELEVATED_PERMISSIONS: RolePermissionAttributes = { manage_scans: true, }; +// Access token whose "exp" claim is in the past (2001), so the JWT callback +// takes the refresh branch. +const EXPIRED_ACCESS_TOKEN = + "header.eyJzdWIiOiJ1c2VyLTEiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtMSIsImV4cCI6MTAwMDAwMDAwMH0.signature"; +// Access token whose "exp" claim is far in the future (2100). +const ROTATED_ACCESS_TOKEN = + "header.eyJzdWIiOiJ1c2VyLTEiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtMSIsImV4cCI6NDEwMjQ0NDgwMH0.signature"; + +const refreshResponse = (accessToken: string, refreshToken: string) => ({ + ok: true, + status: 200, + json: async () => ({ + data: { + type: "tokens-refresh", + attributes: { access: accessToken, refresh: refreshToken }, + }, + }), +}); + +const blacklistedRefreshResponse = () => ({ + ok: false, + status: 401, + json: async () => ({ + errors: [{ detail: "Token is blacklisted", code: "token_not_valid" }], + }), +}); + describe("authConfig JWT callback", () => { beforeEach(() => { vi.clearAllMocks(); @@ -195,3 +222,115 @@ describe("authConfig JWT callback", () => { expect(result.error).toBeUndefined(); }); }); + +describe("authConfig token refresh with rotated refresh tokens", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(console, "warn").mockImplementation(() => undefined); + }); + + // The rotated-pair cache is module scoped, so each test needs its own refresh + // token to stay independent. + const expiredToken = (refreshToken: string) => ({ + accessToken: EXPIRED_ACCESS_TOKEN, + refreshToken, + tenant_id: "tenant-1", + user: { + name: "Tenant User", + email: "tenant@example.com", + dateJoined: "2026-01-01", + permissions: RESTRICTED_PERMISSIONS, + }, + }); + + it("should reuse the rotated token pair when the previous refresh could not be persisted", async () => { + // Given a refresh that succeeds but whose cookie is never written (Server + // Component render), the API blacklists "refresh-token-1" on rotation, so a + // second refresh with the same stale cookie would be rejected. + const fetchMock = vi + .fn() + .mockResolvedValueOnce( + refreshResponse(ROTATED_ACCESS_TOKEN, "refresh-token-2"), + ) + .mockResolvedValueOnce(blacklistedRefreshResponse()); + vi.stubGlobal("fetch", fetchMock); + const jwtCallback = authConfig.callbacks?.jwt; + if (!jwtCallback) throw new Error("JWT callback is not configured"); + + // When the same stale token is presented twice + const firstResult = await jwtCallback({ + token: expiredToken("stale-cookie-refresh-token"), + user: {} as Parameters[0]["user"], + account: null, + }); + const secondResult = await jwtCallback({ + token: expiredToken("stale-cookie-refresh-token"), + user: {} as Parameters[0]["user"], + account: null, + }); + + // Then the rotated pair is reused instead of burning a blacklisted token + expect(firstResult).toMatchObject({ + accessToken: ROTATED_ACCESS_TOKEN, + refreshToken: "refresh-token-2", + }); + expect(secondResult).toMatchObject({ + accessToken: ROTATED_ACCESS_TOKEN, + refreshToken: "refresh-token-2", + }); + expect(secondResult.error).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(1); + }); + + it("should still report a terminal error when the refresh token is genuinely rejected", async () => { + // Given + const fetchMock = vi.fn().mockResolvedValue(blacklistedRefreshResponse()); + vi.stubGlobal("fetch", fetchMock); + const jwtCallback = authConfig.callbacks?.jwt; + if (!jwtCallback) throw new Error("JWT callback is not configured"); + + // When + const result = await jwtCallback({ + token: expiredToken("rejected-refresh-token"), + user: {} as Parameters[0]["user"], + account: null, + }); + + // Then + expect(result.error).toBe("RefreshAccessTokenError"); + }); + + it("should retry against the API after a transient refresh failure", async () => { + // Given a network failure followed by a healthy response + const fetchMock = vi + .fn() + .mockRejectedValueOnce(new Error("Network unreachable")) + .mockResolvedValueOnce( + refreshResponse(ROTATED_ACCESS_TOKEN, "retried-refresh-token"), + ); + vi.stubGlobal("fetch", fetchMock); + const jwtCallback = authConfig.callbacks?.jwt; + if (!jwtCallback) throw new Error("JWT callback is not configured"); + + // When + const failedResult = await jwtCallback({ + token: expiredToken("transient-refresh-token"), + user: {} as Parameters[0]["user"], + account: null, + }); + const retriedResult = await jwtCallback({ + token: expiredToken("transient-refresh-token"), + user: {} as Parameters[0]["user"], + account: null, + }); + + // Then the failure is not cached and the retry recovers the session + expect(failedResult.error).toBe("RefreshAccessTokenError"); + expect(retriedResult).toMatchObject({ + accessToken: ROTATED_ACCESS_TOKEN, + refreshToken: "retried-refresh-token", + }); + expect(retriedResult.error).toBeUndefined(); + expect(fetchMock).toHaveBeenCalledTimes(2); + }); +}); diff --git a/ui/auth.config.ts b/ui/auth.config.ts index eefc73b2a4..f9ff8199c3 100644 --- a/ui/auth.config.ts +++ b/ui/auth.config.ts @@ -121,24 +121,55 @@ const applyDecodedClaims = ( } }; -const refreshTokenPromises = new Map>(); +interface RotatedCredentials { + accessToken: string; + refreshToken: string; +} -const refreshAccessToken = async (token: AuthToken): Promise => { - const refreshToken = token.refreshToken; +type RefreshOutcome = + | { status: "rotated"; credentials: RotatedCredentials } + | { status: "failed" }; - if (!refreshToken) { - return { - ...token, - error: "MissingRefreshToken", - }; +/** + * The API rotates refresh tokens and blacklists the previous one + * (ROTATE_REFRESH_TOKENS + BLACKLIST_AFTER_ROTATION), while NextAuth can only + * persist the resulting cookie where Set-Cookie is allowed — the proxy, Route + * Handlers and Server Actions, never a Server Component render. A refresh + * performed outside those contexts therefore consumes the stored refresh token + * without saving its replacement, and the next request replays a blacklisted + * token and looks like an expired session. + * + * Keeping the rotated pair keyed by the token that produced it lets any request + * still carrying the stale cookie reuse the replacement instead. The cache is + * per-process, so it narrows the window rather than closing it across replicas. + */ +const ROTATED_CREDENTIALS_TTL_MS = 60 * 1000; + +interface RefreshCacheEntry { + outcome: Promise; + expiresAt: number; +} + +const refreshOutcomes = new Map(); + +const readPendingOutcome = ( + refreshToken: string, +): Promise | undefined => { + const entry = refreshOutcomes.get(refreshToken); + + if (!entry) return undefined; + + if (Date.now() >= entry.expiresAt) { + refreshOutcomes.delete(refreshToken); + return undefined; } - const existingPromise = refreshTokenPromises.get(refreshToken); - - if (existingPromise) { - return existingPromise; - } + return entry.outcome; +}; +const requestRotatedCredentials = async ( + refreshToken: string, +): Promise => { const url = new URL(`${apiBaseUrl}/tokens/refresh`); const bodyData = { @@ -150,77 +181,114 @@ const refreshAccessToken = async (token: AuthToken): Promise => { }, }; - const refreshPromise = (async () => { - try { - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/vnd.api+json", - Accept: "application/vnd.api+json", - }, - body: JSON.stringify(bodyData), - }); + try { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/vnd.api+json", + Accept: "application/vnd.api+json", + }, + body: JSON.stringify(bodyData), + }); - const payload = await response.json().catch(() => undefined); + const payload = await response.json().catch(() => undefined); - if (!response.ok) { - const detail = payload?.errors?.[0]?.detail; - // eslint-disable-next-line no-console - console.warn( - "Failed to refresh access token:", - detail || `HTTP error ${response.status}`, - ); - return { - ...token, - error: "RefreshAccessTokenError", - }; - } + if (!response.ok) { + const detail = payload?.errors?.[0]?.detail; + // eslint-disable-next-line no-console + console.warn( + "Failed to refresh access token:", + detail || `HTTP error ${response.status}`, + ); + return { status: "failed" }; + } - const newAccessToken = payload?.data?.attributes?.access as - | string - | undefined; - const nextRefreshToken = - (payload?.data?.attributes?.refresh as string | undefined) ?? - refreshToken; + const newAccessToken = payload?.data?.attributes?.access as + | string + | undefined; - if (!newAccessToken) { - // eslint-disable-next-line no-console - console.warn("Missing access token in refresh response"); - return { - ...token, - error: "RefreshAccessTokenError", - }; - } + if (!newAccessToken) { + // eslint-disable-next-line no-console + console.warn("Missing access token in refresh response"); + return { status: "failed" }; + } - const nextToken: AuthToken = { - ...token, + const nextRefreshToken = + (payload?.data?.attributes?.refresh as string | undefined) ?? + refreshToken; + + return { + status: "rotated", + credentials: { accessToken: newAccessToken, refreshToken: nextRefreshToken, - error: undefined, - }; - - applyDecodedClaims(nextToken, newAccessToken, "refreshed access token"); - - return nextToken; - } catch (error) { - // eslint-disable-next-line no-console - console.warn("Error refreshing access token:", error); - return { - ...token, - error: "RefreshAccessTokenError", - }; - } - })(); - - refreshTokenPromises.set(refreshToken, refreshPromise); - - try { - return await refreshPromise; - } finally { - refreshTokenPromises.delete(refreshToken); + }, + }; + } catch (error) { + // eslint-disable-next-line no-console + console.warn("Error refreshing access token:", error); + return { status: "failed" }; } }; +const refreshAccessToken = async (token: AuthToken): Promise => { + const refreshToken = token.refreshToken; + + if (!refreshToken) { + return { + ...token, + error: "MissingRefreshToken", + }; + } + + let pendingOutcome = readPendingOutcome(refreshToken); + + if (!pendingOutcome) { + pendingOutcome = requestRotatedCredentials(refreshToken); + const entry: RefreshCacheEntry = { + outcome: pendingOutcome, + // In-flight requests must always dedupe; the real expiry is set once the + // rotated pair is known. + expiresAt: Number.POSITIVE_INFINITY, + }; + refreshOutcomes.set(refreshToken, entry); + + void pendingOutcome.then((outcome) => { + if (outcome.status === "failed") { + // A failure must not be cached: the next attempt has to reach the API. + refreshOutcomes.delete(refreshToken); + return; + } + + entry.expiresAt = Date.now() + ROTATED_CREDENTIALS_TTL_MS; + }); + } + + const outcome = await pendingOutcome; + + if (outcome.status === "failed") { + return { + ...token, + error: "RefreshAccessTokenError", + }; + } + + const nextToken: AuthToken = { + ...token, + accessToken: outcome.credentials.accessToken, + refreshToken: outcome.credentials.refreshToken, + error: undefined, + }; + + applyDecodedClaims( + nextToken, + outcome.credentials.accessToken, + "refreshed access token", + ); + + return nextToken; +}; + export const authConfig = { session: { strategy: "jwt", diff --git a/ui/changelog.d/rotated-refresh-token-reuse.fixed.md b/ui/changelog.d/rotated-refresh-token-reuse.fixed.md new file mode 100644 index 0000000000..1bea63f446 --- /dev/null +++ b/ui/changelog.d/rotated-refresh-token-reuse.fixed.md @@ -0,0 +1 @@ +Valid sessions are no longer signed out when a rotated refresh token could not be persisted