mirror of
https://github.com/prowler-cloud/prowler.git
synced 2026-10-04 02:04:06 +00:00
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:
co-authored by
alejandrobailo
parent
301edea7ce
commit
a610314eba
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user