fix(ui): refresh cached permissions after token rotation (#12640)

This commit is contained in:
Alejandro Bailo
2026-08-31 10:49:10 +02:00
committed by GitHub
parent 0326844527
commit 89988dada5
6 changed files with 600 additions and 17 deletions
+92
View File
@@ -154,4 +154,96 @@ describe("auth actions", () => {
expect(result.permissions.manage_lighthouse_ai_configuration).toBe(false);
expect(result.permissions.manage_users).toBe(true);
});
it("should forward an abort signal when loading the current user", async () => {
// Given
mockUserMe({ manage_users: true });
const abortController = new AbortController();
// When
await getUserByMe("access-token", abortController.signal);
// Then
expect(fetchMock).toHaveBeenCalledWith(
"https://api.example.com/api/v1/users/me?include=roles",
expect.objectContaining({ signal: abortController.signal }),
);
});
it.each([
{
status: 401,
detail: "Rejected by API",
message: "Invalid or expired token",
},
{
status: 403,
detail: "Database password: super-secret",
message: "Access denied",
},
{ status: 404, detail: "Rejected by API", message: "User not found" },
])(
"should preserve a $status status when loading the current user fails",
async ({ status, detail, message }) => {
// Given
fetchMock.mockResolvedValue(
new Response(JSON.stringify({ errors: [{ detail }] }), { status }),
);
// When
const result = getUserByMe("access-token");
// Then
await expect(result).rejects.toMatchObject({ message, status });
},
);
it("should preserve a 401 status when the error body is not JSON", async () => {
// Given
fetchMock.mockResolvedValue(new Response("Unauthorized", { status: 401 }));
// When
const result = getUserByMe("access-token");
// Then
await expect(result).rejects.toMatchObject({
message: "Invalid or expired token",
status: 401,
});
});
it("should preserve a 403 status when the error body is not JSON", async () => {
// Given
fetchMock.mockResolvedValue(new Response("Forbidden", { status: 403 }));
// When
const result = getUserByMe("access-token");
// Then
await expect(result).rejects.toMatchObject({
message: "Access denied",
status: 403,
});
});
it("should not expose upstream details for unexpected errors", async () => {
// Given
fetchMock.mockResolvedValue(
new Response(
JSON.stringify({
errors: [{ detail: "Database password: super-secret" }],
}),
{ status: 500 },
),
);
// When
const result = getUserByMe("access-token");
// Then
await expect(result).rejects.toMatchObject({
message: "Unable to load user",
status: 500,
});
});
});
+25 -17
View File
@@ -4,6 +4,7 @@ import { AuthError } from "next-auth";
import { signIn, signOut } from "@/auth.config";
import { apiBaseUrl } from "@/lib";
import { UserMeError } from "@/lib/auth-errors";
import { addAuthEvent } from "@/lib/sentry-breadcrumbs";
import type { UtmParams } from "@/lib/utm";
import type { SignInFormData, SignUpFormData } from "@/types";
@@ -140,7 +141,10 @@ export const getToken = async (formData: SignInFormData) => {
}
};
export const getUserByMe = async (accessToken: string) => {
export const getUserByMe = async (
accessToken: string,
signal?: AbortSignal,
) => {
const url = new URL(`${apiBaseUrl}/users/me?include=roles`);
try {
@@ -150,25 +154,23 @@ export const getUserByMe = async (accessToken: string) => {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${accessToken}`,
},
signal,
});
const parsedResponse = await response.json();
if (!response.ok) {
// Handle different HTTP error codes
switch (response.status) {
case 401:
throw new Error("Invalid or expired token");
case 403:
throw new Error(parsedResponse.errors?.[0]?.detail);
case 404:
throw new Error("User not found");
default:
throw new Error(
parsedResponse.errors?.[0]?.detail || "Unknown error",
);
}
const errorMessage =
response.status === 401
? "Invalid or expired token"
: response.status === 403
? "Access denied"
: response.status === 404
? "User not found"
: "Unable to load user";
throw new UserMeError(errorMessage, response.status);
}
const parsedResponse = await response.json();
const userRole = parsedResponse.included?.find(
(item: any) => item.type === "roles",
);
@@ -193,8 +195,14 @@ export const getUserByMe = async (accessToken: string) => {
dateJoined: parsedResponse.data.attributes.date_joined,
permissions,
};
} catch (error: any) {
throw new Error(error.message || "Network error or server unreachable");
} catch (error: unknown) {
if (error instanceof UserMeError) throw error;
throw new UserMeError(
error instanceof Error
? error.message
: "Network error or server unreachable",
);
}
};
+444
View File
@@ -1,6 +1,7 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { authConfig } from "./auth.config";
import { UserMeError } from "./lib/auth-errors";
import type { RolePermissionAttributes } from "./types/users";
const { getUserByMeMock } = vi.hoisted(() => ({
@@ -45,6 +46,35 @@ const ELEVATED_PERMISSIONS: RolePermissionAttributes = {
manage_scans: true,
};
const accessTokenFor = (tenantId: string, expiration: number) =>
`header.${Buffer.from(
JSON.stringify({ sub: "user-1", tenant_id: tenantId, exp: expiration }),
).toString("base64url")}.signature`;
const successfulRefreshResponse = (accessToken: string, refreshToken: string) =>
new Response(
JSON.stringify({
data: {
attributes: {
access: accessToken,
refresh: refreshToken,
},
},
}),
{ status: 200 },
);
const mockSuccessfulRefresh = (
accessToken: string,
refreshToken = "new-refresh-token",
) => {
const fetchMock = vi
.fn()
.mockResolvedValue(successfulRefreshResponse(accessToken, refreshToken));
vi.stubGlobal("fetch", fetchMock);
return fetchMock;
};
describe("authConfig JWT callback", () => {
beforeEach(() => {
vi.clearAllMocks();
@@ -194,4 +224,418 @@ describe("authConfig JWT callback", () => {
});
expect(result.error).toBeUndefined();
});
it("should replace restricted permissions after access token refresh", async () => {
// Given
const currentAccessToken = accessTokenFor("stale-tenant", 1);
const newAccessToken = accessTokenFor("tenant-1", 4_102_444_800);
mockSuccessfulRefresh(newAccessToken);
getUserByMeMock.mockResolvedValue({
name: "Tenant User",
email: "tenant@example.com",
company: "Tenant Company",
dateJoined: "2026-01-01",
permissions: ELEVATED_PERMISSIONS,
});
const jwtCallback = authConfig.callbacks?.jwt;
if (!jwtCallback) throw new Error("JWT callback is not configured");
// When
const result = await jwtCallback({
token: {
accessToken: currentAccessToken,
refreshToken: "current-refresh-token",
user: {
name: "Tenant User",
email: "tenant@example.com",
dateJoined: "2026-01-01",
permissions: RESTRICTED_PERMISSIONS,
},
},
user: {} as Parameters<typeof jwtCallback>[0]["user"],
});
// Then
expect(getUserByMeMock).toHaveBeenCalledWith(
newAccessToken,
expect.any(AbortSignal),
);
expect(result).toMatchObject({
accessToken: newAccessToken,
refreshToken: "new-refresh-token",
tenant_id: "tenant-1",
user: {
permissions: ELEVATED_PERMISSIONS,
},
});
expect(result.error).toBeUndefined();
});
it("should replace elevated permissions when access is revoked", async () => {
// Given
const currentAccessToken = accessTokenFor("tenant-1", 1);
const newAccessToken = accessTokenFor("tenant-1", 4_102_444_800);
mockSuccessfulRefresh(newAccessToken);
getUserByMeMock.mockResolvedValue({
name: "Tenant User",
email: "tenant@example.com",
company: "Tenant Company",
dateJoined: "2026-01-01",
permissions: RESTRICTED_PERMISSIONS,
});
const jwtCallback = authConfig.callbacks?.jwt;
if (!jwtCallback) throw new Error("JWT callback is not configured");
// When
const result = await jwtCallback({
token: {
accessToken: currentAccessToken,
refreshToken: "current-refresh-token",
user: {
name: "Tenant User",
email: "tenant@example.com",
dateJoined: "2026-01-01",
permissions: ELEVATED_PERMISSIONS,
},
},
user: {} as Parameters<typeof jwtCallback>[0]["user"],
});
// Then
expect(getUserByMeMock).toHaveBeenCalledWith(
newAccessToken,
expect.any(AbortSignal),
);
expect(result.user?.permissions).toEqual(RESTRICTED_PERMISSIONS);
expect(result.error).toBeUndefined();
});
it("should keep the refreshed tokens and cached user when reloading the user fails", async () => {
// Given
const currentAccessToken = accessTokenFor("tenant-1", 1);
const newAccessToken = accessTokenFor("tenant-1", 4_102_444_800);
const warnSpy = vi
.spyOn(console, "warn")
.mockImplementation(() => undefined);
mockSuccessfulRefresh(newAccessToken);
getUserByMeMock.mockRejectedValue(
new UserMeError("Sensitive backend detail", 500),
);
const jwtCallback = authConfig.callbacks?.jwt;
if (!jwtCallback) throw new Error("JWT callback is not configured");
const sessionCallback = authConfig.callbacks?.session;
if (!sessionCallback) throw new Error("Session callback is not configured");
const cachedUser = {
name: "Tenant User",
email: "tenant@example.com",
dateJoined: "2026-01-01",
permissions: ELEVATED_PERMISSIONS,
};
// When
const result = await jwtCallback({
token: {
accessToken: currentAccessToken,
refreshToken: "current-refresh-token",
user: cachedUser,
},
user: {} as Parameters<typeof jwtCallback>[0]["user"],
});
const session = await sessionCallback({
session: {
expires: "2026-12-31T23:59:59.999Z",
user: { name: "Tenant User" },
},
token: result,
} as Parameters<typeof sessionCallback>[0]);
// Then
expect(session).toMatchObject({
accessToken: newAccessToken,
refreshToken: "new-refresh-token",
tenantId: "tenant-1",
user: cachedUser,
});
expect(result.error).toBeUndefined();
expect(warnSpy).toHaveBeenCalledWith(
"Unable to refresh user after access token refresh",
);
});
it("should bound a pending user reload and keep the refreshed session", async () => {
// Given
const currentAccessToken = accessTokenFor("tenant-1", 1);
const newAccessToken = accessTokenFor("tenant-1", 4_102_444_800);
const abortController = new AbortController();
const timeoutSpy = vi
.spyOn(AbortSignal, "timeout")
.mockReturnValue(abortController.signal);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
mockSuccessfulRefresh(newAccessToken);
getUserByMeMock.mockImplementation(
(_accessToken: string, signal?: AbortSignal) => {
if (!signal) return Promise.reject(new Error("Missing abort signal"));
return new Promise((_resolve, reject) => {
signal.addEventListener("abort", () => reject(signal.reason), {
once: true,
});
});
},
);
const jwtCallback = authConfig.callbacks?.jwt;
if (!jwtCallback) throw new Error("JWT callback is not configured");
const cachedUser = {
name: "Tenant User",
email: "tenant@example.com",
dateJoined: "2026-01-01",
permissions: ELEVATED_PERMISSIONS,
};
// When
const resultPromise = jwtCallback({
token: {
accessToken: currentAccessToken,
refreshToken: "current-refresh-token",
user: cachedUser,
},
user: {} as Parameters<typeof jwtCallback>[0]["user"],
});
await vi.waitFor(() => expect(getUserByMeMock).toHaveBeenCalled());
// Then
expect(timeoutSpy).toHaveBeenCalledWith(5_000);
expect(getUserByMeMock).toHaveBeenCalledWith(
newAccessToken,
abortController.signal,
);
abortController.abort(
new DOMException("Request timed out", "TimeoutError"),
);
await expect(resultPromise).resolves.toMatchObject({
accessToken: newAccessToken,
refreshToken: "new-refresh-token",
user: cachedUser,
error: undefined,
});
});
it.each([401, 403, 404])(
"should invalidate the session when reloading the user returns %i",
async (status) => {
// Given
const currentAccessToken = accessTokenFor("tenant-1", 1);
const newAccessToken = accessTokenFor("tenant-1", 4_102_444_800);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
mockSuccessfulRefresh(newAccessToken);
getUserByMeMock.mockRejectedValue(
new UserMeError("Unable to load user", status),
);
const jwtCallback = authConfig.callbacks?.jwt;
if (!jwtCallback) throw new Error("JWT callback is not configured");
const sessionCallback = authConfig.callbacks?.session;
if (!sessionCallback)
throw new Error("Session callback is not configured");
// When
const result = await jwtCallback({
token: {
accessToken: currentAccessToken,
refreshToken: "current-refresh-token",
user: {
name: "Tenant User",
email: "tenant@example.com",
dateJoined: "2026-01-01",
permissions: ELEVATED_PERMISSIONS,
},
},
user: {} as Parameters<typeof jwtCallback>[0]["user"],
});
const session = await sessionCallback({
session: {
expires: "2026-12-31T23:59:59.999Z",
user: { name: "Tenant User" },
},
token: result,
} as Parameters<typeof sessionCallback>[0]);
// Then
expect(result.user).toBeUndefined();
expect(result.accessToken).toBeUndefined();
expect(result.refreshToken).toBeUndefined();
expect(result.error).toBe("RefreshAccessTokenError");
expect(session.user).toBeUndefined();
expect(session.accessToken).toBeUndefined();
expect(session.refreshToken).toBeUndefined();
},
);
it("should invalidate the session when access token refresh fails", async () => {
// Given
const currentAccessToken = accessTokenFor("tenant-1", 1);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
vi.stubGlobal(
"fetch",
vi
.fn()
.mockResolvedValue(
new Response(
JSON.stringify({ errors: [{ detail: "Refresh token expired" }] }),
{ status: 401 },
),
),
);
const jwtCallback = authConfig.callbacks?.jwt;
if (!jwtCallback) throw new Error("JWT callback is not configured");
const sessionCallback = authConfig.callbacks?.session;
if (!sessionCallback) throw new Error("Session callback is not configured");
// When
const result = await jwtCallback({
token: {
accessToken: currentAccessToken,
refreshToken: "expired-refresh-token",
user: {
name: "Tenant User",
email: "tenant@example.com",
dateJoined: "2026-01-01",
permissions: ELEVATED_PERMISSIONS,
},
},
user: {} as Parameters<typeof jwtCallback>[0]["user"],
});
const session = await sessionCallback({
session: {
expires: "2026-12-31T23:59:59.999Z",
user: { name: "Tenant User" },
},
token: result,
} as Parameters<typeof sessionCallback>[0]);
// Then
expect(getUserByMeMock).not.toHaveBeenCalled();
expect(result.error).toBe("RefreshAccessTokenError");
expect(session.error).toBe("RefreshAccessTokenError");
expect(session.user).toBeUndefined();
expect(session.accessToken).toBeUndefined();
expect(session.refreshToken).toBeUndefined();
expect(session.tenantId).toBeUndefined();
});
it("should deduplicate concurrent token and user refreshes", async () => {
// Given
const currentAccessToken = accessTokenFor("tenant-1", 1);
const newAccessToken = accessTokenFor("tenant-1", 4_102_444_800);
const fetchMock = mockSuccessfulRefresh(newAccessToken);
getUserByMeMock.mockResolvedValue({
name: "Tenant User",
email: "tenant@example.com",
company: "Tenant Company",
dateJoined: "2026-01-01",
permissions: ELEVATED_PERMISSIONS,
});
const jwtCallback = authConfig.callbacks?.jwt;
if (!jwtCallback) throw new Error("JWT callback is not configured");
const currentToken = {
accessToken: currentAccessToken,
refreshToken: "shared-refresh-token",
user: {
name: "Tenant User",
email: "tenant@example.com",
dateJoined: "2026-01-01",
permissions: RESTRICTED_PERMISSIONS,
},
};
// When
const [firstResult, secondResult] = await Promise.all([
jwtCallback({
token: { ...currentToken },
user: {} as Parameters<typeof jwtCallback>[0]["user"],
}),
jwtCallback({
token: { ...currentToken },
user: {} as Parameters<typeof jwtCallback>[0]["user"],
}),
]);
// Then
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(getUserByMeMock).toHaveBeenCalledTimes(1);
expect(firstResult).toEqual(secondResult);
expect(firstResult.user?.permissions).toEqual(ELEVATED_PERMISSIONS);
});
it("should retry reloading the user on the next token rotation", async () => {
// Given
const currentAccessToken = accessTokenFor("tenant-1", 1);
const firstAccessToken = accessTokenFor("tenant-1", 1);
const secondAccessToken = accessTokenFor("tenant-1", 4_102_444_800);
vi.spyOn(console, "warn").mockImplementation(() => undefined);
const fetchMock = vi
.fn()
.mockResolvedValueOnce(
successfulRefreshResponse(
firstAccessToken,
"first-rotated-refresh-token",
),
)
.mockResolvedValueOnce(
successfulRefreshResponse(
secondAccessToken,
"second-rotated-refresh-token",
),
);
vi.stubGlobal("fetch", fetchMock);
getUserByMeMock
.mockRejectedValueOnce(new Error("Temporary API failure"))
.mockResolvedValueOnce({
name: "Tenant User",
email: "tenant@example.com",
company: "Tenant Company",
dateJoined: "2026-01-01",
permissions: RESTRICTED_PERMISSIONS,
});
const jwtCallback = authConfig.callbacks?.jwt;
if (!jwtCallback) throw new Error("JWT callback is not configured");
// When
const firstResult = await jwtCallback({
token: {
accessToken: currentAccessToken,
refreshToken: "current-refresh-token",
user: {
name: "Tenant User",
email: "tenant@example.com",
dateJoined: "2026-01-01",
permissions: ELEVATED_PERMISSIONS,
},
},
user: {} as Parameters<typeof jwtCallback>[0]["user"],
});
const secondResult = await jwtCallback({
token: firstResult,
user: {} as Parameters<typeof jwtCallback>[0]["user"],
});
// Then
expect(fetchMock).toHaveBeenCalledTimes(2);
expect(getUserByMeMock).toHaveBeenNthCalledWith(
1,
firstAccessToken,
expect.any(AbortSignal),
);
expect(getUserByMeMock).toHaveBeenNthCalledWith(
2,
secondAccessToken,
expect.any(AbortSignal),
);
expect(firstResult.user?.permissions).toEqual(ELEVATED_PERMISSIONS);
expect(secondResult).toMatchObject({
accessToken: secondAccessToken,
refreshToken: "second-rotated-refresh-token",
user: { permissions: RESTRICTED_PERMISSIONS },
});
});
});
+29
View File
@@ -11,6 +11,7 @@ import { z } from "zod";
import { getToken, getUserByMe } from "./actions/auth";
import { apiBaseUrl } from "./lib";
import { UserMeError } from "./lib/auth-errors";
import {
SLACK_CALLBACK_PATH,
SLACK_EXPIRED_CALLBACK_URL,
@@ -64,6 +65,9 @@ const DEFAULT_PERMISSIONS: RolePermissionAttributes = {
const TENANT_SWITCH_ERROR = "TenantSwitchError";
const NON_RETRYABLE_USER_ME_STATUSES = new Set([401, 403, 404]);
const USER_REFRESH_TIMEOUT_MS = 5_000;
type TokenUserInput = Partial<TokenUser> & { company?: string };
type JwtCallback = NonNullable<NonNullable<NextAuthConfig["callbacks"]>["jwt"]>;
@@ -206,6 +210,31 @@ const refreshAccessToken = async (token: AuthToken): Promise<AuthToken> => {
applyDecodedClaims(nextToken, newAccessToken, "refreshed access token");
try {
const userMeResponse = await getUserByMe(
newAccessToken,
AbortSignal.timeout(USER_REFRESH_TIMEOUT_MS),
);
nextToken.user = tokenUserFromApi(userMeResponse);
} catch (error) {
if (
error instanceof UserMeError &&
error.status !== undefined &&
NON_RETRYABLE_USER_ME_STATUSES.has(error.status)
) {
return {
...nextToken,
accessToken: undefined,
refreshToken: undefined,
user: undefined,
error: "RefreshAccessTokenError",
};
}
// eslint-disable-next-line no-console
console.warn("Unable to refresh user after access token refresh");
}
return nextToken;
} catch (error) {
// eslint-disable-next-line no-console
@@ -0,0 +1 @@
Cached permissions now refresh from `/users/me?include=roles` after access token rotation
+9
View File
@@ -0,0 +1,9 @@
export class UserMeError extends Error {
constructor(
message: string,
public readonly status?: number,
) {
super(message);
this.name = "UserMeError";
}
}