mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
fix(ui): refresh cached permissions after token rotation (#12640)
This commit is contained in:
@@ -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
@@ -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",
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,9 @@
|
||||
export class UserMeError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public readonly status?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "UserMeError";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user