diff --git a/ui/lib/helper.ts b/ui/lib/helper.ts index e6496c5050..c7c714aca8 100644 --- a/ui/lib/helper.ts +++ b/ui/lib/helper.ts @@ -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 = { - 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) => { diff --git a/ui/lib/server-actions-helper.test.ts b/ui/lib/server-actions-helper.test.ts index bfb9fb5ced..4644de68e6 100644 --- a/ui/lib/server-actions-helper.test.ts +++ b/ui/lib/server-actions-helper.test.ts @@ -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", () => ({ / { 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 }); + }); }); diff --git a/ui/lib/server-actions-helper.ts b/ui/lib/server-actions-helper.ts index ae677f2ed2..1c36d347d1 100644 --- a/ui/lib/server-actions-helper.ts +++ b/ui/lib/server-actions-helper.ts @@ -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 diff --git a/ui/lib/server-auth.test.ts b/ui/lib/server-auth.test.ts new file mode 100644 index 0000000000..9d22cd1efb --- /dev/null +++ b/ui/lib/server-auth.test.ts @@ -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", + ); + }); +}); diff --git a/ui/lib/server-auth.ts b/ui/lib/server-auth.ts new file mode 100644 index 0000000000..b292a11233 --- /dev/null +++ b/ui/lib/server-auth.ts @@ -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 => { + 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 = { + Accept: "application/vnd.api+json", + Authorization: `Bearer ${accessToken}`, + }; + + if (options?.contentType) { + authHeaders["Content-Type"] = "application/vnd.api+json"; + } + + return authHeaders; +}; diff --git a/ui/proxy.ts b/ui/proxy.ts index 8784fd260f..179caa1ba3 100644 --- a/ui/proxy.ts +++ b/ui/proxy.ts @@ -59,7 +59,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 = {