mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-30 07:14:20 +00:00
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:
3 files changed
+282
-74
No files matched your search
@@ -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
@@ -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
|
||||
Reference in new issue
Block a user