fix(ui): complete Slack OAuth callback server-side to avoid router race (#12572)

Co-authored-by: alejandrobailo <alejandrobailo94@gmail.com>
This commit is contained in:
Pablo Fernandez Guerra (PFE)
2026-08-27 13:54:15 +02:00
committed by GitHub
co-authored by alejandrobailo
parent 301edea7ce
commit a610314eba
20 changed files with 1325 additions and 698 deletions
+11 -6
View File
@@ -284,10 +284,7 @@ describe("exchangeSlackOAuthCode result shape", () => {
// Then — the answer for a body with no `data`: the install happened, only
// its result is unknown.
expect(result).toEqual({
unconfirmed: true,
message: SLACK_UNREADABLE_RESULT_MESSAGE,
});
expect(result).toEqual({ unconfirmed: true });
});
it("hands over the workspace the API upserted", async () => {
@@ -481,7 +478,11 @@ describe("getSlackChannels", () => {
const result = await getSlackChannels(SLACK_INTEGRATION_ID);
expect(result).toEqual({ error: RATE_LIMITED_MESSAGE, code: null });
expect(result).toEqual({
error: RATE_LIMITED_MESSAGE,
code: null,
status: 429,
});
});
});
@@ -727,7 +728,11 @@ describe.each(COPY_ONLY_ACTIONS)("$name", ({ call }) => {
expect(captureExceptionMock).not.toHaveBeenCalled();
expect(captureMessageMock).not.toHaveBeenCalled();
// None of these refusals names a `code`.
expect(result).toEqual({ error: refusal.expected, code: null });
expect(result).toEqual({
error: refusal.expected,
code: null,
status: refusal.status,
});
});
});
+15 -3
View File
@@ -35,7 +35,6 @@ interface SlackRateLimited {
*/
interface SlackUnconfirmed {
unconfirmed: true;
message: string;
}
interface SlackActionError {
@@ -48,6 +47,12 @@ interface SlackActionError {
* reconnecting rather than by retrying.
*/
code?: string | null;
/**
* The refusal's HTTP status, for callers that map a class of refusal the
* `code` does not name — the exchange's code-less `400` for a consumed
* state/code, which no retry can fix.
*/
status?: number;
}
interface SlackAuthorizeUrl {
@@ -177,7 +182,11 @@ const failureFrom = async (
};
}
return { error: slackErrorMessage(failure, fallback), code: failure.code };
return {
error: slackErrorMessage(failure, fallback),
code: failure.code,
status: failure.status,
};
};
/**
@@ -205,6 +214,7 @@ const refusalFrom = async (
? slackRateLimitMessage(failure.retryAfterSeconds)
: slackErrorMessage(failure, fallback),
code: failure.code,
status: failure.status,
};
};
@@ -291,7 +301,9 @@ export const exchangeSlackOAuthCode = async (
revalidatePath("/integrations/slack");
if (!isIntegrationResource(body?.data)) {
return { unconfirmed: true, message: SLACK_UNREADABLE_RESULT_MESSAGE };
// The discriminant is the whole answer: the notice on the integration
// page carries its own unconfirmed copy.
return { unconfirmed: true };
}
return { integration: parseStringify(body.data) as IntegrationProps };
@@ -6,13 +6,7 @@ import { useState } from "react";
import { getIntegrations } from "@/actions/integrations/integrations";
import { getAlertSlackChannels } from "@/app/(prowler)/alerts/_actions/slack-channels";
import { SlackChannelMultiSelect } from "@/components/integrations/slack/slack-channel-multi-select";
import {
Button,
Label,
Tooltip,
TooltipContent,
TooltipTrigger,
} from "@/components/shadcn";
import { Button, Label } from "@/components/shadcn";
import {
MultiSelect,
MultiSelectTrigger,
@@ -115,7 +109,7 @@ const mergeOptions = (
};
const ManageIntegrationLink = () => (
<Button variant="link" size="link-sm" className="h-auto p-0" asChild>
<Button variant="link" size="link-xs" className="h-auto p-0" asChild>
<Link href={SLACK_INTEGRATION_HREF}>Manage the Slack integration</Link>
</Button>
);
@@ -253,27 +247,18 @@ export const SlackChannelsField = ({
) : (
<>
<Label>Destination channels</Label>
<Tooltip>
<TooltipTrigger asChild>
<span className="inline-flex w-full" tabIndex={0}>
<MultiSelect values={[]} onValuesChange={() => undefined}>
<MultiSelectTrigger
id="slack-channels"
aria-label="Destination channels"
// The reason must travel with the control; the tooltip
// only reaches a pointer or the wrapper's focus.
aria-describedby={CHANNELS_NOTICE_ID}
disabled
>
<MultiSelectValue placeholder="No channels available" />
</MultiSelectTrigger>
</MultiSelect>
</span>
</TooltipTrigger>
<TooltipContent side="top" className="max-w-xs">
{noticeCopy}
</TooltipContent>
</Tooltip>
<MultiSelect values={[]} onValuesChange={() => undefined}>
<MultiSelectTrigger
id="slack-channels"
aria-label="Destination channels"
// The reason travels with the control: the notice below is
// visible, this makes assistive tech read it from the field.
aria-describedby={CHANNELS_NOTICE_ID}
disabled
>
<MultiSelectValue placeholder="No channels available" />
</MultiSelectTrigger>
</MultiSelect>
</>
)}
<FieldNotice copy={noticeCopy} />
@@ -1,21 +0,0 @@
import { redirect } from "next/navigation";
import { Suspense } from "react";
import { SlackCallback } from "@/components/integrations/slack/slack-callback";
import { ContentLayout } from "@/components/shadcn/content-layout";
import { isCloud } from "@/lib/shared/env";
export default async function SlackCallbackPage() {
if (!isCloud()) {
redirect("/");
}
return (
<ContentLayout title="Slack">
{/* `SlackCallback` reads the query string, so it needs a boundary. */}
<Suspense fallback={null}>
<SlackCallback />
</Suspense>
</ContentLayout>
);
}
@@ -0,0 +1,302 @@
/**
* Unit tests for the Slack OAuth callback Route Handler. MSW (node) serves the
* same contract double the browser tests use, so the real
* `exchangeSlackOAuthCode` runs underneath — only Next's request-scoped pieces
* (session, cache) are stubbed.
*/
import { setupServer } from "msw/node";
import { revalidatePath } from "next/cache";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from "vitest";
import { handlersForSlack } from "@/__tests__/msw/handlers/slack";
import {
SLACK_EXCHANGE_OUTCOME,
SLACK_OAUTH_CODE,
SLACK_OAUTH_STATE,
SLACK_RETRY_AFTER_SECONDS,
SLACK_WORKSPACE_CONFLICT_CODE,
slackFixture,
} from "@/__tests__/msw/handlers/slack.fixtures";
import type { SlackFixture } from "@/__tests__/msw/handlers/slack.fixtures";
import { GET, HEAD } from "./route";
// The MSW handlers read the literal `process.env.UI_API_BASE_URL`, which
// Vite's `define` inlines at build time; the action resolves the same var
// through `readEnv`'s computed access at module import, which `define` cannot
// reach. This hoisted block runs before either import so both agree.
vi.hoisted(() => {
process.env.UI_API_BASE_URL ??= "http://localhost/api/v1";
});
vi.mock("next/cache", () => ({
revalidatePath: vi.fn(),
revalidateTag: vi.fn(),
unstable_cache: <T>(fn: T) => fn,
}));
// `auth()` reaches for a request scope the test has none of; the exchange only
// needs a bearer the double never checks.
vi.mock("@/auth.config", () => ({
auth: vi.fn(() => Promise.resolve({ accessToken: "test-access-token" })),
}));
const server = setupServer();
const exchangeCalls: string[] = [];
server.events.on("request:start", ({ request }) => {
if (
request.method === "POST" &&
request.url.includes("/slack/oauth/exchange")
) {
exchangeCalls.push(request.url);
}
});
beforeAll(() => server.listen({ onUnhandledRequest: "error" }));
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
const CALLBACK_URL = "https://cloud.prowler.com/integrations/slack/callback";
const wire = (fixture: SlackFixture) =>
server.use(...handlersForSlack(fixture));
const get = (query: string, headers?: HeadersInit) =>
GET(new Request(`${CALLBACK_URL}?${query}`, { headers }));
const HAPPY_QUERY = `code=${SLACK_OAUTH_CODE}&state=${SLACK_OAUTH_STATE}`;
/** The redirect's target, with the `303` asserted on the way. */
const locationOf = (response: Response): URL => {
expect(response.status).toBe(303);
const location = response.headers.get("location");
expect(location).not.toBeNull();
return new URL(location as string);
};
const revalidatedPaths = () =>
vi.mocked(revalidatePath).mock.calls.map(([path]) => path);
beforeEach(() => {
vi.stubEnv("UI_CLOUD_ENABLED", "true");
vi.mocked(revalidatePath).mockClear();
exchangeCalls.length = 0;
});
describe("the Slack OAuth callback route", () => {
it("exchanges the code and redirects to the integration page, leaking neither code nor state", async () => {
wire(slackFixture());
const location = locationOf(await get(HAPPY_QUERY));
expect(location.pathname).toBe("/integrations/slack");
expect(Array.from(location.searchParams.entries())).toEqual([
["slack", "connected"],
]);
// The code is single-use: one exchange, no retry.
expect(exchangeCalls).toHaveLength(1);
// A completed install invalidates the cached "none connected".
expect(revalidatedPaths()).toEqual(
expect.arrayContaining(["/integrations", "/integrations/slack"]),
);
});
it.each([
SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT,
SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML,
SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA,
])(
"reports an unreadable 2xx (%s) as unconfirmed, not as failed",
async (exchangeOutcome) => {
wire(slackFixture({ exchangeOutcome }));
const location = locationOf(await get(HAPPY_QUERY));
expect(Array.from(location.searchParams.entries())).toEqual([
["slack", "unconfirmed"],
]);
// Nothing of the unreadable body rides the redirect.
expect(location.href).not.toMatch(/DOCTYPE|html/i);
// The 2xx says the install happened, so the cache goes with it.
expect(revalidatedPaths()).toEqual(
expect.arrayContaining(["/integrations", "/integrations/slack"]),
);
},
);
it("passes Slack's own refusal through as a token, exchanging nothing", async () => {
const location = locationOf(await get("error=access_denied"));
expect(Array.from(location.searchParams.entries())).toEqual([
["slack", "slack_error"],
["slack_reason", "access_denied"],
]);
expect(exchangeCalls).toHaveLength(0);
});
it("lets the error win when Slack sends it alongside a code, so the code is not spent", async () => {
const location = locationOf(
await get(`error=access_denied&${HAPPY_QUERY}`),
);
expect(location.searchParams.get("slack")).toBe("slack_error");
expect(exchangeCalls).toHaveLength(0);
});
it("drops a refusal reason that is not shaped like a token", async () => {
for (const error of [
"<script>alert(1)</script>",
"Some long sentence, not a reason code.",
]) {
const location = locationOf(
await get(`error=${encodeURIComponent(error)}`),
);
expect(Array.from(location.searchParams.entries())).toEqual([
["slack", "slack_error"],
]);
expect(location.href).not.toMatch(/script|alert|sentence/i);
}
});
// The back button's path: replaying the callback re-sends a state/code the
// API already consumed, which it refuses with a code-less 400.
it.each([
SLACK_EXCHANGE_OUTCOME.REFUSED_STATE,
SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED,
])(
"reports a consumed or timed-out completion (%s) as expired, not as retryable",
async (exchangeOutcome) => {
wire(slackFixture({ exchangeOutcome }));
const location = locationOf(await get(HAPPY_QUERY));
expect(Array.from(location.searchParams.entries())).toEqual([
["slack", "expired"],
]);
},
);
it("names the workspace conflict so the page can keep Prowler's wording for it", async () => {
wire(
slackFixture({
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE,
}),
);
const location = locationOf(await get(HAPPY_QUERY));
expect(Array.from(location.searchParams.entries())).toEqual([
["slack", "error"],
["slack_code", SLACK_WORKSPACE_CONFLICT_CODE],
]);
});
it("carries the rate limit's wait so the page can say when to come back", async () => {
wire(slackFixture({ rateLimited: true }));
const location = locationOf(await get(HAPPY_QUERY));
expect(Array.from(location.searchParams.entries())).toEqual([
["slack", "rate_limited"],
["slack_retry", String(SLACK_RETRY_AFTER_SECONDS)],
]);
});
it("reports Slack being broken upstream as an error", async () => {
wire(slackFixture({ oauthUpstreamError: true }));
const location = locationOf(await get(HAPPY_QUERY));
expect(location.searchParams.get("slack")).toBe("error");
});
it.each([
["state", `code=${SLACK_OAUTH_CODE}`],
["code", `state=${SLACK_OAUTH_STATE}`],
])(
"asks for nothing when the completion carries no %s",
async (_missing, query) => {
const location = locationOf(await get(query));
expect(Array.from(location.searchParams.entries())).toEqual([
["slack", "incomplete"],
]);
expect(exchangeCalls).toHaveLength(0);
},
);
it("answers HEAD itself, since Next would otherwise run the GET for it", () => {
const response = HEAD();
expect(response.status).toBe(204);
expect(response.headers.get("Cache-Control")).toBe("no-store");
expect(exchangeCalls).toHaveLength(0);
});
it("answers a prefetch nothing, so no intermediary burns the code", async () => {
const prefetchHeaders: HeadersInit[] = [
{ "sec-purpose": "prefetch" },
{ purpose: "prefetch" },
{ "x-moz": "prefetch" },
];
for (const headers of prefetchHeaders) {
const response = await get(HAPPY_QUERY, headers);
expect(response.status).toBe(204);
expect(response.headers.get("Cache-Control")).toBe("no-store");
}
expect(exchangeCalls).toHaveLength(0);
});
it("detects a prefetch when an earlier purpose header describes navigation", async () => {
// Given - different clients supplied purpose headers with mixed values.
const headers = {
"sec-purpose": "navigate",
purpose: "prefetch",
"x-moz": "navigate",
};
// When
const response = await get(HAPPY_QUERY, headers);
// Then - any prefetch marker prevents the single-use code exchange.
expect(response.status).toBe(204);
expect(exchangeCalls).toHaveLength(0);
});
it("exchanges the code when ordinary navigation follows a prefetch", async () => {
// Given - a speculative request reached the callback before navigation.
wire(slackFixture());
const prefetchResponse = await get(HAPPY_QUERY, {
"sec-purpose": "prefetch",
});
// When - the browser performs the ordinary callback navigation.
const location = locationOf(await get(HAPPY_QUERY));
// Then - the guard cannot be cached and the real exchange still runs once.
expect(prefetchResponse.headers.get("Cache-Control")).toBe("no-store");
expect(location.searchParams.get("slack")).toBe("connected");
expect(exchangeCalls).toHaveLength(1);
});
it("sends non-cloud deployments home without exchanging anything", async () => {
vi.stubEnv("UI_CLOUD_ENABLED", "false");
const location = locationOf(await get(HAPPY_QUERY));
expect(location.pathname).toBe("/");
expect(exchangeCalls).toHaveLength(0);
});
});
@@ -0,0 +1,132 @@
import { NextResponse } from "next/server";
import { exchangeSlackOAuthCode } from "@/actions/integrations/slack";
import type { SlackExchangeResult } from "@/actions/integrations/slack";
import {
SLACK_INTEGRATION_PATH,
SLACK_CONNECT_STATUS,
slackConnectQuery,
} from "@/lib/integrations/slack-connect-status";
import type { SlackConnectOutcomeInput } from "@/lib/integrations/slack-connect-status";
import { isCloud } from "@/lib/shared/env";
/**
* Slack's OAuth callback, completed server-side: exchange, then an HTTP `303`
* to the integration page with the outcome in the query string.
*
* A Route Handler rather than a page, like the GitHub/Google/SAML callbacks: a
* client-side `router.replace` after the exchange rides the App Router action
* queue, where a Server Action resolving mid-navigation can cancel it
* (vercel/next.js#88343) — and the single-use `code` must not reach hydrated
* client code or the session history, which the redirect guarantees.
*/
const noStoreNoContent = (): NextResponse =>
new NextResponse(null, {
status: 204,
headers: { "Cache-Control": "no-store" },
});
const redirectTo = (request: Request, target: string): NextResponse =>
NextResponse.redirect(new URL(target, request.url), 303);
const outcomeRedirect = (
request: Request,
outcome: SlackConnectOutcomeInput,
): NextResponse =>
redirectTo(
request,
`${SLACK_INTEGRATION_PATH}?${slackConnectQuery(outcome)}`,
);
/**
* `Sec-Purpose` per the fetch spec; `Purpose` and `X-moz` are the legacy
* Chrome/Safari and Firefox spellings.
*/
const isPrefetch = (request: Request): boolean => {
return ["sec-purpose", "purpose", "x-moz"].some(
(header) =>
request.headers.get(header)?.toLowerCase().includes("prefetch") ?? false,
);
};
/**
* Explicit, because Next otherwise auto-implements HEAD by running the GET —
* which would exchange (and burn) the single-use code before the user's own
* GET arrives.
*/
export function HEAD(): Response {
return noStoreNoContent();
}
export async function GET(request: Request): Promise<NextResponse> {
if (!isCloud()) return redirectTo(request, "/");
// A speculative fetch would burn the single-use code before the user
// arrives — answer it nothing instead.
if (isPrefetch(request)) return noStoreNoContent();
const { searchParams } = new URL(request.url);
const slackError = searchParams.get("error");
const code = searchParams.get("code");
const state = searchParams.get("state");
// Slack answers a declined install with `error` and no code; when both are
// present, `error` still wins and nothing is exchanged.
if (slackError) {
return outcomeRedirect(request, {
status: SLACK_CONNECT_STATUS.SLACK_ERROR,
reason: slackError,
});
}
if (!code || !state) {
return outcomeRedirect(request, {
status: SLACK_CONNECT_STATUS.INCOMPLETE,
});
}
let result: SlackExchangeResult;
try {
result = await exchangeSlackOAuthCode({ code, state });
} catch {
// The action is written to never throw; if it does anyway, the API may
// already have upserted the integration, so the claim is "unconfirmed",
// not "failed".
return outcomeRedirect(request, {
status: SLACK_CONNECT_STATUS.UNCONFIRMED,
});
}
if ("integration" in result) {
return outcomeRedirect(request, {
status: SLACK_CONNECT_STATUS.CONNECTED,
});
}
if ("unavailable" in result) {
return outcomeRedirect(request, {
status: SLACK_CONNECT_STATUS.UNAVAILABLE,
});
}
if ("rateLimited" in result) {
return outcomeRedirect(request, {
status: SLACK_CONNECT_STATUS.RATE_LIMITED,
retryAfterSeconds: result.retryAfterSeconds,
});
}
if ("unconfirmed" in result) {
return outcomeRedirect(request, {
status: SLACK_CONNECT_STATUS.UNCONFIRMED,
});
}
// A 400 naming no reason is the exchange's "state or code already consumed
// or timed out" refusal — the back button's path. Retrying cannot help, so
// it gets its own status rather than the generic "try again" error.
if (!result.code && result.status === 400) {
return outcomeRedirect(request, { status: SLACK_CONNECT_STATUS.EXPIRED });
}
return outcomeRedirect(request, {
status: SLACK_CONNECT_STATUS.ERROR,
code: result.code ?? null,
});
}
@@ -1,246 +0,0 @@
/**
* Browser-mode tests for the Slack OAuth callback
* (`/integrations/slack/callback`), driven through `SlackIntegrationHarness`.
* MSW answers from handlers derived from the API contract in `design.md`.
*/
import { describe, expect } from "vitest";
import { it } from "@/__tests__/fixtures";
import {
SLACK_EXCHANGE_OUTCOME,
SLACK_OAUTH_CODE,
SLACK_OAUTH_STATE,
slackFixture,
} from "@/__tests__/msw/handlers/slack.fixtures";
import { SlackIntegrationHarness } from "./slack-integration.harness";
/** The workspace the fixtures connect. */
const WORKSPACE_NAME = "Prowler HQ";
/**
* Callback headlines, spelled out rather than imported so a rename fails here.
* `FAILURE_TITLE` is for installs that connected nothing; `UNCONFIRMED_TITLE`
* for answers that arrive after the API already upserted the integration.
*/
const FAILURE_TITLE = "Slack workspace not connected";
const UNCONFIRMED_TITLE = "Slack install not confirmed";
describe("returning from Slack", () => {
it("completes the install and shows the connected workspace", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
expect(await harness.completedInstall()).toBe(true);
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
// The code is single-use and the exchange runs from a render (design D4):
// without the once-guard, a second call burns it and reports a failure.
expect(harness.exchangeCallCount).toBe(1);
// A completed install invalidates the cached "none connected".
expect(harness.revalidatedPaths).toEqual(
expect.arrayContaining(["/integrations", "/integrations/slack"]),
);
}, 30000);
it("does not report an install the API completed as failed when it answers no content", async () => {
// Given — a `204`: the API consumed the code and upserted the integration,
// then answered with no body. `response.ok` is true, so this is no refusal.
const harness = new SlackIntegrationHarness(
slackFixture({
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_CONTENT,
}),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
const reason = await harness.installFailureReason();
expect(reason).toMatch(/could not read the result of the install/);
expect(reason).toMatch(/Slack integration page/);
expect(reason).not.toMatch(/JSON/i);
expect(harness.offersRetry()).toBe(true);
// The `204` says the workspace is connected; the headline cannot deny it.
expect(await harness.installFailureTitle()).toBe(UNCONFIRMED_TITLE);
// The install exists, so the cached "none connected" has to go with it.
expect(harness.revalidatedPaths).toEqual(
expect.arrayContaining(["/integrations", "/integrations/slack"]),
);
}, 30000);
it("shows Prowler's own wording when a proxy answers the completion with an HTML page", async () => {
// Given — a proxy answering `200` with a challenge page instead of JSON.
const harness = new SlackIntegrationHarness(
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_HTML }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
// V8's parse message truncates before the word `html`, so the shared
// HTML-shaped-error filter cannot catch this one.
const reason = await harness.installFailureReason();
expect(reason).toMatch(/could not read the result of the install/);
expect(reason).not.toMatch(/DOCTYPE/i);
expect(reason).not.toMatch(/not valid JSON/i);
}, 30000);
it("says the result is unreadable, not that the workspace is unknown, when the answer names no resource", async () => {
// Given — a `200` carrying well-formed JSON:API with no `data` member.
const harness = new SlackIntegrationHarness(
slackFixture({
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.UNREADABLE_NO_DATA,
}),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
const reason = await harness.installFailureReason();
expect(reason).toMatch(/could not read the result of the install/);
expect(reason).not.toMatch(/undefined/i);
expect(await harness.completedInstall()).toBe(false);
}, 30000);
it("connects nothing when the user declines in Slack, and offers to retry", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountCallback({ error: "access_denied" });
expect(await harness.installFailureReason()).toMatch(
/not approved in Slack/,
);
expect(harness.offersRetry()).toBe(true);
// A declined consent carries no code, so there was nothing to exchange.
expect(harness.exchangeCallCount).toBe(0);
}, 30000);
it("surfaces the reason when Slack refuses to complete the install", async () => {
// Given — Slack rejects the code, and the API's own wording explains it.
const harness = new SlackIntegrationHarness(
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.SLACK_REFUSED }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
// A refusal Prowler has no wording of its own for falls back to the API's
// `detail`, not to a generic failure.
expect(await harness.installFailureReason()).toMatch(
/OAuth code is invalid/,
);
expect(harness.offersRetry()).toBe(true);
}, 30000);
it("surfaces a completion the API refuses, and connects nothing", async () => {
// Given — the state was minted for another session, or already consumed.
const harness = new SlackIntegrationHarness(
slackFixture({ exchangeOutcome: SLACK_EXCHANGE_OUTCOME.REFUSED_STATE }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: "state-from-another-session",
});
expect(await harness.installFailureReason()).toMatch(
/state is invalid, expired, or already consumed/,
);
// The API refused before consuming anything, so nothing was created and the
// headline states that plainly.
expect(await harness.installFailureTitle()).toBe(FAILURE_TITLE);
expect(await harness.completedInstall()).toBe(false);
expect(harness.offersRetry()).toBe(true);
// Refused once, not retried into a second burnt code.
expect(harness.exchangeCallCount).toBe(1);
}, 30000);
it("says how to resolve a workspace conflict, in Prowler's own words", async () => {
// Given — this tenant already has a different workspace connected, which
// the API refuses as a 409 naming the conflict in `code`.
const harness = new SlackIntegrationHarness(
slackFixture({
exchangeOutcome: SLACK_EXCHANGE_OUTCOME.DIFFERENT_WORKSPACE,
}),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
// The copy comes from the error `code`: the API's `detail` states the
// conflict but not the way out of it.
const reason = await harness.installFailureReason();
expect(reason).toMatch(/already connected to a different Slack workspace/);
expect(reason).toMatch(/Disconnect it before connecting another/);
expect(reason).not.toMatch(/tenant/);
expect(await harness.completedInstall()).toBe(false);
expect(harness.offersRetry()).toBe(true);
}, 30000);
it("tells the user when to come back if Slack is rate limiting the install", async () => {
// Given — Slack answers 429 with a Retry-After.
const harness = new SlackIntegrationHarness(
slackFixture({ rateLimited: true }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
const reason = await harness.installFailureReason();
expect(reason).toMatch(/rate limiting/);
expect(reason).toMatch(/about 30 seconds/);
expect(reason).not.toMatch(/not available in this environment/);
// A 429 refuses the exchange outright, so nothing was connected: the plain
// headline, unlike the unreadable `2xx` that arrives after the upsert.
expect(await harness.installFailureTitle()).toBe(FAILURE_TITLE);
expect(harness.offersRetry()).toBe(true);
}, 30000);
it("reports Slack being broken upstream, rather than leaving the callback spinning", async () => {
// Given — the completion answers `502`, the contract's status for a Slack
// upstream failure. The shared 5xx handling throws, so the callback only
// renders this if the action answers that rejection itself.
const harness = new SlackIntegrationHarness(
slackFixture({ oauthUpstreamError: true }),
);
await harness.mountCallback({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
// The API refused, so nothing was created: not the "could not confirm" the
// page falls back to when the action never answers at all.
const reason = await harness.installFailureReason();
expect(reason).toMatch(/temporarily unavailable/);
expect(reason).not.toMatch(/could not confirm/);
expect(await harness.completedInstall()).toBe(false);
expect(harness.offersRetry()).toBe(true);
}, 30000);
it("does not attempt an exchange when the completion carries no state", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountCallback({ code: SLACK_OAUTH_CODE });
// Refused before the API is ever asked, so no code is spent.
expect(await harness.installFailureReason()).toMatch(/incomplete response/);
expect(harness.exchangeCallCount).toBe(0);
}, 30000);
});
@@ -0,0 +1,215 @@
/**
* Browser-mode tests for the notice the OAuth callback route leaves on the
* Slack integration page (`/integrations/slack?slack=…`), driven through
* `SlackIntegrationHarness`. The route handler's own side of the contract —
* exchanging the code and writing these params — is unit-tested in
* `callback/route.test.ts`; here the page is opened the way its redirect
* opens it.
*/
import { describe, expect } from "vitest";
import { it } from "@/__tests__/fixtures";
import {
connectedSlackFixture,
SLACK_RETRY_AFTER_SECONDS,
SLACK_UNMAPPED_REASON_CODE,
SLACK_WORKSPACE_CONFLICT_CODE,
slackFixture,
} from "@/__tests__/msw/handlers/slack.fixtures";
import { SlackIntegrationHarness } from "./slack-integration.harness";
/** The workspace the fixtures connect. */
const WORKSPACE_NAME = "Prowler HQ";
/**
* Notice headlines, spelled out rather than imported so a rename fails here.
* `FAILURE_TITLE` is for installs that connected nothing; `UNCONFIRMED_TITLE`
* for answers that arrived after the API already upserted the integration.
*/
const CONNECTED_TITLE = "Slack workspace connected";
const FAILURE_TITLE = "Slack workspace not connected";
const UNCONFIRMED_TITLE = "Slack install not confirmed";
describe("returning from Slack", () => {
it("shows the connected workspace with the success notice, and cleans the URL", async () => {
const harness = new SlackIntegrationHarness(connectedSlackFixture());
await harness.mountAfterReturnFromSlack({ slack: "connected" });
expect(await harness.connectNoticeTitle()).toBe(CONNECTED_TITLE);
expect(await harness.connectedWorkspaceName()).toBe(WORKSPACE_NAME);
// The params are spent: a reload or a shared URL shows no stale outcome —
// while the notice itself survives its own cleanup.
expect(await harness.strippedQuery()).toBe("");
expect(harness.hasConnectNotice()).toBe(true);
}, 30000);
it("does not claim success when the server lists no connected workspace", async () => {
// Given - a handcrafted success token but no Slack integration in server data.
const harness = new SlackIntegrationHarness(slackFixture());
// When
await harness.mountAfterReturnFromSlack({ slack: "connected" });
// Then - the page treats the unverified claim as an unconfirmed install.
expect(await harness.connectNoticeTitle()).toBe(UNCONFIRMED_TITLE);
expect(harness.offersInstall()).toBe(true);
}, 30000);
it("keeps the query params the notice does not own", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountAfterReturnFromSlack({
slack: "unconfirmed",
foo: "bar",
});
expect(await harness.connectNoticeTitle()).toBe(UNCONFIRMED_TITLE);
expect(await harness.strippedQuery()).toBe("?foo=bar");
}, 30000);
it("keeps the URL fragment while removing the Slack query params", async () => {
// Given
const harness = new SlackIntegrationHarness(slackFixture());
// When
await harness.mountAfterReturnFromSlack(
{ slack: "unconfirmed" },
"#channels",
);
// Then
expect(await harness.strippedQuery()).toBe("");
expect(window.location.hash).toBe("#channels");
}, 30000);
it("connects nothing when the user declines in Slack, and still offers the install", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountAfterReturnFromSlack({
slack: "slack_error",
slack_reason: "access_denied",
});
expect(await harness.connectNoticeTitle()).toBe(FAILURE_TITLE);
expect(await harness.connectNoticeDescription()).toMatch(
/not approved in Slack/,
);
expect(harness.offersInstall()).toBe(true);
}, 30000);
it("names a Slack reason it has no wording of its own for", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountAfterReturnFromSlack({
slack: "slack_error",
slack_reason: SLACK_UNMAPPED_REASON_CODE,
});
expect(await harness.connectNoticeDescription()).toMatch(
new RegExp(`\\(${SLACK_UNMAPPED_REASON_CODE}\\)`),
);
}, 30000);
it("says the completion was incomplete when Slack sent no usable answer back", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountAfterReturnFromSlack({ slack: "incomplete" });
expect(await harness.connectNoticeTitle()).toBe(FAILURE_TITLE);
expect(await harness.connectNoticeDescription()).toMatch(
/incomplete response/,
);
}, 30000);
it("says a spent install link is done for, not to try again", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountAfterReturnFromSlack({ slack: "expired" });
expect(await harness.connectNoticeTitle()).toBe(FAILURE_TITLE);
const description = await harness.connectNoticeDescription();
expect(description).toMatch(/already been used or expired/);
expect(description).toMatch(/Start the install again/);
expect(description).not.toMatch(/in a moment/);
// The way out it names is on offer right below.
expect(harness.offersInstall()).toBe(true);
}, 30000);
it("says Slack is not available in this environment", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountAfterReturnFromSlack({ slack: "unavailable" });
expect(await harness.connectNoticeDescription()).toMatch(
/not available in this environment yet/,
);
}, 30000);
it("tells the user when to come back if Slack rate limited the install", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountAfterReturnFromSlack({
slack: "rate_limited",
slack_retry: String(SLACK_RETRY_AFTER_SECONDS),
});
expect(await harness.connectNoticeDescription()).toMatch(
/about 30 seconds/,
);
}, 30000);
it("does not deny an install the API may have completed", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountAfterReturnFromSlack({ slack: "unconfirmed" });
expect(await harness.connectNoticeTitle()).toBe(UNCONFIRMED_TITLE);
expect(await harness.connectNoticeDescription()).toMatch(
/If none is listed below/,
);
}, 30000);
it("keeps Prowler's wording for a refusal the API named by code", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountAfterReturnFromSlack({
slack: "error",
slack_code: SLACK_WORKSPACE_CONFLICT_CODE,
});
const description = await harness.connectNoticeDescription();
expect(description).toMatch(
/already connected to a different Slack workspace/,
);
expect(description).toMatch(/Disconnect it before connecting another/);
}, 30000);
it("never renders a code it cannot vouch for", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mountAfterReturnFromSlack({
slack: "error",
slack_code: "<script>alert(1)</script>",
});
const description = await harness.connectNoticeDescription();
expect(description).toMatch(/could not complete that request/);
expect(description).not.toMatch(/script|alert/);
}, 30000);
it("shows no notice on a plain visit, or for a status it does not recognise", async () => {
const harness = new SlackIntegrationHarness(slackFixture());
await harness.mount();
expect(harness.hasConnectNotice()).toBe(false);
await harness.mountAfterReturnFromSlack({
slack: "definitely_not_a_status",
});
expect(harness.offersInstall()).toBe(true);
expect(harness.hasConnectNotice()).toBe(false);
}, 30000);
});
@@ -1,5 +1,8 @@
import { Suspense } from "react";
import { getIntegrations } from "@/actions/integrations/integrations";
import { getSlackAuthorizeUrl } from "@/actions/integrations/slack";
import { SlackConnectNotice } from "@/components/integrations/slack/slack-connect-notice";
import { SlackIntegrationManager } from "@/components/integrations/slack/slack-integration-manager";
import { GENERIC_SERVER_ERROR_MESSAGE } from "@/lib/helper";
import { INTEGRATION_TYPE, type IntegrationProps } from "@/types/integrations";
@@ -41,21 +44,29 @@ export async function SlackIntegrationContent() {
const authorize = integration ? null : await getSlackAuthorizeUrl();
return (
<SlackIntegrationManager
integration={integration}
authorizeUrl={
authorize && "authorizeUrl" in authorize ? authorize.authorizeUrl : null
}
unavailable={Boolean(authorize && "unavailable" in authorize)}
// Rate limited is not unavailable: the install is still on offer, it just
// cannot be started yet.
rateLimitMessage={
authorize && "rateLimited" in authorize ? authorize.message : null
}
loadError={
loadError ??
(authorize && "error" in authorize ? authorize.error : null)
}
/>
<div className="flex flex-col gap-6">
{/* Suspense: the notice reads `useSearchParams`. */}
<Suspense fallback={null}>
<SlackConnectNotice hasConnectedWorkspace={Boolean(integration)} />
</Suspense>
<SlackIntegrationManager
integration={integration}
authorizeUrl={
authorize && "authorizeUrl" in authorize
? authorize.authorizeUrl
: null
}
unavailable={Boolean(authorize && "unavailable" in authorize)}
// Rate limited is not unavailable: the install is still on offer, it
// just cannot be started yet.
rateLimitMessage={
authorize && "rateLimited" in authorize ? authorize.message : null
}
loadError={
loadError ??
(authorize && "error" in authorize ? authorize.error : null)
}
/>
</div>
);
}
@@ -15,7 +15,7 @@ import type { SlackFixture } from "@/__tests__/msw/handlers/slack.fixtures";
import { worker } from "@/__tests__/msw/worker";
import { render } from "@/__tests__/render-browser";
import { setSlackAuthorizedChannels } from "@/actions/integrations/slack";
import { SlackCallback } from "@/components/integrations/slack/slack-callback";
import { SLACK_CONNECT_PARAMS } from "@/lib/integrations/slack-connect-status";
import { IntegrationsContent } from "../integrations-content";
@@ -53,13 +53,6 @@ const REVOCATION_NOTICE = /revocation/i;
/** The alert shown when Slack has stopped accepting the credential. */
const REVOKED_CREDENTIAL_NOTICE = /no longer accepts Prowler's access/;
interface CallbackParams {
code?: string;
state?: string;
/** Slack's own refusal code, e.g. `access_denied`. */
error?: string;
}
/** What a picker search leaves on offer. */
interface ChannelSearch {
/** Names still offered once the filter landed, in the order offered. */
@@ -129,22 +122,28 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
await rendered.rerender(await SlackIntegrationContent());
}
async mountCallback({ code, state, error }: CallbackParams): Promise<void> {
/**
* Open the integration page the way the OAuth callback route's redirect
* does: with the outcome it wrote in the query string. The route handler
* itself cannot run in this lane, so its side of the contract is covered by
* `callback/route.test.ts`.
*/
async mountAfterReturnFromSlack(
params: Record<string, string>,
fragment = "",
): Promise<void> {
// Unmount first, or two copies of the page answer every query.
(await this.mounted)?.unmount();
const params = new URLSearchParams();
if (code) params.set("code", code);
if (state) params.set("state", state);
if (error) params.set("error", error);
window.history.replaceState(
null,
"",
`/integrations/slack/callback?${params.toString()}`,
`/integrations/slack?${new URLSearchParams(params).toString()}${fragment}`,
);
this.wireHandlers();
// Held so the `revisit()` that follows a reinstall can take it down first.
this.mounted = render(createElement(SlackCallback));
const readsBefore = this.channelListCallCount;
this.mounted = render(await SlackIntegrationContent());
if (this.fixture.install) await this.waitForChannelsRead(readsBefore);
}
/** Mount the integrations catalogue. No handlers: every card there is static. */
@@ -376,51 +375,53 @@ export class SlackIntegrationHarness extends BrowserHarness<SlackFixture> {
// --- Returning from Slack -----------------------------------------------
/**
* The one element every non-success outcome renders. Keyed on it rather than
* the alert title, which is not the same claim on every outcome.
*/
private backLink(): HTMLAnchorElement | null {
return (
Array.from(this.container.querySelectorAll("a")).find(
(anchor) =>
anchor.getAttribute("href") === "/integrations/slack" &&
/Back to Slack integration/.test(anchor.textContent ?? ""),
) ?? null
);
private connectNotice(): HTMLElement | null {
return this.q("[data-slack-connect-notice]");
}
async completedInstall(): Promise<boolean> {
const outcome = await this.waitFor(
() => this.containsText(/Connected to /) || this.backLink() !== null,
10000,
"the callback outcome",
);
return outcome && this.containsText(/Connected to /);
/** Whether the page shows a callback outcome at all. Does not wait. */
hasConnectNotice(): boolean {
return this.connectNotice() !== null;
}
async installFailureReason(): Promise<string> {
await this.waitFor(() => this.backLink(), 10000, "the failed callback");
const description = await this.waitFor(
() => this.q('[data-slot="alert-description"]'),
5000,
"the failure reason",
);
return (description.textContent ?? "").trim();
}
async installFailureTitle(): Promise<string> {
await this.waitFor(() => this.backLink(), 10000, "the failed callback");
async connectNoticeTitle(): Promise<string> {
const title = await this.waitFor(
() => this.q('[data-slot="alert-title"]'),
5000,
"the failure title",
() => this.q('[data-slack-connect-notice] [data-slot="alert-title"]'),
10000,
"the connect notice title",
);
return (title.textContent ?? "").trim();
}
offersRetry(): boolean {
return this.backLink() !== null || this.offersInstall();
async connectNoticeDescription(): Promise<string> {
const description = await this.waitFor(
() =>
this.q('[data-slack-connect-notice] [data-slot="alert-description"]'),
10000,
"the connect notice description",
);
return (description.textContent ?? "").trim();
}
/**
* The query string once the notice's own URL cleanup has landed. Waits on
* the `slack*` params being gone, so an assertion never reads mid-strip.
*/
async strippedQuery(): Promise<string> {
const settled = await this.waitFor(
() => {
const current = window.location.search;
// Keyed on the contract's own param names, not a substring: a
// preserved param merely mentioning "slack" is not a leftover.
const params = new URLSearchParams(current);
return SLACK_CONNECT_PARAMS.some((param) => params.has(param))
? null
: current || "<none>";
},
5000,
"the slack params to be stripped from the URL",
);
return settled === "<none>" ? "" : settled;
}
// --- Choosing a destination channel --------------------------------------
@@ -1,8 +1,9 @@
/**
* Browser-mode tests for the Slack integration page (`/integrations/slack`),
* driven through `SlackIntegrationHarness`. MSW answers from handlers derived
* from the API contract in `design.md`. The OAuth callback is its own route,
* covered in `slack-callback-page.integration.test.tsx`.
* from the API contract in `design.md`. The OAuth callback is a Route Handler,
* covered in `callback/route.test.ts`; the notice its redirect leaves on this
* page is covered in `slack-connect-notice.integration.test.tsx`.
*/
import { describe, expect } from "vitest";
@@ -38,6 +39,7 @@ import {
unreadableCheckTimeSlackFixture,
unreportedRevocationSlackFixture,
} from "@/__tests__/msw/handlers/slack.fixtures";
import { exchangeSlackOAuthCode } from "@/actions/integrations/slack";
import {
CONNECTION_OUTCOME,
@@ -831,12 +833,15 @@ describe("authorizing destination channels", () => {
await harness.mount();
expect(harness.connectionCheckHint()).toMatch(/nothing is posted/);
// When — the same workspace is approved again.
await harness.mountCallback({
// When — the same workspace is approved again. The exchange is the
// callback route's doing (covered in `callback/route.test.ts`); here it
// runs directly against the same wired double, whose state the next
// visit reads back.
const exchanged = await exchangeSlackOAuthCode({
code: SLACK_OAUTH_CODE,
state: SLACK_OAUTH_STATE,
});
expect(await harness.completedInstall()).toBe(true);
expect("integration" in exchanged).toBe(true);
await harness.revisit();
// Then — a same-workspace reinstall keeps the channels and resets every
+7 -1
View File
@@ -11,6 +11,10 @@ import { z } from "zod";
import { getToken, getUserByMe } from "./actions/auth";
import { apiBaseUrl } from "./lib";
import {
SLACK_CALLBACK_PATH,
SLACK_EXPIRED_CALLBACK_URL,
} from "./lib/integrations/slack-connect-status";
import type { RolePermissionAttributes } from "./types/users";
interface CustomJwtPayload extends JwtPayload {
@@ -317,7 +321,9 @@ export const authConfig = {
const signInUrl = new URL("/sign-in", nextUrl.origin);
signInUrl.searchParams.set(
"callbackUrl",
nextUrl.pathname + nextUrl.search,
nextUrl.pathname === SLACK_CALLBACK_PATH
? SLACK_EXPIRED_CALLBACK_URL
: nextUrl.pathname + nextUrl.search,
);
// Include session error if present (e.g., RefreshAccessTokenError)
if (sessionError) {
@@ -0,0 +1 @@
Returning from Slack after approving the install now reliably lands on the Slack integration page instead of getting stuck on the callback screen (Prowler Cloud only)
@@ -1,156 +0,0 @@
/**
* The cases `slack-page.integration.test.tsx` cannot express: it runs the Server
* Action as a plain function, so there is no client→server transport to reject,
* and its handler only answers the contract's shapes. React error boundaries
* cannot see a rejection awaited in an effect, so an uncaught one leaves the
* user on the spinner with no error and no way out.
*/
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { IntegrationProps } from "@/types/integrations";
import { SlackCallback } from "./slack-callback";
const COMPLETED_QUERY = "code=slack-code-1f4a&state=st-2f1c9d7a";
const { exchangeSlackOAuthCode, callbackQuery, routerReplace } = vi.hoisted(
() => ({
exchangeSlackOAuthCode: vi.fn(),
callbackQuery: { value: "" },
routerReplace: vi.fn(),
}),
);
vi.mock("@/actions/integrations/slack", () => ({ exchangeSlackOAuthCode }));
// One router across renders, so the redirect off the spent code is assertable.
const router = { replace: routerReplace };
vi.mock("next/navigation", () => ({
useRouter: () => router,
useSearchParams: () => new URLSearchParams(callbackQuery.value),
}));
beforeEach(() => {
callbackQuery.value = COMPLETED_QUERY;
routerReplace.mockClear();
});
const SPINNER_COPY = /Connecting your Slack workspace/;
/**
* Literals, not imports: a rename on the component's side has to fail here.
* `FAILURE_TITLE` claims nothing was connected, which only holds for outcomes
* that happen before the API consumed the code.
*/
const FAILURE_TITLE = "Slack workspace not connected";
const UNCONFIRMED_TITLE = "Slack install not confirmed";
describe("returning from Slack when the completion answers unexpectedly", () => {
it("reports an unconfirmed result instead of spinning forever when the exchange call never comes back", async () => {
// The client→server POST itself fails (dropped connection, action id
// invalidated by a deploy), so the action's own error handling never runs.
exchangeSlackOAuthCode.mockRejectedValue(new TypeError("Failed to fetch"));
render(<SlackCallback />);
// The API consumes the single-use code before answering, so the workspace
// may well be connected: unknown, not failed.
expect(
await screen.findByText(/could not confirm whether the workspace/i),
).toBeInTheDocument();
expect(
screen.getByRole("link", { name: /Back to Slack integration/ }),
).toHaveAttribute("href", "/integrations/slack");
expect(screen.queryByText(SPINNER_COPY)).not.toBeInTheDocument();
expect(screen.getByText(UNCONFIRMED_TITLE)).toBeInTheDocument();
expect(screen.queryByText(FAILURE_TITLE)).not.toBeInTheDocument();
});
it("still reports the workspace as connected when the created integration carries no configuration", async () => {
// The install already succeeded; `configuration` only goes missing on the
// client, where the callback reads the workspace name off it.
exchangeSlackOAuthCode.mockResolvedValue({
integration: {
type: "integrations",
id: "slack-integration-1",
attributes: {
inserted_at: "2026-08-10T09:00:00Z",
updated_at: "2026-08-10T09:00:00Z",
enabled: true,
connected: null,
connection_last_checked_at: null,
integration_type: "slack",
},
links: { self: "/api/v1/integrations/slack-integration-1" },
// Cast: the shape is the one the contract rules out.
} as unknown as IntegrationProps,
});
render(<SlackCallback />);
expect(
await screen.findByText(/Connected to your Slack workspace/),
).toBeInTheDocument();
expect(screen.queryByText(SPINNER_COPY)).not.toBeInTheDocument();
// Keyed on the escape link, the only element unique to the failure branch,
// so this holds whichever headline that branch would have carried.
expect(
screen.queryByRole("link", { name: /Back to Slack integration/ }),
).not.toBeInTheDocument();
// `replace`, not `push`: a back navigation must not remount onto the code.
expect(routerReplace).toHaveBeenCalledWith("/integrations/slack");
});
});
describe("returning from Slack with an error on the callback URL", () => {
it("says the install was declined when Slack reports the approval was refused", async () => {
// The one code Slack reliably sends to this redirect.
callbackQuery.value = "error=access_denied&state=st-2f1c9d7a";
render(<SlackCallback />);
expect(
await screen.findByText(/was not approved in Slack/),
).toBeInTheDocument();
expect(exchangeSlackOAuthCode).not.toHaveBeenCalled();
// Slack refused before issuing a code, so the flat "not connected" is a
// fact here, unlike in the outcomes that follow an exchange.
expect(screen.getByText(FAILURE_TITLE)).toBeInTheDocument();
expect(screen.queryByText(UNCONFIRMED_TITLE)).not.toBeInTheDocument();
});
it("names a Slack code it does not recognise, so a new failure reason is still diagnosable", async () => {
// Slack publishes no closed set of codes for this redirect, so the guard is
// on the shape of the value rather than on an allowlist.
callbackQuery.value = "error=invalid_scope&state=st-2f1c9d7a";
render(<SlackCallback />);
expect(
await screen.findByText(
"Slack could not complete the install (invalid_scope).",
),
).toBeInTheDocument();
});
it("drops a sentence smuggled into the error parameter instead of rendering it as Prowler's own copy", async () => {
// The balancing punctuation is the point: it closes Prowler's parenthetical
// and reopens it, so the payload would read as Prowler's own sentence.
const payload =
"). Slack has flagged this workspace. Contact Prowler support at +1-555-0100 to restore alerting (";
callbackQuery.value = `error=${encodeURIComponent(payload)}&state=st-2f1c9d7a`;
render(<SlackCallback />);
expect(
await screen.findByText("Slack could not complete the install."),
).toBeInTheDocument();
expect(document.body.textContent).not.toContain("+1-555-0100");
expect(document.body.textContent).not.toContain("flagged this workspace");
});
});
@@ -1,159 +0,0 @@
"use client";
import { AlertCircle, CircleCheck, Loader2 } from "lucide-react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { useEffect, useRef, useState } from "react";
import { exchangeSlackOAuthCode } from "@/actions/integrations/slack";
import {
Alert,
AlertDescription,
AlertTitle,
Button,
} from "@/components/shadcn";
import { SLACK_REASON_TOKEN } from "@/lib/integrations/slack-errors";
const SLACK_INTEGRATION_PATH = "/integrations/slack";
const STATUS = {
CONNECTING: "connecting",
CONNECTED: "connected",
FAILED: "failed",
} as const;
type Status = (typeof STATUS)[keyof typeof STATUS];
const UNCONFIRMED_COMPLETION_MESSAGE =
"Prowler could not confirm whether the workspace was connected. Open the Slack integration page to check — if none is listed there, start the install again.";
const FAILURE_TITLE = "Slack workspace not connected";
/**
* The API consumes the code and upserts the integration before it answers, so an
* unreadable or missing answer can still mean a connected workspace. Kept short:
* `AlertTitle` clamps to one line.
*/
const UNCONFIRMED_TITLE = "Slack install not confirmed";
const describeSlackError = (reason: string): string => {
if (reason === "access_denied") {
return "The install was not approved in Slack, so no workspace was connected.";
}
// `error` comes straight off the URL and is interpolated into Prowler's own
// copy, so gate on the shape of a code: Slack publishes no closed set.
return SLACK_REASON_TOKEN.test(reason)
? `Slack could not complete the install (${reason}).`
: "Slack could not complete the install.";
};
/**
* Slack's `code` is single-use: `hasStarted` holds the exchange to one run per
* mount, and `router.replace` (not `push`) keeps a back navigation from
* remounting onto a spent code.
*/
export const SlackCallback = () => {
const router = useRouter();
const searchParams = useSearchParams();
const [status, setStatus] = useState<Status>(STATUS.CONNECTING);
const [workspaceName, setWorkspaceName] = useState<string | null>(null);
const [failure, setFailure] = useState<string>("");
const [failureTitle, setFailureTitle] = useState<string>(FAILURE_TITLE);
const hasStarted = useRef(false);
useEffect(() => {
if (hasStarted.current) return;
hasStarted.current = true;
const slackError = searchParams.get("error");
const code = searchParams.get("code");
const state = searchParams.get("state");
// Slack answers a declined install with `error` and no code, so there is
// nothing to exchange.
if (slackError) {
setFailure(describeSlackError(slackError));
setStatus(STATUS.FAILED);
return;
}
if (!code || !state) {
setFailure(
"Slack sent an incomplete response back, so the install could not be completed.",
);
setStatus(STATUS.FAILED);
return;
}
const complete = async () => {
const result = await exchangeSlackOAuthCode({ code, state });
if ("integration" in result) {
setWorkspaceName(
result.integration.attributes?.configuration?.team_name ?? null,
);
setStatus(STATUS.CONNECTED);
router.replace(SLACK_INTEGRATION_PATH);
return;
}
if ("unavailable" in result) {
setFailure("Slack is not available in this environment yet.");
} else if ("rateLimited" in result) {
setFailure(result.message);
} else if ("unconfirmed" in result) {
setFailure(result.message);
setFailureTitle(UNCONFIRMED_TITLE);
} else {
setFailure(result.error);
}
setStatus(STATUS.FAILED);
};
// A rejection here means the call never came back (stale action id after a
// deploy, HTML 502): error boundaries cannot see a rejection awaited inside
// an effect, and the once-guard blocks a retry, so the page would spin.
void complete().catch(() => {
setFailure(UNCONFIRMED_COMPLETION_MESSAGE);
setFailureTitle(UNCONFIRMED_TITLE);
setStatus(STATUS.FAILED);
});
}, [router, searchParams]);
if (status === STATUS.CONNECTING) {
return (
<div className="flex items-center gap-3 text-sm text-gray-600 dark:text-gray-300">
<Loader2 className="animate-spin" size={16} />
Connecting your Slack workspace...
</div>
);
}
if (status === STATUS.CONNECTED) {
return (
<Alert variant="success">
<CircleCheck />
<AlertTitle>
Connected to {workspaceName ?? "your Slack workspace"}
</AlertTitle>
<AlertDescription>
Taking you back to the Slack integration, where you can authorize the
channels Prowler posts to.
</AlertDescription>
</Alert>
);
}
return (
<div className="flex flex-col items-start gap-4">
<Alert variant="error">
<AlertCircle />
<AlertTitle>{failureTitle}</AlertTitle>
<AlertDescription>{failure}</AlertDescription>
</Alert>
<Button asChild variant="outline">
<Link href={SLACK_INTEGRATION_PATH}>Back to Slack integration</Link>
</Button>
</div>
);
};
@@ -0,0 +1,161 @@
"use client";
import { AlertCircle, CircleCheck } from "lucide-react";
import { useSearchParams } from "next/navigation";
import { useState } from "react";
import type { ComponentProps } from "react";
import { Alert, AlertDescription, AlertTitle } from "@/components/shadcn";
import { useMountEffect } from "@/hooks/use-mount-effect";
import {
readSlackConnectOutcome,
SLACK_CONNECT_PARAMS,
SLACK_CONNECT_STATUS,
} from "@/lib/integrations/slack-connect-status";
import type { SlackConnectOutcome } from "@/lib/integrations/slack-connect-status";
import {
SLACK_GENERIC_ERROR_MESSAGE,
SLACK_REASON_TOKEN,
slackErrorMessage,
slackRateLimitMessage,
} from "@/lib/integrations/slack-errors";
const FAILURE_TITLE = "Slack workspace not connected";
/**
* The API consumes the code and upserts the integration before it answers, so
* an unreadable or missing answer can still mean a connected workspace. Kept
* short: `AlertTitle` clamps to one line.
*/
const UNCONFIRMED_TITLE = "Slack install not confirmed";
const describeSlackError = (reason: string | null): string => {
if (reason === "access_denied") {
return "The install was not approved in Slack, so no workspace was connected.";
}
// The reason came off the URL and is interpolated into Prowler's own copy,
// so gate on the shape of a code (the contract already did — belt and
// braces): Slack publishes no closed set.
return reason && SLACK_REASON_TOKEN.test(reason)
? `Slack could not complete the install (${reason}).`
: "Slack could not complete the install.";
};
interface NoticeContent {
variant: ComponentProps<typeof Alert>["variant"];
title: string;
description: string;
}
const noticeFor = (outcome: SlackConnectOutcome): NoticeContent => {
switch (outcome.status) {
case SLACK_CONNECT_STATUS.CONNECTED:
return {
variant: "success",
title: "Slack workspace connected",
description: "You can now authorize the channels Prowler posts to.",
};
case SLACK_CONNECT_STATUS.SLACK_ERROR:
return {
variant: "error",
title: FAILURE_TITLE,
description: describeSlackError(outcome.reason),
};
case SLACK_CONNECT_STATUS.INCOMPLETE:
return {
variant: "error",
title: FAILURE_TITLE,
description:
"Slack sent an incomplete response back, so the install could not be completed.",
};
case SLACK_CONNECT_STATUS.EXPIRED:
return {
variant: "error",
title: FAILURE_TITLE,
// Not "try again in a moment": a spent install link never recovers.
description:
"The install link from Slack had already been used or expired, so no workspace was connected. Start the install again.",
};
case SLACK_CONNECT_STATUS.UNAVAILABLE:
return {
variant: "error",
title: FAILURE_TITLE,
description: "Slack is not available in this environment yet.",
};
case SLACK_CONNECT_STATUS.RATE_LIMITED:
return {
variant: "error",
title: FAILURE_TITLE,
description: slackRateLimitMessage(outcome.retryAfterSeconds),
};
case SLACK_CONNECT_STATUS.UNCONFIRMED:
return {
variant: "error",
title: UNCONFIRMED_TITLE,
description:
"Prowler could not confirm whether the workspace was connected. If none is listed below, start the install again.",
};
case SLACK_CONNECT_STATUS.ERROR:
return {
variant: "error",
title: FAILURE_TITLE,
// Known codes keep Prowler's wording; the URL carries no free text, so
// everything else reads as the generic refusal.
description: slackErrorMessage(
{ code: outcome.code },
SLACK_GENERIC_ERROR_MESSAGE,
),
};
}
};
/**
* The outcome the OAuth callback route left in the query string, shown once.
* The params are stripped via the History API rather than `router.replace`: an
* RSC refetch here buys nothing (the exchange already revalidated), and a
* client navigation is exactly what the callback stopped relying on.
*/
interface SlackConnectNoticeProps {
hasConnectedWorkspace: boolean;
}
export const SlackConnectNotice = ({
hasConnectedWorkspace,
}: SlackConnectNoticeProps) => {
const searchParams = useSearchParams();
// Read once into state: the notice has to survive its own URL cleanup.
const [outcome] = useState<SlackConnectOutcome | null>(() =>
readSlackConnectOutcome(new URLSearchParams(searchParams.toString())),
);
useMountEffect(() => {
if (!outcome) return;
const params = new URLSearchParams(window.location.search);
SLACK_CONNECT_PARAMS.forEach((param) => params.delete(param));
const query = params.toString();
const fragment = window.location.hash;
window.history.replaceState(
null,
"",
query
? `${window.location.pathname}?${query}${fragment}`
: `${window.location.pathname}${fragment}`,
);
});
if (!outcome) return null;
const verifiedOutcome =
outcome.status === SLACK_CONNECT_STATUS.CONNECTED && !hasConnectedWorkspace
? { ...outcome, status: SLACK_CONNECT_STATUS.UNCONFIRMED }
: outcome;
const notice = noticeFor(verifiedOutcome);
return (
<Alert data-slack-connect-notice variant={notice.variant}>
{notice.variant === "success" ? <CircleCheck /> : <AlertCircle />}
<AlertTitle>{notice.title}</AlertTitle>
<AlertDescription>{notice.description}</AlertDescription>
</Alert>
);
};
@@ -0,0 +1,106 @@
import { describe, expect, it } from "vitest";
import {
readSlackConnectOutcome,
SLACK_CONNECT_STATUS,
slackConnectQuery,
} from "./slack-connect-status";
describe("the Slack connect status contract", () => {
it("round-trips every field the writer accepts", () => {
const written = slackConnectQuery({
status: SLACK_CONNECT_STATUS.RATE_LIMITED,
retryAfterSeconds: 30,
});
expect(readSlackConnectOutcome(written)).toEqual({
status: "rate_limited",
reason: null,
code: null,
retryAfterSeconds: 30,
});
});
it("keeps a token-shaped reason and code", () => {
expect(
slackConnectQuery({
status: SLACK_CONNECT_STATUS.SLACK_ERROR,
reason: "access_denied",
}).get("slack_reason"),
).toBe("access_denied");
expect(
slackConnectQuery({
status: SLACK_CONNECT_STATUS.ERROR,
code: "slack_workspace_conflict",
}).get("slack_code"),
).toBe("slack_workspace_conflict");
});
it("drops a reason or code that is not shaped like a token, on write and on read", () => {
const written = slackConnectQuery({
status: SLACK_CONNECT_STATUS.SLACK_ERROR,
reason: "<script>alert(1)</script>",
code: "A sentence, not a code.",
});
expect(Array.from(written.keys())).toEqual(["slack"]);
// Read-side gate too: the URL is user-editable after the redirect.
const handcrafted = new URLSearchParams(
"slack=slack_error&slack_reason=Not%20a%20token&slack_code=<x>",
);
expect(readSlackConnectOutcome(handcrafted)).toEqual({
status: "slack_error",
reason: null,
code: null,
retryAfterSeconds: null,
});
});
it("rounds a fractional retry up and drops one that is not a positive number", () => {
expect(
slackConnectQuery({
status: SLACK_CONNECT_STATUS.RATE_LIMITED,
retryAfterSeconds: 1.5,
}).get("slack_retry"),
).toBe("2");
for (const retryAfterSeconds of [
0,
-3,
Number.NaN,
Number.POSITIVE_INFINITY,
]) {
expect(
slackConnectQuery({
status: SLACK_CONNECT_STATUS.RATE_LIMITED,
retryAfterSeconds,
}).get("slack_retry"),
).toBeNull();
}
expect(
readSlackConnectOutcome(
new URLSearchParams("slack=rate_limited&slack_retry=soon"),
)?.retryAfterSeconds,
).toBeNull();
});
it("reads the statuses that travel alone, the expired one included", () => {
for (const status of ["expired", "incomplete", "unavailable"]) {
expect(
readSlackConnectOutcome(new URLSearchParams(`slack=${status}`))?.status,
).toBe(status);
}
});
it("reads no outcome from an unknown status or a plain page visit", () => {
expect(
readSlackConnectOutcome(
new URLSearchParams("slack=definitely_not_a_status"),
),
).toBeNull();
expect(readSlackConnectOutcome(new URLSearchParams(""))).toBeNull();
expect(readSlackConnectOutcome(new URLSearchParams("foo=bar"))).toBeNull();
});
});
+120
View File
@@ -0,0 +1,120 @@
/**
* The status-param contract between the OAuth callback Route Handler and the
* notice on the Slack integration page: the handler writes the install's
* outcome into its redirect's query string, the notice reads it back. Tokens
* only, never free text — everything here rides the URL, so both sides gate
* every value on its shape before it travels or renders.
*/
import { SLACK_REASON_TOKEN } from "@/lib/integrations/slack-errors";
export const SLACK_INTEGRATION_PATH = "/integrations/slack";
export const SLACK_CALLBACK_PATH = `${SLACK_INTEGRATION_PATH}/callback`;
export const SLACK_CONNECT_STATUS = {
CONNECTED: "connected",
/** Slack itself refused the install (`?error=` on the callback). */
SLACK_ERROR: "slack_error",
/** The callback carried no usable `code`/`state` pair. */
INCOMPLETE: "incomplete",
/** The state or single-use code had already been consumed or timed out. */
EXPIRED: "expired",
UNAVAILABLE: "unavailable",
RATE_LIMITED: "rate_limited",
/** The API may have connected the workspace; its answer was unreadable. */
UNCONFIRMED: "unconfirmed",
/** The API refused the exchange, optionally naming a reason in `code`. */
ERROR: "error",
} as const;
export const SLACK_EXPIRED_CALLBACK_URL = `${SLACK_INTEGRATION_PATH}?slack=${SLACK_CONNECT_STATUS.EXPIRED}`;
export type SlackConnectStatus =
(typeof SLACK_CONNECT_STATUS)[keyof typeof SLACK_CONNECT_STATUS];
const SLACK_STATUS_PARAM = "slack";
const SLACK_REASON_PARAM = "slack_reason";
const SLACK_CODE_PARAM = "slack_code";
const SLACK_RETRY_PARAM = "slack_retry";
/** Every param the contract owns — what the notice strips from the URL. */
export const SLACK_CONNECT_PARAMS = [
SLACK_STATUS_PARAM,
SLACK_REASON_PARAM,
SLACK_CODE_PARAM,
SLACK_RETRY_PARAM,
] as const;
export interface SlackConnectOutcome {
status: SlackConnectStatus;
/** Slack's `?error` token; only with `slack_error`. */
reason: string | null;
/** The API refusal's `code`; only with `error`. */
code: string | null;
/** Only with `rate_limited`. */
retryAfterSeconds: number | null;
}
export interface SlackConnectOutcomeInput {
status: SlackConnectStatus;
reason?: string | null;
code?: string | null;
retryAfterSeconds?: number | null;
}
const RETRY_SECONDS = /^\d{1,6}$/;
const STATUS_VALUES = new Set<string>(Object.values(SLACK_CONNECT_STATUS));
const isStatus = (value: string | null): value is SlackConnectStatus =>
value !== null && STATUS_VALUES.has(value);
const tokenOrNull = (value: string | null | undefined): string | null =>
value && SLACK_REASON_TOKEN.test(value) ? value : null;
/** A `Retry-After` can be fractional; the wait copy rounds up anyway. */
const retryParamValue = (value: number | null | undefined): string | null => {
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
return null;
}
const text = String(Math.ceil(value));
return RETRY_SECONDS.test(text) ? text : null;
};
export const slackConnectQuery = (
outcome: SlackConnectOutcomeInput,
): URLSearchParams => {
const params = new URLSearchParams();
params.set(SLACK_STATUS_PARAM, outcome.status);
const reason = tokenOrNull(outcome.reason);
if (reason) params.set(SLACK_REASON_PARAM, reason);
const code = tokenOrNull(outcome.code);
if (code) params.set(SLACK_CODE_PARAM, code);
const retry = retryParamValue(outcome.retryAfterSeconds);
if (retry) params.set(SLACK_RETRY_PARAM, retry);
return params;
};
/**
* `null` for a query that carries no (recognisable) outcome, so a page visit
* that did not come through the callback renders no notice.
*/
export const readSlackConnectOutcome = (
params: URLSearchParams,
): SlackConnectOutcome | null => {
const status = params.get(SLACK_STATUS_PARAM);
if (!isStatus(status)) return null;
const retry = params.get(SLACK_RETRY_PARAM);
return {
status,
reason: tokenOrNull(params.get(SLACK_REASON_PARAM)),
code: tokenOrNull(params.get(SLACK_CODE_PARAM)),
retryAfterSeconds:
retry !== null && RETRY_SECONDS.test(retry) ? Number(retry) : null,
};
};
+129
View File
@@ -0,0 +1,129 @@
import type { NextAuthRequest } from "next-auth";
import { describe, expect, it, vi } from "vitest";
vi.mock("next-auth", () => ({
default: vi.fn(() => ({
signIn: vi.fn(),
signOut: vi.fn(),
auth: vi.fn(),
handlers: {},
})),
}));
vi.mock("next-auth/providers/credentials", () => ({
default: vi.fn((config) => config),
}));
vi.mock("@/auth.config", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/auth.config")>();
return {
...actual,
auth: vi.fn(
(handler: (request: NextAuthRequest) => Response | Promise<Response>) =>
async (request: NextAuthRequest) => {
// Match NextAuth's production order: `authorized` can return a
// response before the wrapped proxy handler is invoked.
const authorization = await actual.authConfig.callbacks?.authorized?.(
{
auth: request.auth,
request,
},
);
if (authorization instanceof Response) return authorization;
return handler(request);
},
),
};
});
vi.mock("@/lib/csp", () => ({ getCspHeader: () => "default-src 'self'" }));
vi.mock("@/lib/integrations", () => ({
GATED_INTEGRATIONS: { posthog: "posthog" },
isGatedIntegrationEnabled: () => false,
readGatedEnv: () => undefined,
}));
vi.mock("@/lib/runtime-env", () => ({ readEnv: () => undefined }));
vi.mock("@/lib/shared/env", () => ({ isCloud: () => true }));
import proxy from "./proxy";
const CALLBACK_URL =
"https://cloud.prowler.com/integrations/slack/callback" +
"?code=slack-code-1f4a&state=st-2f1c9d7a";
const invokeProxy = async (
auth: NextAuthRequest["auth"],
href = CALLBACK_URL,
): Promise<Response> => {
const url = new URL(href);
const request = {
auth,
nextUrl: url,
url: url.toString(),
} as NextAuthRequest;
return (proxy as unknown as (request: NextAuthRequest) => Promise<Response>)(
request,
);
};
describe("Slack OAuth callback authentication", () => {
it.each([
{
label: "the session expired",
auth: { error: "RefreshAccessTokenError" } as NextAuthRequest["auth"],
},
{ label: "the session is missing", auth: null },
])(
"strips the OAuth credentials before sign-in when $label",
async ({ auth }) => {
// Given - Slack returned a single-use code to an unauthenticated callback.
// When
const response = await invokeProxy(auth);
// Then - sign-in resumes on a clean integration URL that asks for a new install.
const location = new URL(response.headers.get("location") as string);
expect(location.pathname).toBe("/sign-in");
expect(location.searchParams.get("callbackUrl")).toBe(
"/integrations/slack?slack=expired",
);
expect(location.href).not.toContain("slack-code-1f4a");
expect(location.href).not.toContain("st-2f1c9d7a");
},
);
it("keeps any other page's own query, so sign-in still returns where the user was", async () => {
// Given - an ordinary protected page carrying state worth resuming on.
const findings = "https://cloud.prowler.com/findings?severity=critical";
// When
const response = await invokeProxy(null, findings);
// Then - only the callback's credentials are dropped, nothing else.
const location = new URL(response.headers.get("location") as string);
expect(location.searchParams.get("callbackUrl")).toBe(
"/findings?severity=critical",
);
});
it("is answered by the authorized callback, never by the proxy behind it", async () => {
// Given - the proxy is the only layer that attaches the security headers,
// which makes it observable whether it ran at all.
// When
const turnedAway = await invokeProxy(null);
const allowed = await invokeProxy({
user: { permissions: { manage_integrations: true } },
} as NextAuthRequest["auth"]);
// Then - an unauthenticated request is settled before the proxy is reached,
// so a fix that lands only there would never run in production.
expect(turnedAway.headers.get("content-security-policy")).toBeNull();
expect(allowed.headers.get("content-security-policy")).toBe(
"default-src 'self'",
);
});
});
+22 -4
View File
@@ -8,6 +8,10 @@ import {
isGatedIntegrationEnabled,
readGatedEnv,
} from "@/lib/integrations";
import {
SLACK_CALLBACK_PATH,
SLACK_EXPIRED_CALLBACK_URL,
} from "@/lib/integrations/slack-connect-status";
import { readEnv } from "@/lib/runtime-env";
import { isCloud } from "@/lib/shared/env";
import { copyAttributionParams } from "@/lib/utm";
@@ -63,15 +67,29 @@ export default auth((req: NextAuthRequest) => {
if (sessionError && !isPublicRoute(pathname)) {
const signInUrl = new URL("/sign-in", req.url);
signInUrl.searchParams.set("error", sessionError);
signInUrl.searchParams.set("callbackUrl", pathname + req.nextUrl.search);
copyAttributionParams(req.nextUrl.searchParams, signInUrl.searchParams);
signInUrl.searchParams.set(
"callbackUrl",
pathname === SLACK_CALLBACK_PATH
? SLACK_EXPIRED_CALLBACK_URL
: pathname + req.nextUrl.search,
);
if (pathname !== SLACK_CALLBACK_PATH) {
copyAttributionParams(req.nextUrl.searchParams, signInUrl.searchParams);
}
return redirect(signInUrl);
}
if (!user && !isPublicRoute(pathname)) {
const signInUrl = new URL("/sign-in", req.url);
signInUrl.searchParams.set("callbackUrl", pathname + req.nextUrl.search);
copyAttributionParams(req.nextUrl.searchParams, signInUrl.searchParams);
signInUrl.searchParams.set(
"callbackUrl",
pathname === SLACK_CALLBACK_PATH
? SLACK_EXPIRED_CALLBACK_URL
: pathname + req.nextUrl.search,
);
if (pathname !== SLACK_CALLBACK_PATH) {
copyAttributionParams(req.nextUrl.searchParams, signInUrl.searchParams);
}
return redirect(signInUrl);
}