fix(ui): reuse rotated refresh tokens across requests

- Cache the rotated credential pair so a stale session cookie no longer
  replays a blacklisted refresh token
- Keep refresh failures uncached so transient errors can retry
- Cover pair reuse, genuine rejection, and transient retry
This commit is contained in:
Alan Buscaglia committed 2026-07-29 16:40:22 +02:00
1 parent 8d0573d89c
commit 16b6214ade
3 files changed
+282 -74

No files matched your search

+139
View File
@@ -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<typeof jwtCallback>[0]["user"],
account: null,
});
const secondResult = await jwtCallback({
token: expiredToken("stale-cookie-refresh-token"),
user: {} as Parameters<typeof jwtCallback>[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<typeof jwtCallback>[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<typeof jwtCallback>[0]["user"],
account: null,
});
const retriedResult = await jwtCallback({
token: expiredToken("transient-refresh-token"),
user: {} as Parameters<typeof jwtCallback>[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);
});
});
+142 -74
View File
@@ -121,24 +121,55 @@ const applyDecodedClaims = (
}
};
const refreshTokenPromises = new Map<string, Promise<AuthToken>>();
interface RotatedCredentials {
accessToken: string;
refreshToken: string;
}
const refreshAccessToken = async (token: AuthToken): Promise<AuthToken> => {
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<RefreshOutcome>;
expiresAt: number;
}
const refreshOutcomes = new Map<string, RefreshCacheEntry>();
const readPendingOutcome = (
refreshToken: string,
): Promise<RefreshOutcome> | 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<RefreshOutcome> => {
const url = new URL(`${apiBaseUrl}/tokens/refresh`);
const bodyData = {
@@ -150,77 +181,114 @@ const refreshAccessToken = async (token: AuthToken): Promise<AuthToken> => {
},
};
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<AuthToken> => {
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",
@@ -0,0 +1 @@
Valid sessions are no longer signed out when a rotated refresh token could not be persisted