mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-08-19 09:30:21 +00:00
Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16b6214ade | ||
|
|
8d0573d89c | ||
|
|
627d3b6199 | ||
|
|
ecfea481ef | ||
|
|
eb039d6f77 | ||
|
|
af61400681 | ||
|
|
bcb6ddb950 | ||
|
|
dabed66b3f | ||
|
|
b0b3ecec00 | ||
|
|
34079dcc09 | ||
|
|
bb411eb4bc |
@@ -9,7 +9,8 @@ import {
|
||||
SingleApiKeyResponse,
|
||||
UpdateApiKeyPayload,
|
||||
} from "@/components/users/profile/api-keys/types";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
import { adaptApiKeysResponse } from "./api-keys.adapter";
|
||||
|
||||
@@ -10,6 +10,9 @@ const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,8 +2,9 @@
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { customAttackPathQuerySchema } from "@/lib/attack-paths/custom-query";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
AttackPathCartographySchema,
|
||||
|
||||
@@ -16,6 +16,9 @@ const { fetchMock, getAuthHeadersMock, handleApiResponseMock } = vi.hoisted(
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { AttackPathScan, AttackPathScansResponse } from "@/types/attack-paths";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
export const getCompliancesOverview = async ({
|
||||
|
||||
@@ -29,7 +29,6 @@ import {
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
composeSort,
|
||||
FG_FAIL_FIRST,
|
||||
FG_RECENT_LAST_SEEN,
|
||||
@@ -39,6 +38,10 @@ vi.mock("@/lib", () => ({
|
||||
splitCsvFilterValues,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/provider-filters", () => ({
|
||||
// Simulate real appendSanitizedProviderFilters: appends all non-undefined filters to the URL.
|
||||
appendSanitizedProviderFilters: vi.fn(
|
||||
|
||||
@@ -10,10 +10,10 @@ import {
|
||||
FG_RECENT_LAST_SEEN,
|
||||
FG_SEVERITY_HIGH_FIRST,
|
||||
FINDING_GROUP_RESOURCES_DEFAULT_SORT,
|
||||
getAuthHeaders,
|
||||
includesMutedFindings,
|
||||
splitCsvFilterValues,
|
||||
} from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -31,10 +31,13 @@ import { RESOURCE_DRAWER_OTHER_FINDINGS_SORT } from "@/lib/findings-sort";
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
RESOURCE_DRAWER_OTHER_FINDINGS_SORT,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/provider-filters", () => ({
|
||||
appendSanitizedProviderTypeFilters: appendSanitizedProviderTypeFiltersMock,
|
||||
}));
|
||||
|
||||
@@ -4,11 +4,8 @@ import {
|
||||
getFindingGroupResources,
|
||||
getLatestFindingGroupResources,
|
||||
} from "@/actions/finding-groups";
|
||||
import {
|
||||
apiBaseUrl,
|
||||
getAuthHeaders,
|
||||
RESOURCE_DRAWER_OTHER_FINDINGS_SORT,
|
||||
} from "@/lib";
|
||||
import { apiBaseUrl, RESOURCE_DRAWER_OTHER_FINDINGS_SORT } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { runWithConcurrencyLimit } from "@/lib/concurrency";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
@@ -20,6 +20,9 @@ vi.mock("@/actions/mute-rules", () => ({
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { adaptLatestFindingTriageNote } from "@/actions/findings/findings-triage.adapter";
|
||||
import { createMuteRule } from "@/actions/mute-rules";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
FINDING_TRIAGE_STATUS_LABELS,
|
||||
|
||||
@@ -20,6 +20,9 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { redirect } from "next/navigation";
|
||||
|
||||
import { attachFindingTriageSummariesToResponse } from "@/actions/findings/findings-triage.adapter";
|
||||
import { getFindingTriageAdapterOptions } from "@/actions/findings/findings-triage.options";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { pollTaskUntilSettled } from "@/actions/task/poll";
|
||||
import { apiBaseUrl, getAuthHeaders, parseStringify } from "@/lib";
|
||||
import { apiBaseUrl, parseStringify } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { IntegrationType } from "@/types/integrations";
|
||||
import type { TaskState } from "@/types/tasks";
|
||||
|
||||
@@ -7,7 +7,6 @@ const { fetchMock, pollTaskUntilSettledMock } = vi.hoisted(() => ({
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: vi.fn().mockResolvedValue({ Authorization: "Bearer token" }),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
@@ -18,6 +17,10 @@ vi.mock("@/actions/task/poll", () => ({
|
||||
pollTaskUntilSettled: pollTaskUntilSettledMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: vi.fn().mockResolvedValue({ Authorization: "Bearer token" }),
|
||||
}));
|
||||
|
||||
import { pollJiraDispatchTask, sendJiraDispatch } from "./jira-dispatch";
|
||||
|
||||
describe("sendJiraDispatch", () => {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
"use server";
|
||||
|
||||
import { pollTaskUntilSettled } from "@/actions/task/poll";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { evaluateJiraDispatchTask } from "@/lib/jira-dispatch-result";
|
||||
import { handleApiError } from "@/lib/server-actions-helper";
|
||||
import type {
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { apiBaseUrl } from "@/lib/helper";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { samlConfigFormSchema } from "@/types/formSchemas";
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
const invitationTokenSchema = z.string().min(1).max(500);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { apiBaseUrl } from "@/lib/helper";
|
||||
import {
|
||||
validateBaseUrl,
|
||||
validateCredentials,
|
||||
|
||||
@@ -22,10 +22,13 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiError: handleApiErrorMock,
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
@@ -111,6 +114,16 @@ describe("getAllProviderGroups", () => {
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rethrows the framework redirect from authentication", async () => {
|
||||
// Given
|
||||
const redirectError = new Error("NEXT_REDIRECT");
|
||||
getAuthHeadersMock.mockRejectedValueOnce(redirectError);
|
||||
|
||||
// When / Then
|
||||
await expect(getAllProviderGroups()).rejects.toBe(redirectError);
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns undefined when a later page resolves to an error payload", async () => {
|
||||
handleApiResponseMock
|
||||
.mockResolvedValueOnce(makePage([makeGroup("g1", "Group 1")], 1, 2))
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders, getErrorMessage } from "@/lib";
|
||||
import { apiBaseUrl, getErrorMessage } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { ManageGroupPayload, ProviderGroupsResponse } from "@/types/components";
|
||||
|
||||
@@ -65,9 +66,9 @@ export const getAllProviderGroups = async (): Promise<
|
||||
const allGroups: ProviderGroupsResponse["data"] = [];
|
||||
let lastResponse: ProviderGroupsResponse | undefined;
|
||||
let hasMorePages = true;
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
|
||||
try {
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
while (hasMorePages && currentPage <= maxPages) {
|
||||
const url = new URL(`${apiBaseUrl}/provider-groups`);
|
||||
url.searchParams.append("page[number]", currentPage.toString());
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { apiBaseUrl } from "@/lib/helper";
|
||||
|
||||
import {
|
||||
DeleteMuteRuleActionState,
|
||||
|
||||
@@ -20,6 +20,9 @@ vi.mock("next/cache", () => ({
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { revalidatePath } from "next/cache";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
OrganizationListResponse,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import {
|
||||
getDateFromForTimeRange,
|
||||
type TimeRange,
|
||||
} from "@/app/(prowler)/_overview/severity-over-time/_constants/time-range.constants";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { apiBaseUrl } from "@/lib/helper";
|
||||
import { mutedFindingsConfigFormSchema } from "@/types/formSchemas";
|
||||
import {
|
||||
DeleteMutedFindingsConfigActionState,
|
||||
|
||||
@@ -24,11 +24,14 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getFormValue: getFormValueMock,
|
||||
wait: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/provider-credentials/build-credentials", () => ({
|
||||
buildSecretConfig: vi.fn(() => ({
|
||||
secretType: "access-secret-key",
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders, getFormValue, wait } from "@/lib";
|
||||
import { apiBaseUrl, getFormValue, wait } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { buildSecretConfig } from "@/lib/provider-credentials/build-credentials";
|
||||
import { ProviderCredentialFields } from "@/lib/provider-credentials/provider-credential-fields";
|
||||
import { appendSanitizedProviderInFilters } from "@/lib/provider-filters";
|
||||
|
||||
@@ -27,7 +27,6 @@ import {
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
GENERIC_SERVER_ERROR_MESSAGE:
|
||||
"Server is temporarily unavailable. Please try again in a few minutes.",
|
||||
sanitizeErrorMessage: (message: string, fallback: string) =>
|
||||
@@ -43,6 +42,10 @@ vi.mock("@/lib", () => ({
|
||||
splitCsvFilterValues,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
@@ -8,9 +8,9 @@ import {
|
||||
apiBaseUrl,
|
||||
FINDINGS_FILTERED_SORT,
|
||||
GENERIC_SERVER_ERROR_MESSAGE,
|
||||
getAuthHeaders,
|
||||
sanitizeErrorMessage,
|
||||
} from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { appendSanitizedProviderTypeFilters } from "@/lib/provider-filters";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
@@ -22,6 +22,9 @@ vi.mock("next/navigation", () => ({
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { isCloud } from "@/lib/shared/env";
|
||||
|
||||
|
||||
@@ -4,7 +4,8 @@ import yaml from "js-yaml";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { apiBaseUrl } from "@/lib/helper";
|
||||
import { scanConfigurationFormSchema } from "@/types/formSchemas";
|
||||
import {
|
||||
DeleteScanConfigurationActionState,
|
||||
|
||||
@@ -16,11 +16,14 @@ vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
GENERIC_SERVER_ERROR_MESSAGE:
|
||||
"Server is temporarily unavailable. Please try again in a few minutes.",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getErrorMessage: (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiError: handleApiErrorMock,
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
|
||||
@@ -6,9 +6,9 @@ import { redirect } from "next/navigation";
|
||||
import {
|
||||
apiBaseUrl,
|
||||
GENERIC_SERVER_ERROR_MESSAGE,
|
||||
getAuthHeaders,
|
||||
getErrorMessage,
|
||||
} from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import {
|
||||
COMPLIANCE_REPORT_DISPLAY_NAMES,
|
||||
type ComplianceReportType,
|
||||
|
||||
@@ -25,6 +25,9 @@ vi.mock("next/cache", () => ({
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { scheduleUpdatePayloadSchema } from "@/lib/schedules";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import type {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
export const getTask = async (taskId: string) => {
|
||||
|
||||
@@ -4,7 +4,8 @@ import { revalidatePath } from "next/cache";
|
||||
import { z } from "zod";
|
||||
|
||||
import { signOut } from "@/auth.config";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { apiBaseUrl } from "@/lib/helper";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
export const getAllTenants = async () => {
|
||||
|
||||
@@ -5,7 +5,8 @@ import { redirect } from "next/navigation";
|
||||
import { z } from "zod";
|
||||
|
||||
import { auth } from "@/auth.config";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import {
|
||||
TENANT_MEMBERSHIP_ROLE,
|
||||
|
||||
@@ -14,11 +14,14 @@ const {
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getErrorMessage: (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiError: handleApiErrorMock,
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
import {
|
||||
|
||||
@@ -14,11 +14,14 @@ const {
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getErrorMessage: (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiError: handleApiErrorMock,
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
|
||||
const RECIPIENTS_PATH = "/alerts/recipients";
|
||||
|
||||
@@ -15,11 +15,14 @@ const {
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
GENERIC_SERVER_ERROR_MESSAGE: "Generic server error.",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getErrorMessage: (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
@@ -14,12 +14,15 @@ const {
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.test/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
getErrorMessage: (error: unknown) =>
|
||||
error instanceof Error ? error.message : String(error),
|
||||
GENERIC_SERVER_ERROR_MESSAGE: "Generic server error.",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/server-actions-helper", () => ({
|
||||
handleApiResponse: handleApiResponseMock,
|
||||
}));
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import * as Sentry from "@sentry/nextjs";
|
||||
|
||||
import type { ScanBinaryResult } from "@/actions/scans/scans";
|
||||
import {
|
||||
GENERIC_SERVER_ERROR_MESSAGE,
|
||||
getAuthHeaders,
|
||||
getErrorMessage,
|
||||
} from "@/lib";
|
||||
import { GENERIC_SERVER_ERROR_MESSAGE, getErrorMessage } from "@/lib";
|
||||
import { hasActionError, type ActionErrorResult } from "@/lib/action-errors";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import { SentryErrorSource, SentryErrorType } from "@/sentry";
|
||||
|
||||
|
||||
@@ -15,15 +15,18 @@ vi.mock("@sentry/nextjs", () => ({
|
||||
// keeping the revalidate gate (the behavior under test) running for real.
|
||||
vi.mock("@/lib/helper", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ Authorization: "Bearer token-123" }),
|
||||
parseStringify: (value: unknown) => JSON.parse(JSON.stringify(value)),
|
||||
getErrorMessage: (error: unknown) => String(error),
|
||||
sanitizeErrorMessage: (message: string) => message,
|
||||
GENERIC_SERVER_ERROR_MESSAGE: "Server error",
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getAuthHeaders: vi
|
||||
.fn()
|
||||
.mockResolvedValue({ Authorization: "Bearer token-123" }),
|
||||
}));
|
||||
|
||||
import {
|
||||
createLighthouseV2Session,
|
||||
getLighthouseV2SupportedModels,
|
||||
|
||||
@@ -13,7 +13,8 @@ import type {
|
||||
LighthouseV2SupportedModel,
|
||||
LighthouseV2SupportedProvider,
|
||||
} from "@/app/(prowler)/lighthouse/_types";
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib/helper";
|
||||
import { getAuthHeaders } from "@/lib/auth-headers";
|
||||
import { apiBaseUrl } from "@/lib/helper";
|
||||
import { LIGHTHOUSE_ROUTE } from "@/lib/lighthouse-routes";
|
||||
import { handleApiError, handleApiResponse } from "@/lib/server-actions-helper";
|
||||
import type { JsonApiDocument } from "@/types/jsonapi";
|
||||
|
||||
@@ -2,13 +2,16 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { GET } from "./route";
|
||||
|
||||
const { getAuthHeadersMock } = vi.hoisted(() => ({
|
||||
getAuthHeadersMock: vi.fn(),
|
||||
const { getRouteAuthHeadersMock } = vi.hoisted(() => ({
|
||||
getRouteAuthHeadersMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib", () => ({
|
||||
apiBaseUrl: "https://api.example.com/api/v1",
|
||||
getAuthHeaders: getAuthHeadersMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/auth-headers", () => ({
|
||||
getRouteAuthHeaders: getRouteAuthHeadersMock,
|
||||
}));
|
||||
|
||||
describe("GET /api/scans/[scanId]/report", () => {
|
||||
@@ -17,6 +20,24 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("returns 401 without fetching upstream when authentication is invalid", async () => {
|
||||
// Given
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getRouteAuthHeadersMock.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
const response = await GET(new Request("http://localhost/api"), {
|
||||
params: Promise.resolve({ scanId: "scan-123" }),
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(response.status).toBe(401);
|
||||
await expect(response.json()).resolves.toEqual({ error: "Unauthorized." });
|
||||
expect(response.headers.get("location")).toBeNull();
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("streams the upstream report body without buffering it", async () => {
|
||||
const upstreamBody = new ReadableStream({
|
||||
start(controller) {
|
||||
@@ -34,7 +55,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(new Request("http://localhost/api"), {
|
||||
params: Promise.resolve({ scanId: "scan-123" }),
|
||||
@@ -69,7 +92,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
"fetch",
|
||||
vi.fn().mockResolvedValue(new Response(upstreamBody, { status: 200 })),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api?preflight=1"),
|
||||
@@ -92,7 +117,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
}),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(new Request("http://localhost/api"), {
|
||||
params: Promise.resolve({ scanId: "scan-123" }),
|
||||
@@ -119,7 +146,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
}),
|
||||
),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api?preflight=1"),
|
||||
@@ -142,7 +171,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
Response.json({ data: { id: "task-1" } }, { status: 202 }),
|
||||
),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(new Request("http://localhost/api"), {
|
||||
params: Promise.resolve({ scanId: "scan-123" }),
|
||||
@@ -157,7 +188,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
"fetch",
|
||||
vi.fn().mockRejectedValue(new DOMException("Timed out", "TimeoutError")),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api?preflight=1"),
|
||||
@@ -184,7 +217,9 @@ describe("GET /api/scans/[scanId]/report", () => {
|
||||
),
|
||||
),
|
||||
);
|
||||
getAuthHeadersMock.mockResolvedValue({ Authorization: "Bearer token" });
|
||||
getRouteAuthHeadersMock.mockResolvedValue({
|
||||
Authorization: "Bearer token",
|
||||
});
|
||||
|
||||
const response = await GET(
|
||||
new Request("http://localhost/api?preflight=1"),
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { NextResponse } from "next/server";
|
||||
|
||||
import { apiBaseUrl, getAuthHeaders } from "@/lib";
|
||||
import { apiBaseUrl } from "@/lib";
|
||||
import { getRouteAuthHeaders } from "@/lib/auth-headers";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
export const runtime = "nodejs";
|
||||
@@ -63,7 +64,14 @@ export async function GET(
|
||||
{ params }: ScanReportRouteContext,
|
||||
) {
|
||||
const { scanId } = await params;
|
||||
const headers = await getAuthHeaders({ contentType: false });
|
||||
const headers = await getRouteAuthHeaders({ contentType: false });
|
||||
if (!headers) {
|
||||
return NextResponse.json(
|
||||
{ error: "Unauthorized." },
|
||||
{ status: 401, headers: { "Cache-Control": "no-store" } },
|
||||
);
|
||||
}
|
||||
|
||||
const upstreamUrl = `${apiBaseUrl}/scans/${encodeURIComponent(scanId)}/report`;
|
||||
const isPreflight =
|
||||
new URL(request.url).searchParams.get("preflight") === "1";
|
||||
|
||||
@@ -45,6 +45,33 @@ const ELEVATED_PERMISSIONS: RolePermissionAttributes = {
|
||||
manage_scans: true,
|
||||
};
|
||||
|
||||
// Access token whose "exp" claim is in the past (2001), so the JWT callback
|
||||
// takes the refresh branch.
|
||||
const EXPIRED_ACCESS_TOKEN =
|
||||
"header.eyJzdWIiOiJ1c2VyLTEiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtMSIsImV4cCI6MTAwMDAwMDAwMH0.signature";
|
||||
// Access token whose "exp" claim is far in the future (2100).
|
||||
const ROTATED_ACCESS_TOKEN =
|
||||
"header.eyJzdWIiOiJ1c2VyLTEiLCJ0ZW5hbnRfaWQiOiJ0ZW5hbnQtMSIsImV4cCI6NDEwMjQ0NDgwMH0.signature";
|
||||
|
||||
const refreshResponse = (accessToken: string, refreshToken: string) => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
data: {
|
||||
type: "tokens-refresh",
|
||||
attributes: { access: accessToken, refresh: refreshToken },
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const blacklistedRefreshResponse = () => ({
|
||||
ok: false,
|
||||
status: 401,
|
||||
json: async () => ({
|
||||
errors: [{ detail: "Token is blacklisted", code: "token_not_valid" }],
|
||||
}),
|
||||
});
|
||||
|
||||
describe("authConfig JWT callback", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
@@ -195,3 +222,115 @@ describe("authConfig JWT callback", () => {
|
||||
expect(result.error).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("authConfig token refresh with rotated refresh tokens", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.spyOn(console, "warn").mockImplementation(() => undefined);
|
||||
});
|
||||
|
||||
// The rotated-pair cache is module scoped, so each test needs its own refresh
|
||||
// token to stay independent.
|
||||
const expiredToken = (refreshToken: string) => ({
|
||||
accessToken: EXPIRED_ACCESS_TOKEN,
|
||||
refreshToken,
|
||||
tenant_id: "tenant-1",
|
||||
user: {
|
||||
name: "Tenant User",
|
||||
email: "tenant@example.com",
|
||||
dateJoined: "2026-01-01",
|
||||
permissions: RESTRICTED_PERMISSIONS,
|
||||
},
|
||||
});
|
||||
|
||||
it("should reuse the rotated token pair when the previous refresh could not be persisted", async () => {
|
||||
// Given a refresh that succeeds but whose cookie is never written (Server
|
||||
// Component render), the API blacklists "refresh-token-1" on rotation, so a
|
||||
// second refresh with the same stale cookie would be rejected.
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
refreshResponse(ROTATED_ACCESS_TOKEN, "refresh-token-2"),
|
||||
)
|
||||
.mockResolvedValueOnce(blacklistedRefreshResponse());
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const jwtCallback = authConfig.callbacks?.jwt;
|
||||
if (!jwtCallback) throw new Error("JWT callback is not configured");
|
||||
|
||||
// When the same stale token is presented twice
|
||||
const firstResult = await jwtCallback({
|
||||
token: expiredToken("stale-cookie-refresh-token"),
|
||||
user: {} as Parameters<typeof jwtCallback>[0]["user"],
|
||||
account: null,
|
||||
});
|
||||
const secondResult = await jwtCallback({
|
||||
token: expiredToken("stale-cookie-refresh-token"),
|
||||
user: {} as Parameters<typeof jwtCallback>[0]["user"],
|
||||
account: null,
|
||||
});
|
||||
|
||||
// Then the rotated pair is reused instead of burning a blacklisted token
|
||||
expect(firstResult).toMatchObject({
|
||||
accessToken: ROTATED_ACCESS_TOKEN,
|
||||
refreshToken: "refresh-token-2",
|
||||
});
|
||||
expect(secondResult).toMatchObject({
|
||||
accessToken: ROTATED_ACCESS_TOKEN,
|
||||
refreshToken: "refresh-token-2",
|
||||
});
|
||||
expect(secondResult.error).toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should still report a terminal error when the refresh token is genuinely rejected", async () => {
|
||||
// Given
|
||||
const fetchMock = vi.fn().mockResolvedValue(blacklistedRefreshResponse());
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const jwtCallback = authConfig.callbacks?.jwt;
|
||||
if (!jwtCallback) throw new Error("JWT callback is not configured");
|
||||
|
||||
// When
|
||||
const result = await jwtCallback({
|
||||
token: expiredToken("rejected-refresh-token"),
|
||||
user: {} as Parameters<typeof jwtCallback>[0]["user"],
|
||||
account: null,
|
||||
});
|
||||
|
||||
// Then
|
||||
expect(result.error).toBe("RefreshAccessTokenError");
|
||||
});
|
||||
|
||||
it("should retry against the API after a transient refresh failure", async () => {
|
||||
// Given a network failure followed by a healthy response
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(new Error("Network unreachable"))
|
||||
.mockResolvedValueOnce(
|
||||
refreshResponse(ROTATED_ACCESS_TOKEN, "retried-refresh-token"),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const jwtCallback = authConfig.callbacks?.jwt;
|
||||
if (!jwtCallback) throw new Error("JWT callback is not configured");
|
||||
|
||||
// When
|
||||
const failedResult = await jwtCallback({
|
||||
token: expiredToken("transient-refresh-token"),
|
||||
user: {} as Parameters<typeof jwtCallback>[0]["user"],
|
||||
account: null,
|
||||
});
|
||||
const retriedResult = await jwtCallback({
|
||||
token: expiredToken("transient-refresh-token"),
|
||||
user: {} as Parameters<typeof jwtCallback>[0]["user"],
|
||||
account: null,
|
||||
});
|
||||
|
||||
// Then the failure is not cached and the retry recovers the session
|
||||
expect(failedResult.error).toBe("RefreshAccessTokenError");
|
||||
expect(retriedResult).toMatchObject({
|
||||
accessToken: ROTATED_ACCESS_TOKEN,
|
||||
refreshToken: "retried-refresh-token",
|
||||
});
|
||||
expect(retriedResult.error).toBeUndefined();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
|
||||
+142
-74
@@ -121,24 +121,55 @@ const applyDecodedClaims = (
|
||||
}
|
||||
};
|
||||
|
||||
const refreshTokenPromises = new Map<string, Promise<AuthToken>>();
|
||||
interface RotatedCredentials {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
}
|
||||
|
||||
const refreshAccessToken = async (token: AuthToken): Promise<AuthToken> => {
|
||||
const refreshToken = token.refreshToken;
|
||||
type RefreshOutcome =
|
||||
| { status: "rotated"; credentials: RotatedCredentials }
|
||||
| { status: "failed" };
|
||||
|
||||
if (!refreshToken) {
|
||||
return {
|
||||
...token,
|
||||
error: "MissingRefreshToken",
|
||||
};
|
||||
/**
|
||||
* The API rotates refresh tokens and blacklists the previous one
|
||||
* (ROTATE_REFRESH_TOKENS + BLACKLIST_AFTER_ROTATION), while NextAuth can only
|
||||
* persist the resulting cookie where Set-Cookie is allowed — the proxy, Route
|
||||
* Handlers and Server Actions, never a Server Component render. A refresh
|
||||
* performed outside those contexts therefore consumes the stored refresh token
|
||||
* without saving its replacement, and the next request replays a blacklisted
|
||||
* token and looks like an expired session.
|
||||
*
|
||||
* Keeping the rotated pair keyed by the token that produced it lets any request
|
||||
* still carrying the stale cookie reuse the replacement instead. The cache is
|
||||
* per-process, so it narrows the window rather than closing it across replicas.
|
||||
*/
|
||||
const ROTATED_CREDENTIALS_TTL_MS = 60 * 1000;
|
||||
|
||||
interface RefreshCacheEntry {
|
||||
outcome: Promise<RefreshOutcome>;
|
||||
expiresAt: number;
|
||||
}
|
||||
|
||||
const refreshOutcomes = new Map<string, RefreshCacheEntry>();
|
||||
|
||||
const readPendingOutcome = (
|
||||
refreshToken: string,
|
||||
): Promise<RefreshOutcome> | undefined => {
|
||||
const entry = refreshOutcomes.get(refreshToken);
|
||||
|
||||
if (!entry) return undefined;
|
||||
|
||||
if (Date.now() >= entry.expiresAt) {
|
||||
refreshOutcomes.delete(refreshToken);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const existingPromise = refreshTokenPromises.get(refreshToken);
|
||||
|
||||
if (existingPromise) {
|
||||
return existingPromise;
|
||||
}
|
||||
return entry.outcome;
|
||||
};
|
||||
|
||||
const requestRotatedCredentials = async (
|
||||
refreshToken: string,
|
||||
): Promise<RefreshOutcome> => {
|
||||
const url = new URL(`${apiBaseUrl}/tokens/refresh`);
|
||||
|
||||
const bodyData = {
|
||||
@@ -150,77 +181,114 @@ const refreshAccessToken = async (token: AuthToken): Promise<AuthToken> => {
|
||||
},
|
||||
};
|
||||
|
||||
const refreshPromise = (async () => {
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
Accept: "application/vnd.api+json",
|
||||
},
|
||||
body: JSON.stringify(bodyData),
|
||||
});
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/vnd.api+json",
|
||||
Accept: "application/vnd.api+json",
|
||||
},
|
||||
body: JSON.stringify(bodyData),
|
||||
});
|
||||
|
||||
const payload = await response.json().catch(() => undefined);
|
||||
const payload = await response.json().catch(() => undefined);
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = payload?.errors?.[0]?.detail;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"Failed to refresh access token:",
|
||||
detail || `HTTP error ${response.status}`,
|
||||
);
|
||||
return {
|
||||
...token,
|
||||
error: "RefreshAccessTokenError",
|
||||
};
|
||||
}
|
||||
if (!response.ok) {
|
||||
const detail = payload?.errors?.[0]?.detail;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn(
|
||||
"Failed to refresh access token:",
|
||||
detail || `HTTP error ${response.status}`,
|
||||
);
|
||||
return { status: "failed" };
|
||||
}
|
||||
|
||||
const newAccessToken = payload?.data?.attributes?.access as
|
||||
| string
|
||||
| undefined;
|
||||
const nextRefreshToken =
|
||||
(payload?.data?.attributes?.refresh as string | undefined) ??
|
||||
refreshToken;
|
||||
const newAccessToken = payload?.data?.attributes?.access as
|
||||
| string
|
||||
| undefined;
|
||||
|
||||
if (!newAccessToken) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Missing access token in refresh response");
|
||||
return {
|
||||
...token,
|
||||
error: "RefreshAccessTokenError",
|
||||
};
|
||||
}
|
||||
if (!newAccessToken) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Missing access token in refresh response");
|
||||
return { status: "failed" };
|
||||
}
|
||||
|
||||
const nextToken: AuthToken = {
|
||||
...token,
|
||||
const nextRefreshToken =
|
||||
(payload?.data?.attributes?.refresh as string | undefined) ??
|
||||
refreshToken;
|
||||
|
||||
return {
|
||||
status: "rotated",
|
||||
credentials: {
|
||||
accessToken: newAccessToken,
|
||||
refreshToken: nextRefreshToken,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
applyDecodedClaims(nextToken, newAccessToken, "refreshed access token");
|
||||
|
||||
return nextToken;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Error refreshing access token:", error);
|
||||
return {
|
||||
...token,
|
||||
error: "RefreshAccessTokenError",
|
||||
};
|
||||
}
|
||||
})();
|
||||
|
||||
refreshTokenPromises.set(refreshToken, refreshPromise);
|
||||
|
||||
try {
|
||||
return await refreshPromise;
|
||||
} finally {
|
||||
refreshTokenPromises.delete(refreshToken);
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn("Error refreshing access token:", error);
|
||||
return { status: "failed" };
|
||||
}
|
||||
};
|
||||
|
||||
const refreshAccessToken = async (token: AuthToken): Promise<AuthToken> => {
|
||||
const refreshToken = token.refreshToken;
|
||||
|
||||
if (!refreshToken) {
|
||||
return {
|
||||
...token,
|
||||
error: "MissingRefreshToken",
|
||||
};
|
||||
}
|
||||
|
||||
let pendingOutcome = readPendingOutcome(refreshToken);
|
||||
|
||||
if (!pendingOutcome) {
|
||||
pendingOutcome = requestRotatedCredentials(refreshToken);
|
||||
const entry: RefreshCacheEntry = {
|
||||
outcome: pendingOutcome,
|
||||
// In-flight requests must always dedupe; the real expiry is set once the
|
||||
// rotated pair is known.
|
||||
expiresAt: Number.POSITIVE_INFINITY,
|
||||
};
|
||||
refreshOutcomes.set(refreshToken, entry);
|
||||
|
||||
void pendingOutcome.then((outcome) => {
|
||||
if (outcome.status === "failed") {
|
||||
// A failure must not be cached: the next attempt has to reach the API.
|
||||
refreshOutcomes.delete(refreshToken);
|
||||
return;
|
||||
}
|
||||
|
||||
entry.expiresAt = Date.now() + ROTATED_CREDENTIALS_TTL_MS;
|
||||
});
|
||||
}
|
||||
|
||||
const outcome = await pendingOutcome;
|
||||
|
||||
if (outcome.status === "failed") {
|
||||
return {
|
||||
...token,
|
||||
error: "RefreshAccessTokenError",
|
||||
};
|
||||
}
|
||||
|
||||
const nextToken: AuthToken = {
|
||||
...token,
|
||||
accessToken: outcome.credentials.accessToken,
|
||||
refreshToken: outcome.credentials.refreshToken,
|
||||
error: undefined,
|
||||
};
|
||||
|
||||
applyDecodedClaims(
|
||||
nextToken,
|
||||
outcome.credentials.accessToken,
|
||||
"refreshed access token",
|
||||
);
|
||||
|
||||
return nextToken;
|
||||
};
|
||||
|
||||
export const authConfig = {
|
||||
session: {
|
||||
strategy: "jwt",
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Expired sessions redirect to sign-in before authenticated API requests
|
||||
@@ -0,0 +1 @@
|
||||
Valid sessions are no longer signed out when a rotated refresh token could not be persisted
|
||||
@@ -0,0 +1,67 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { authMock, getAuthHeadersIfAvailableMock, getRequiredAuthHeadersMock } =
|
||||
vi.hoisted(() => ({
|
||||
authMock: vi.fn(),
|
||||
getAuthHeadersIfAvailableMock: vi.fn(),
|
||||
getRequiredAuthHeadersMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
vi.mock("@/auth.config", () => ({
|
||||
auth: authMock,
|
||||
}));
|
||||
|
||||
vi.mock("./server-auth", () => ({
|
||||
getAuthHeadersIfAvailable: getAuthHeadersIfAvailableMock,
|
||||
getRequiredAuthHeaders: getRequiredAuthHeadersMock,
|
||||
}));
|
||||
|
||||
import { getAuthHeaders, getRouteAuthHeaders } from "./auth-headers";
|
||||
|
||||
describe("getAuthHeaders", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("forwards the restored session and requested header options", async () => {
|
||||
// Given
|
||||
authMock.mockResolvedValue({
|
||||
accessToken: "access-token",
|
||||
error: "RefreshAccessTokenError",
|
||||
});
|
||||
const options = { contentType: true };
|
||||
|
||||
// When
|
||||
await getAuthHeaders(options);
|
||||
|
||||
// Then
|
||||
expect(getRequiredAuthHeadersMock).toHaveBeenCalledWith(
|
||||
"access-token",
|
||||
options,
|
||||
"RefreshAccessTokenError",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns no route headers for a terminal session", async () => {
|
||||
// Given
|
||||
authMock.mockResolvedValue({
|
||||
accessToken: "stale-access-token",
|
||||
error: "RefreshAccessTokenError",
|
||||
});
|
||||
getAuthHeadersIfAvailableMock.mockReturnValue(null);
|
||||
|
||||
// When
|
||||
const result = await getRouteAuthHeaders();
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
expect(getAuthHeadersIfAvailableMock).toHaveBeenCalledWith(
|
||||
"stale-access-token",
|
||||
undefined,
|
||||
"RefreshAccessTokenError",
|
||||
);
|
||||
expect(getRequiredAuthHeadersMock).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import "server-only";
|
||||
|
||||
import { auth } from "@/auth.config";
|
||||
|
||||
import {
|
||||
getAuthHeadersIfAvailable,
|
||||
getRequiredAuthHeaders,
|
||||
} from "./server-auth";
|
||||
|
||||
export const getAuthHeaders = async (options?: { contentType?: boolean }) => {
|
||||
const session = await auth();
|
||||
|
||||
return getRequiredAuthHeaders(session?.accessToken, options, session?.error);
|
||||
};
|
||||
|
||||
export const getRouteAuthHeaders = async (options?: {
|
||||
contentType?: boolean;
|
||||
}) => {
|
||||
const session = await auth();
|
||||
|
||||
return getAuthHeadersIfAvailable(
|
||||
session?.accessToken,
|
||||
options,
|
||||
session?.error,
|
||||
);
|
||||
};
|
||||
@@ -15,10 +15,6 @@ vi.mock("@/actions/task", () => ({
|
||||
getTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/auth.config", () => ({
|
||||
auth: vi.fn(),
|
||||
}));
|
||||
|
||||
const createToast = () => vi.fn();
|
||||
|
||||
const getAnchor = () => {
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
type ScanBinaryResult,
|
||||
} from "@/actions/scans";
|
||||
import { getTask } from "@/actions/task";
|
||||
import { auth } from "@/auth.config";
|
||||
import { useToast } from "@/components/shadcn";
|
||||
import {
|
||||
COMPLIANCE_REPORT_DISPLAY_NAMES,
|
||||
@@ -53,26 +52,6 @@ export function filterEmptyValues(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the authentication headers for API requests
|
||||
* @param options - Optional configuration options
|
||||
* @returns Authentication headers with Accept and Authorization
|
||||
*/
|
||||
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;
|
||||
};
|
||||
|
||||
export const getAuthUrl = (provider: AuthSocialProvider) => {
|
||||
const config = {
|
||||
google: {
|
||||
|
||||
@@ -8,6 +8,14 @@ import {
|
||||
|
||||
import { handleApiError, handleApiResponse } from "./server-actions-helper";
|
||||
|
||||
const { unstableRethrowMock } = vi.hoisted(() => ({
|
||||
unstableRethrowMock: vi.fn((error: unknown) => {
|
||||
if (error instanceof Error && error.message === "NEXT_REDIRECT") {
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@sentry/nextjs", () => ({
|
||||
captureException: vi.fn(),
|
||||
captureMessage: vi.fn(),
|
||||
@@ -17,6 +25,10 @@ vi.mock("next/cache", () => ({
|
||||
revalidatePath: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
unstable_rethrow: unstableRethrowMock,
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/helper", () => ({
|
||||
GENERIC_SERVER_ERROR_MESSAGE:
|
||||
"Server is temporarily unavailable. Please try again in a few minutes.",
|
||||
@@ -178,6 +190,14 @@ describe("server-actions-helper", () => {
|
||||
});
|
||||
|
||||
describe("handleApiError", () => {
|
||||
it("should rethrow authentication redirect control flow", () => {
|
||||
// Given
|
||||
const redirectError = new Error("NEXT_REDIRECT");
|
||||
|
||||
// When / Then
|
||||
expect(() => handleApiError(redirectError)).toThrow(redirectError);
|
||||
});
|
||||
|
||||
it("should not recapture errors that were already reported", () => {
|
||||
// Given
|
||||
const error = new Error("Already reported failure");
|
||||
@@ -253,4 +273,42 @@ describe("server-actions-helper", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
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";
|
||||
import {
|
||||
@@ -147,6 +148,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,117 @@
|
||||
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("server-only", () => ({}));
|
||||
|
||||
vi.mock("next/headers", () => ({
|
||||
headers: headersMock,
|
||||
}));
|
||||
|
||||
vi.mock("next/navigation", () => ({
|
||||
redirect: redirectMock,
|
||||
}));
|
||||
|
||||
import {
|
||||
getAuthHeadersIfAvailable,
|
||||
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("returns no headers for a terminal session without redirecting", () => {
|
||||
// Given
|
||||
const accessToken = "stale-access-token";
|
||||
|
||||
// When
|
||||
const result = getAuthHeadersIfAvailable(
|
||||
accessToken,
|
||||
undefined,
|
||||
"RefreshAccessTokenError",
|
||||
);
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns no headers for a missing session without redirecting", () => {
|
||||
// When
|
||||
const result = getAuthHeadersIfAvailable(undefined);
|
||||
|
||||
// Then
|
||||
expect(result).toBeNull();
|
||||
expect(redirectMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to the root callback when the current path is unavailable", async () => {
|
||||
// Given
|
||||
headersMock.mockResolvedValue(new Headers());
|
||||
|
||||
// When / Then
|
||||
await expect(redirectToSignIn()).rejects.toThrow(
|
||||
"NEXT_REDIRECT:/sign-in?callbackUrl=%2F",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
import "server-only";
|
||||
|
||||
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()}`);
|
||||
};
|
||||
|
||||
type SessionError = "RefreshAccessTokenError" | (string & {});
|
||||
|
||||
export const getAuthHeadersIfAvailable = (
|
||||
accessToken: string | undefined,
|
||||
options?: { contentType?: boolean },
|
||||
sessionError?: SessionError,
|
||||
) => {
|
||||
if (!accessToken || sessionError === "RefreshAccessTokenError") {
|
||||
return null;
|
||||
}
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
export const getRequiredAuthHeaders = async (
|
||||
accessToken: string | undefined,
|
||||
options?: { contentType?: boolean },
|
||||
sessionError?: SessionError,
|
||||
) => {
|
||||
const authHeaders = getAuthHeadersIfAvailable(
|
||||
accessToken,
|
||||
options,
|
||||
sessionError,
|
||||
);
|
||||
if (!authHeaders) {
|
||||
return redirectToSignIn();
|
||||
}
|
||||
|
||||
return authHeaders;
|
||||
};
|
||||
+10
-1
@@ -92,7 +92,16 @@ export default auth((req: NextAuthRequest) => {
|
||||
}
|
||||
}
|
||||
|
||||
return withSecurityHeaders(NextResponse.next());
|
||||
const requestHeaders = new Headers(req.headers);
|
||||
requestHeaders.set("x-prowler-current-path", pathname + req.nextUrl.search);
|
||||
|
||||
return withSecurityHeaders(
|
||||
NextResponse.next({
|
||||
request: {
|
||||
headers: requestHeaders,
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
export const config = {
|
||||
|
||||
@@ -10,6 +10,8 @@ import { afterAll, afterEach, beforeAll, vi } from "vitest";
|
||||
|
||||
import { worker } from "./__tests__/msw/worker";
|
||||
|
||||
vi.mock("server-only", () => ({}));
|
||||
|
||||
// Server Actions ("use server") are bundled by Vite as plain async functions
|
||||
// — the directive is a Next.js compiler concept, not part of Vite. When the
|
||||
// page invokes one, it runs in the browser and reaches `auth()` from
|
||||
|
||||
@@ -103,6 +103,7 @@ export default defineConfig(() => {
|
||||
"next-auth/react",
|
||||
"next-auth/providers/credentials",
|
||||
"next-themes",
|
||||
"server-only",
|
||||
|
||||
// App component lib
|
||||
"@iconify/react",
|
||||
|
||||
Reference in New Issue
Block a user