diff --git a/.changeset/fix-slack-oauth-redirect.md b/.changeset/fix-slack-oauth-redirect.md new file mode 100644 index 00000000..237088d8 --- /dev/null +++ b/.changeset/fix-slack-oauth-redirect.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/slack": patch +--- + +Fix Slack OAuth callbacks by allowing `redirectUri` to be passed explicitly during the token exchange while preserving the callback query param as a backward-compatible fallback. diff --git a/apps/docs/content/docs/guides/slack-nextjs.mdx b/apps/docs/content/docs/guides/slack-nextjs.mdx index 42850559..516e4aac 100644 --- a/apps/docs/content/docs/guides/slack-nextjs.mdx +++ b/apps/docs/content/docs/guides/slack-nextjs.mdx @@ -87,6 +87,8 @@ After creating the app: 1. Go to **OAuth & Permissions**, click **Install to Workspace**, and copy the **Bot User OAuth Token** (`xoxb-...`) — you'll need this as `SLACK_BOT_TOKEN` 2. Go to **Basic Information** → **App Credentials** and copy the **Signing Secret** — you'll need this as `SLACK_SIGNING_SECRET` +If you're distributing the app across multiple workspaces via OAuth instead of installing it to one workspace, configure `clientId` and `clientSecret` on the Slack adapter and pass the same redirect URI used during the authorize step into `handleOAuthCallback(request, { redirectUri })` in your callback route. + ## Configure environment variables Create a `.env.local` file in your project root: diff --git a/packages/adapter-slack/README.md b/packages/adapter-slack/README.md index 13971cf9..735fda27 100644 --- a/packages/adapter-slack/README.md +++ b/packages/adapter-slack/README.md @@ -61,11 +61,15 @@ The adapter handles the full Slack OAuth V2 exchange. Point your OAuth redirect import { slackAdapter } from "@/lib/bot"; export async function GET(request: Request) { - const { teamId } = await slackAdapter.handleOAuthCallback(request); + const { teamId } = await slackAdapter.handleOAuthCallback(request, { + redirectUri: process.env.SLACK_REDIRECT_URI, + }); return new Response(`Installed for team ${teamId}!`); } ``` +If your install flow uses a specific redirect URI, pass the same value here that you used during the authorize step. This is especially useful when one app supports multiple redirect URLs. When no option is provided, the adapter still falls back to `redirect_uri` on the callback request URL. + ### Using the adapter outside webhooks During webhook handling, the adapter resolves tokens automatically from `team_id`. Outside that context (e.g. cron jobs or background workers), use `getInstallation` and `withBotToken`: diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 91cd0989..6197b549 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -1492,7 +1492,7 @@ describe("installationKeyPrefix", () => { describe("handleOAuthCallback", () => { const secret = "test-signing-secret"; - it("exchanges code for token and saves installation", async () => { + function createOAuthAdapter() { const state = createMockState(); const adapter = createSlackAdapter({ signingSecret: secret, @@ -1504,21 +1504,27 @@ describe("handleOAuthCallback", () => { // Mock the oauth.v2.access call on the internal client const mockClient = (adapter as unknown as { client: { oauth: unknown } }) .client; + const mockAccess = vi.fn().mockResolvedValue({ + ok: true, + access_token: "xoxb-oauth-bot-token", + bot_user_id: "U_BOT_OAUTH", + team: { id: "T_OAUTH_1", name: "OAuth Team" }, + }); ( mockClient as unknown as { oauth: { v2: { access: ReturnType } }; } ).oauth = { v2: { - access: vi.fn().mockResolvedValue({ - ok: true, - access_token: "xoxb-oauth-bot-token", - bot_user_id: "U_BOT_OAUTH", - team: { id: "T_OAUTH_1", name: "OAuth Team" }, - }), + access: mockAccess, }, }; + return { adapter, state, mockAccess }; + } + + it("exchanges code for token and saves installation", async () => { + const { adapter, state, mockAccess } = createOAuthAdapter(); await adapter.initialize(createMockChatInstance(state)); const request = new Request( @@ -1535,6 +1541,76 @@ describe("handleOAuthCallback", () => { const stored = await adapter.getInstallation("T_OAUTH_1"); expect(stored).not.toBeNull(); expect(stored?.botToken).toBe("xoxb-oauth-bot-token"); + expect(mockAccess).toHaveBeenCalledWith({ + client_id: "client-id", + client_secret: "client-secret", + code: "oauth-code-123", + }); + }); + + it("forwards redirect_uri from callback options", async () => { + const { adapter, state, mockAccess } = createOAuthAdapter(); + await adapter.initialize(createMockChatInstance(state)); + + const request = new Request( + "https://example.com/auth/callback/slack?code=oauth-code-123" + ); + await adapter.handleOAuthCallback(request, { + redirectUri: "https://example.com/install/callback", + }); + + expect(mockAccess).toHaveBeenCalledWith({ + client_id: "client-id", + client_secret: "client-secret", + code: "oauth-code-123", + redirect_uri: "https://example.com/install/callback", + }); + }); + + it("prefers callback options redirect_uri over the query param", async () => { + const { adapter, state, mockAccess } = createOAuthAdapter(); + await adapter.initialize(createMockChatInstance(state)); + + const request = new Request( + "https://example.com/auth/callback/slack?code=oauth-code-123&redirect_uri=https%3A%2F%2Fexample.com%2Fquery-callback" + ); + await adapter.handleOAuthCallback(request, { + redirectUri: "https://example.com/explicit-callback", + }); + + expect(mockAccess).toHaveBeenCalledWith({ + client_id: "client-id", + client_secret: "client-secret", + code: "oauth-code-123", + redirect_uri: "https://example.com/explicit-callback", + }); + }); + + it("falls back to redirect_uri from the callback query param", async () => { + const { adapter, state, mockAccess } = createOAuthAdapter(); + await adapter.initialize(createMockChatInstance(state)); + + const request = new Request( + "https://example.com/auth/callback/slack?code=oauth-code-123&redirect_uri=https%3A%2F%2Fexample.com%2Fquery-callback" + ); + await adapter.handleOAuthCallback(request); + + expect(mockAccess).toHaveBeenCalledWith({ + client_id: "client-id", + client_secret: "client-secret", + code: "oauth-code-123", + redirect_uri: "https://example.com/query-callback", + }); + }); + + it("throws when the callback code is missing", async () => { + const { adapter, state } = createOAuthAdapter(); + await adapter.initialize(createMockChatInstance(state)); + + const request = new Request("https://example.com/auth/callback/slack"); + await expect(adapter.handleOAuthCallback(request)).rejects.toThrow( + "Missing 'code' query parameter in OAuth callback request." + ); }); it("throws without clientId and clientSecret", async () => { @@ -4945,6 +5021,16 @@ describe("reverse user lookup", () => { signingSecret: secret, logger: mockLogger, }); + mockClientMethod( + adapter, + "auth.test", + vi.fn().mockResolvedValue({ + ok: true, + user_id: "U_BOT", + user: "bot", + bot_id: "B_BOT", + }) + ); await adapter.initialize(createMockChatInstance(state)); // Seed user cache diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index b661a348..0ccfe565 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -119,6 +119,11 @@ export interface SlackAdapterConfig { userName?: string; } +export interface SlackOAuthCallbackOptions { + /** Redirect URI to send to Slack during the OAuth code exchange. */ + redirectUri?: string; +} + /** Data stored per Slack workspace installation */ export interface SlackInstallation { botToken: string; @@ -588,7 +593,8 @@ export class SlackAdapter implements Adapter { * exchanges it for tokens, and saves the installation. */ async handleOAuthCallback( - request: Request + request: Request, + options?: SlackOAuthCallbackOptions ): Promise<{ teamId: string; installation: SlackInstallation }> { if (!(this.clientId && this.clientSecret)) { throw new ValidationError( @@ -606,13 +612,14 @@ export class SlackAdapter implements Adapter { ); } - const redirectUri = url.searchParams.get("redirect_uri") ?? undefined; + const redirectUri = + options?.redirectUri ?? url.searchParams.get("redirect_uri") ?? undefined; const result = await this.client.oauth.v2.access({ client_id: this.clientId, client_secret: this.clientSecret, code, - redirect_uri: redirectUri, + ...(redirectUri ? { redirect_uri: redirectUri } : {}), }); if (!(result.ok && result.access_token && result.team?.id)) {