fix(ui): redirect expired sessions to sign-in

This commit is contained in:
Alan Buscaglia
2026-07-20 10:04:02 +02:00
parent 35b3ff2c8e
commit 421d48e6f6
6 changed files with 201 additions and 18 deletions
+3 -10
View File
@@ -14,6 +14,8 @@ import {
import { readEnv } from "@/lib/runtime-env";
import { AuthSocialProvider, MetaDataProps, PermissionInfo } from "@/types";
import { getRequiredAuthHeaders } from "./server-auth";
export const baseUrl = process.env.AUTH_URL;
export const apiBaseUrl = readEnv(
"UI_API_BASE_URL",
@@ -61,16 +63,7 @@ export function filterEmptyValues(
export const getAuthHeaders = async (options?: { contentType?: boolean }) => {
const session = await auth();
const headers: Record<string, string> = {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${session?.accessToken}`,
};
if (options?.contentType) {
headers["Content-Type"] = "application/vnd.api+json";
}
return headers;
return getRequiredAuthHeaders(session?.accessToken, options, session?.error);
};
export const getAuthUrl = (provider: AuthSocialProvider) => {
+77 -7
View File
@@ -1,11 +1,20 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { captureExceptionMock, captureMessageMock, revalidatePathMock } =
vi.hoisted(() => ({
captureExceptionMock: vi.fn(),
captureMessageMock: vi.fn(),
revalidatePathMock: vi.fn(),
}));
const {
captureExceptionMock,
captureMessageMock,
revalidatePathMock,
unstableRethrowMock,
} = vi.hoisted(() => ({
captureExceptionMock: vi.fn(),
captureMessageMock: vi.fn(),
revalidatePathMock: vi.fn(),
unstableRethrowMock: vi.fn((error: unknown) => {
if (error instanceof Error && error.message === "NEXT_REDIRECT") {
throw error;
}
}),
}));
vi.mock("@sentry/nextjs", () => ({
captureException: captureExceptionMock,
@@ -16,6 +25,10 @@ vi.mock("next/cache", () => ({
revalidatePath: revalidatePathMock,
}));
vi.mock("next/navigation", () => ({
unstable_rethrow: unstableRethrowMock,
}));
vi.mock("./helper", () => ({
GENERIC_SERVER_ERROR_MESSAGE:
"Server is temporarily unavailable. Please try again in a few minutes.",
@@ -26,7 +39,7 @@ vi.mock("./helper", () => ({
/<html\b|<\/?body\b|<\/?h1\b/i.test(message) ? fallback : message.trim(),
}));
import { handleApiResponse } from "./server-actions-helper";
import { handleApiError, handleApiResponse } from "./server-actions-helper";
describe("server action error handling", () => {
beforeEach(() => {
@@ -34,6 +47,25 @@ describe("server action error handling", () => {
vi.spyOn(console, "error").mockImplementation(() => {});
});
it("rethrows authentication redirect control flow before converting errors", () => {
// Given
const redirectError = new Error("NEXT_REDIRECT");
// When / Then
expect(() => handleApiError(redirectError)).toThrow(redirectError);
});
it("returns ordinary errors as action data", () => {
// Given
const ordinaryError = new Error("API unavailable");
// When
const result = handleApiError(ordinaryError);
// Then
expect(result).toEqual({ error: "API unavailable" });
});
it("throws a generic server error instead of raw HTML for 5xx responses", async () => {
// Given
const response = new Response(
@@ -51,4 +83,42 @@ describe("server action error handling", () => {
"Server is temporarily unavailable. Please try again in a few minutes.",
);
});
it("returns authentication failures as ordinary API error data", async () => {
// Given
const response = new Response(
JSON.stringify({ errors: [{ detail: "Token is invalid or expired" }] }),
{
status: 401,
headers: { "content-type": "application/vnd.api+json" },
},
);
// When
const result = await handleApiResponse(response);
// Then
expect(result).toEqual({
error: "Token is invalid or expired",
errors: [{ detail: "Token is invalid or expired" }],
status: 401,
});
});
it("returns authorization failures without redirecting the session", async () => {
// Given
const response = new Response(
JSON.stringify({ errors: [{ detail: "Permission denied" }] }),
{
status: 403,
headers: { "content-type": "application/vnd.api+json" },
},
);
// When
const result = await handleApiResponse(response);
// Then
expect(result).toMatchObject({ error: "Permission denied", status: 403 });
});
});
+2
View File
@@ -1,5 +1,6 @@
import * as Sentry from "@sentry/nextjs";
import { revalidatePath } from "next/cache";
import { unstable_rethrow } from "next/navigation";
import { SentryErrorSource, SentryErrorType } from "@/sentry";
@@ -155,6 +156,7 @@ export const handleApiResponse = async (
* Includes Sentry error tracking
*/
export const handleApiError = (error: unknown): { error: string } => {
unstable_rethrow(error);
console.error(error);
// Check if this error was already captured by handleApiResponse
+76
View File
@@ -0,0 +1,76 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
const { headersMock, redirectMock } = vi.hoisted(() => ({
headersMock: vi.fn(),
redirectMock: vi.fn((url: string) => {
throw new Error(`NEXT_REDIRECT:${url}`);
}),
}));
vi.mock("next/headers", () => ({
headers: headersMock,
}));
vi.mock("next/navigation", () => ({
redirect: redirectMock,
}));
import { getRequiredAuthHeaders, redirectToSignIn } from "./server-auth";
describe("server authentication", () => {
beforeEach(() => {
vi.clearAllMocks();
headersMock.mockResolvedValue(
new Headers({ "x-prowler-current-path": "/providers?page=2" }),
);
});
it("redirects a missing access token without creating an undefined bearer token", async () => {
// Given
const accessToken = undefined;
// When / Then
await expect(getRequiredAuthHeaders(accessToken)).rejects.toThrow(
"NEXT_REDIRECT:/sign-in?callbackUrl=%2Fproviders%3Fpage%3D2",
);
expect(redirectMock).toHaveBeenCalledOnce();
});
it("redirects a failed refresh session even when it still contains an access token", async () => {
// Given
const accessToken = "stale-access-token";
// When / Then
await expect(
getRequiredAuthHeaders(accessToken, undefined, "RefreshAccessTokenError"),
).rejects.toThrow(
"NEXT_REDIRECT:/sign-in?callbackUrl=%2Fproviders%3Fpage%3D2",
);
expect(redirectMock).toHaveBeenCalledOnce();
});
it("creates bearer headers only when an access token is present", async () => {
// Given
const accessToken = "access-token";
// When
const result = await getRequiredAuthHeaders(accessToken, {
contentType: true,
});
// Then
expect(result).toEqual({
Accept: "application/vnd.api+json",
Authorization: "Bearer access-token",
"Content-Type": "application/vnd.api+json",
});
expect(redirectMock).not.toHaveBeenCalled();
});
it("preserves the protected path when redirecting an authentication failure", async () => {
// When / Then
await expect(redirectToSignIn()).rejects.toThrow(
"NEXT_REDIRECT:/sign-in?callbackUrl=%2Fproviders%3Fpage%3D2",
);
});
});
+35
View File
@@ -0,0 +1,35 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
const CURRENT_PATH_HEADER = "x-prowler-current-path";
const DEFAULT_CALLBACK_PATH = "/";
export const redirectToSignIn = async (): Promise<never> => {
const requestHeaders = await headers();
const callbackUrl =
requestHeaders.get(CURRENT_PATH_HEADER) ?? DEFAULT_CALLBACK_PATH;
const searchParams = new URLSearchParams({ callbackUrl });
redirect(`/sign-in?${searchParams.toString()}`);
};
export const getRequiredAuthHeaders = async (
accessToken: string | undefined,
options?: { contentType?: boolean },
sessionError?: string,
) => {
if (!accessToken || sessionError === "RefreshAccessTokenError") {
return redirectToSignIn();
}
const authHeaders: Record<string, string> = {
Accept: "application/vnd.api+json",
Authorization: `Bearer ${accessToken}`,
};
if (options?.contentType) {
authHeaders["Content-Type"] = "application/vnd.api+json";
}
return authHeaders;
};
+8 -1
View File
@@ -53,7 +53,14 @@ export default auth((req: NextAuthRequest) => {
}
}
return NextResponse.next();
const requestHeaders = new Headers(req.headers);
requestHeaders.set("x-prowler-current-path", pathname + req.nextUrl.search);
return NextResponse.next({
request: {
headers: requestHeaders,
},
});
});
export const config = {