mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-07-24 13:01:56 +00:00
fix(ui): redirect expired sessions to sign-in
This commit is contained in:
+3
-10
@@ -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) => {
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user