Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fix-slack-oauth-redirect.md
Original file line number Diff line number Diff line change
@@ -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.
2 changes: 2 additions & 0 deletions apps/docs/content/docs/guides/slack-nextjs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 5 additions & 1 deletion packages/adapter-slack/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`:
Expand Down
100 changes: 93 additions & 7 deletions packages/adapter-slack/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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<typeof vi.fn> } };
}
).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(
Expand All @@ -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 () => {
Expand Down Expand Up @@ -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
Expand Down
13 changes: 10 additions & 3 deletions packages/adapter-slack/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -588,7 +593,8 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
* 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(
Expand All @@ -606,13 +612,14 @@ export class SlackAdapter implements Adapter<SlackThreadId, unknown> {
);
}

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)) {
Expand Down
Loading