diff --git a/.changeset/slack-dynamic-bot-token-and-webhook-verifier.md b/.changeset/slack-dynamic-bot-token-and-webhook-verifier.md new file mode 100644 index 00000000..3a8977a8 --- /dev/null +++ b/.changeset/slack-dynamic-bot-token-and-webhook-verifier.md @@ -0,0 +1,5 @@ +--- +"@chat-adapter/slack": minor +--- + +Add dynamic `botToken` resolver and custom `webhookVerifier` to Slack adapter config. `botToken` now accepts `string | (() => string | Promise)` so apps can rotate or lazily fetch tokens — the function is invoked per API call. `webhookVerifier: (request: Request) => string | Promise` is used in place of `signingSecret` when set (and `signingSecret` is not provided), letting hosts verify incoming requests with their own logic and return the verified body text; the adapter responds 401 if the verifier throws. diff --git a/packages/adapter-slack/README.md b/packages/adapter-slack/README.md index 455dfe2b..a433b26a 100644 --- a/packages/adapter-slack/README.md +++ b/packages/adapter-slack/README.md @@ -31,6 +31,35 @@ bot.onNewMention(async (thread, message) => { }); ``` +### Token rotation + +`botToken` accepts a function returning a string or `Promise` — the resolver is invoked per API call, so it composes with [Slack token rotation](https://docs.slack.dev/authentication/using-token-rotation/) (12-hour TTL) or lazy fetch from a secret manager: + +```typescript +createSlackAdapter({ + botToken: async () => await secrets.get("slack-bot-token"), +}); +``` + +If the resolver is expensive (e.g. a vault round-trip), implement caching inside the resolver itself. + +### Custom webhook verification + +Pass `webhookVerifier` to replace the built-in HMAC check — useful when verification runs in a proxy or signing layer ahead of your handler: + +```typescript +createSlackAdapter({ + webhookVerifier: async (request, body) => { + if (!(await myProxy.verify(request))) { + throw new Error("invalid"); + } + return true; // or return a string to substitute the verified body + }, +}); +``` + +If both `signingSecret` and `webhookVerifier` are set, `signingSecret` wins. When using `webhookVerifier`, you are responsible for replay/timestamp protection — the built-in 5-minute timestamp tolerance only applies to the `signingSecret` path. + ## Multi-workspace mode For apps installed across multiple Slack workspaces via OAuth, omit `botToken` and provide OAuth credentials instead. The adapter resolves tokens dynamically from your state adapter using the `team_id` from incoming webhooks. @@ -266,8 +295,9 @@ All options are auto-detected from environment variables when not provided. You | Option | Required | Description | |--------|----------|-------------| -| `botToken` | No | Bot token (`xoxb-...`). Auto-detected from `SLACK_BOT_TOKEN` | +| `botToken` | No | Bot token (`xoxb-...`) or a function returning one (sync or async) for rotation/lazy fetch. Auto-detected from `SLACK_BOT_TOKEN` | | `signingSecret` | No* | Signing secret for webhook verification. Auto-detected from `SLACK_SIGNING_SECRET` | +| `webhookVerifier` | No* | Custom verifier `(request, body) => unknown \| Promise` used in place of `signingSecret`. Returning a string substitutes the verified body for downstream parsing | | `mode` | No | Connection mode: `"webhook"` (default) or `"socket"` | | `appToken` | No** | App-level token (`xapp-...`) for socket mode. Auto-detected from `SLACK_APP_TOKEN` | | `socketForwardingSecret` | No | Shared secret for authenticating forwarded socket events. Auto-detected from `SLACK_SOCKET_FORWARDING_SECRET`, falls back to `appToken` | @@ -277,7 +307,7 @@ All options are auto-detected from environment variables when not provided. You | `installationKeyPrefix` | No | Prefix for the state key used to store workspace installations. Defaults to `slack:installation`. The full key is `{prefix}:{teamId}` | | `logger` | No | Logger instance (defaults to `ConsoleLogger("info")`) | -*`signingSecret` is required for webhook mode — either via config or `SLACK_SIGNING_SECRET` env var. +*`signingSecret` is required for webhook mode — either via config, `SLACK_SIGNING_SECRET` env var, or a `webhookVerifier`. **`appToken` is required for socket mode — either via config or `SLACK_APP_TOKEN` env var. ## Environment variables @@ -437,6 +467,7 @@ await slackAdapter.handleOAuthCallback(request); - Verify `SLACK_SIGNING_SECRET` is correct - Check that the request timestamp is within 5 minutes (clock sync issue) +- If using a custom `webhookVerifier`, the error also surfaces when the verifier throws or returns a falsy value ### Bot not responding to messages diff --git a/packages/adapter-slack/src/index.test.ts b/packages/adapter-slack/src/index.test.ts index 98ea4d77..738cb8d7 100644 --- a/packages/adapter-slack/src/index.test.ts +++ b/packages/adapter-slack/src/index.test.ts @@ -141,7 +141,16 @@ describe("constructor env var resolution", () => { }); it("should throw when signingSecret is missing and env var not set", () => { - expect(() => new SlackAdapter({})).toThrow("signingSecret is required"); + expect(() => new SlackAdapter({})).toThrow( + "signingSecret or webhookVerifier is required" + ); + }); + + it("should not throw when webhookVerifier is provided without signingSecret", () => { + const adapter = new SlackAdapter({ + webhookVerifier: () => true, + }); + expect(adapter).toBeInstanceOf(SlackAdapter); }); it("should resolve signingSecret from SLACK_SIGNING_SECRET env var", () => { @@ -357,6 +366,140 @@ describe("handleWebhook - signature verification", () => { }); }); +describe("handleWebhook - webhookVerifier", () => { + it("uses webhookVerifier in place of signingSecret", async () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + webhookVerifier: () => true, + logger: mockLogger, + }); + + const body = JSON.stringify({ + type: "url_verification", + challenge: "verifier-challenge", + }); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + const json = (await response.json()) as { challenge: string }; + expect(json.challenge).toBe("verifier-challenge"); + }); + + it("returns 401 when verifier throws", async () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + webhookVerifier: () => { + throw new Error("bad signature"); + }, + logger: mockLogger, + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "url_verification" }), + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(401); + }); + + it("returns 401 when verifier returns a falsy value", async () => { + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + webhookVerifier: () => false, + logger: mockLogger, + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ type: "url_verification" }), + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(401); + }); + + it("passes the body string to the verifier", async () => { + const body = JSON.stringify({ + type: "url_verification", + challenge: "verifier-challenge", + }); + const verifier = vi.fn((_req: Request, _body: string) => true); + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + webhookVerifier: verifier, + logger: mockLogger, + }); + + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(verifier).toHaveBeenCalledTimes(1); + expect(verifier.mock.calls[0]?.[1]).toBe(body); + }); + + it("prefers signingSecret over webhookVerifier when both are set", async () => { + const secret = "test-signing-secret"; + const verifier = vi.fn(() => true); + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + signingSecret: secret, + webhookVerifier: verifier, + logger: mockLogger, + }); + + const body = JSON.stringify({ + type: "url_verification", + challenge: "test-challenge", + }); + const request = createWebhookRequest(body, secret); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(verifier).not.toHaveBeenCalled(); + }); + + it("ignores SLACK_SIGNING_SECRET env var when webhookVerifier is configured", async () => { + vi.stubEnv("SLACK_SIGNING_SECRET", "env-signing-secret"); + try { + const verifier = vi.fn(() => true); + const adapter = createSlackAdapter({ + botToken: "xoxb-test-token", + webhookVerifier: verifier, + logger: mockLogger, + }); + + const body = JSON.stringify({ + type: "url_verification", + challenge: "verifier-challenge", + }); + const request = new Request("https://example.com/webhook", { + method: "POST", + headers: { "content-type": "application/json" }, + body, + }); + + const response = await adapter.handleWebhook(request); + expect(response.status).toBe(200); + expect(verifier).toHaveBeenCalledTimes(1); + } finally { + vi.unstubAllEnvs(); + } + }); +}); + // ============================================================================ // URL Verification Challenge Tests // ============================================================================ @@ -2486,6 +2629,257 @@ function mockClientMethod( obj[parts.at(-1) as string] = mockFn; } +// ============================================================================ +// botToken as function (resolver) Tests +// ============================================================================ + +describe("botToken as function", () => { + const secret = "test-signing-secret"; + + it("accepts a sync resolver and uses its return value on API calls", async () => { + const resolver = vi.fn(() => "xoxb-sync-token"); + const adapter = createSlackAdapter({ + botToken: resolver, + signingSecret: secret, + logger: mockLogger, + }); + + mockClientMethod( + adapter, + "chat.postMessage", + vi.fn().mockResolvedValue({ ok: true, ts: "1234567890.999999" }) + ); + + await adapter.postMessage("slack:C123:1234567890.000000", "hi"); + + const client = getClient(adapter); + expect(client.chat.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ token: "xoxb-sync-token" }) + ); + expect(resolver).toHaveBeenCalled(); + }); + + it("accepts an async resolver and awaits the returned promise", async () => { + const resolver = vi.fn(async () => { + await Promise.resolve(); + return "xoxb-async-token"; + }); + const adapter = createSlackAdapter({ + botToken: resolver, + signingSecret: secret, + logger: mockLogger, + }); + + mockClientMethod( + adapter, + "chat.postMessage", + vi.fn().mockResolvedValue({ ok: true, ts: "1234567890.999999" }) + ); + + await adapter.postMessage("slack:C123:1234567890.000000", "hi"); + + const client = getClient(adapter); + expect(client.chat.postMessage).toHaveBeenCalledWith( + expect.objectContaining({ token: "xoxb-async-token" }) + ); + expect(resolver).toHaveBeenCalled(); + }); + + it("invokes the resolver per API call (supports rotation)", async () => { + const tokens = ["xoxb-token-1", "xoxb-token-2", "xoxb-token-3"]; + let i = 0; + const resolver = vi.fn(() => tokens[i++]); + const adapter = createSlackAdapter({ + botToken: resolver, + signingSecret: secret, + logger: mockLogger, + }); + + const postMessage = vi + .fn() + .mockResolvedValue({ ok: true, ts: "1234567890.999999" }); + mockClientMethod(adapter, "chat.postMessage", postMessage); + + await adapter.postMessage("slack:C123:1234567890.000000", "first"); + await adapter.postMessage("slack:C123:1234567890.000000", "second"); + await adapter.postMessage("slack:C123:1234567890.000000", "third"); + + expect(resolver).toHaveBeenCalledTimes(3); + expect(postMessage).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ token: "xoxb-token-1" }) + ); + expect(postMessage).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ token: "xoxb-token-2" }) + ); + expect(postMessage).toHaveBeenNthCalledWith( + 3, + expect.objectContaining({ token: "xoxb-token-3" }) + ); + }); + + it("treats a function botToken as single-workspace mode", async () => { + const adapter = createSlackAdapter({ + botToken: () => "xoxb-fn-token", + signingSecret: secret, + logger: mockLogger, + }); + + mockClientMethod( + adapter, + "auth.test", + vi.fn().mockResolvedValue({ + ok: true, + user_id: "U_BOT", + bot_id: "B_BOT", + user: "fnbot", + }) + ); + + // initialize() in single-workspace mode calls auth.test with the resolved token + await adapter.initialize({ + getState: () => ({}) as unknown as StateAdapter, + } as unknown as ChatInstance); + + const client = getClient(adapter); + expect(client.auth.test).toHaveBeenCalledWith( + expect.objectContaining({ token: "xoxb-fn-token" }) + ); + expect(adapter.botUserId).toBe("U_BOT"); + }); + + it("propagates errors thrown by the resolver", async () => { + const adapter = createSlackAdapter({ + botToken: () => { + throw new Error("token fetch failed"); + }, + signingSecret: secret, + logger: mockLogger, + }); + + mockClientMethod(adapter, "chat.postMessage", vi.fn()); + + await expect( + adapter.postMessage("slack:C123:1234567890.000000", "hi") + ).rejects.toThrow("token fetch failed"); + }); +}); + +// ============================================================================ +// Attachment fetchData token snapshot Tests +// ============================================================================ + +describe("Attachment.fetchData token resolution", () => { + const fileEvent = { + type: "message", + user: "U123", + channel: "C456", + text: "with file", + ts: "1234567890.123456", + files: [ + { + id: "F123", + mimetype: "application/pdf", + url_private: "https://files.slack.com/file.pdf", + name: "doc.pdf", + size: 100, + }, + ], + }; + + function createMockFetchResponse(): Response { + return new Response(new ArrayBuffer(8), { + status: 200, + headers: { "content-type": "application/pdf" }, + }); + } + + it("snapshots ctx token at attachment creation in multi-workspace mode", async () => { + const adapter = createSlackAdapter({ + signingSecret: "test-signing-secret", + logger: mockLogger, + }); + interface AdapterInternals { + requestContext: { + run: (ctx: { token: string }, fn: () => T) => T; + }; + } + const internals = adapter as unknown as AdapterInternals; + + const attachment = internals.requestContext.run( + { token: "xoxb-team-snapshot" }, + () => adapter.parseMessage(fileEvent).attachments?.[0] + ); + expect(attachment).toBeDefined(); + + const fetchMock = vi + .fn() + .mockImplementation(() => Promise.resolve(createMockFetchResponse())); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + try { + // Call fetchData OUTSIDE the requestContext frame to confirm the + // captured ctxToken is used (we are no longer inside AsyncLocalStorage). + await attachment?.fetchData?.(); + } finally { + globalThis.fetch = originalFetch; + } + + expect(fetchMock).toHaveBeenCalledWith( + "https://files.slack.com/file.pdf", + expect.objectContaining({ + headers: { Authorization: "Bearer xoxb-team-snapshot" }, + }) + ); + }); + + it("re-resolves the default provider at fetch time in single-workspace mode", async () => { + const tokens = ["xoxb-stale", "xoxb-fresh"]; + let i = 0; + const resolver = vi.fn(() => tokens[i++]); + const adapter = createSlackAdapter({ + botToken: resolver, + signingSecret: "test-signing-secret", + logger: mockLogger, + }); + + // Attachment created outside any requestContext frame; resolver is NOT + // invoked at creation time because resolution is deferred to fetch. + const attachment = adapter.parseMessage(fileEvent).attachments?.[0]; + expect(attachment).toBeDefined(); + expect(resolver).not.toHaveBeenCalled(); + + const fetchMock = vi + .fn() + .mockImplementation(() => Promise.resolve(createMockFetchResponse())); + const originalFetch = globalThis.fetch; + globalThis.fetch = fetchMock as unknown as typeof globalThis.fetch; + try { + // First fetch picks up the first resolver value. + await attachment?.fetchData?.(); + expect(fetchMock).toHaveBeenLastCalledWith( + "https://files.slack.com/file.pdf", + expect.objectContaining({ + headers: { Authorization: "Bearer xoxb-stale" }, + }) + ); + // A subsequent fetchData() re-invokes the resolver and picks up rotation. + await attachment?.fetchData?.(); + expect(fetchMock).toHaveBeenLastCalledWith( + "https://files.slack.com/file.pdf", + expect.objectContaining({ + headers: { Authorization: "Bearer xoxb-fresh" }, + }) + ); + } finally { + globalThis.fetch = originalFetch; + } + + expect(resolver).toHaveBeenCalledTimes(2); + }); +}); + // ============================================================================ // postMessage Tests // ============================================================================ diff --git a/packages/adapter-slack/src/index.ts b/packages/adapter-slack/src/index.ts index 10f6b596..b50b2ac3 100644 --- a/packages/adapter-slack/src/index.ts +++ b/packages/adapter-slack/src/index.ts @@ -104,6 +104,29 @@ const SLACK_MESSAGE_URL_PATTERN = export type SlackAdapterMode = "webhook" | "socket"; +/** + * Bot token configuration. Can be a static string, or a function that returns + * a token (optionally asynchronously). The function is invoked each time a + * token is needed, enabling rotation or lazy retrieval from a secret manager. + */ +export type SlackBotToken = string | (() => string | Promise); + +/** + * Normalize a SlackBotToken config value to a resolver function, or undefined + * if no token is configured. + */ +function normalizeBotTokenProvider( + value: SlackBotToken | undefined +): (() => string | Promise) | undefined { + if (value === undefined) { + return undefined; + } + if (typeof value === "function") { + return value; + } + return () => value; +} + /** Envelope for events forwarded from a socket mode listener via HTTP POST */ interface SlackForwardedSocketEvent { body: Record; @@ -117,8 +140,12 @@ export interface SlackAdapterConfig { apiUrl?: string; /** App-level token (xapp-...). Required for socket mode. */ appToken?: string; - /** Bot token (xoxb-...). Required for single-workspace mode. Omit for multi-workspace. */ - botToken?: string; + /** + * Bot token (xoxb-...). Required for single-workspace mode. Omit for multi-workspace. + * May be a string, or a function returning a string or Promise (called + * on each use to support rotation or deferred resolution). + */ + botToken?: SlackBotToken; /** Bot user ID (will be fetched if not provided) */ botUserId?: string; /** Slack app client ID (required for OAuth / multi-workspace) */ @@ -145,6 +172,29 @@ export interface SlackAdapterConfig { socketForwardingSecret?: string; /** Override bot username (optional) */ userName?: string; + /** + * Custom webhook verifier. Used in place of `signingSecret`. + * Receives the incoming `Request` and the raw body text already + * read by the adapter. To reject the request, either + * return a falsy value (sync or async) or throw/reject; the adapter will + * respond with `401 Invalid signature`. Any truthy return value is treated + * as a successful verification. If a string is returned, it replaces the + * raw body for downstream parsing — useful when the verifier needs to + * canonicalize or substitute the verified payload. + * + * When both `signingSecret` and `webhookVerifier` are specified, + * `signingSecret` takes precedence. + * + * SECURITY: When this is used in place of `signingSecret`, the built-in + * Slack timestamp tolerance check is NOT performed. Implementations are + * responsible for verifying the `x-slack-request-timestamp` header (or an + * equivalent freshness signal) to prevent replay of captured signed + * requests. + */ + webhookVerifier?: ( + request: Request, + body: string + ) => unknown | Promise; } export interface SlackOAuthCallbackOptions { @@ -417,7 +467,12 @@ export class SlackAdapter implements Adapter { private readonly client: WebClient; private readonly signingSecret: string | undefined; - private readonly defaultBotToken: string | undefined; + private readonly webhookVerifier: + | ((request: Request, body: string) => unknown | Promise) + | undefined; + private readonly defaultBotTokenProvider: + | (() => string | Promise) + | undefined; private chat: ChatInstance | null = null; private readonly logger: Logger; private _botUserId: string | null = null; @@ -464,12 +519,20 @@ export class SlackAdapter implements Adapter { } constructor(config: SlackAdapterConfig = {}) { + const webhookVerifier = config.webhookVerifier; + // An explicit webhookVerifier in config opts out of the SLACK_SIGNING_SECRET + // env fallback — otherwise an env-configured deployment would silently + // shadow the verifier the caller intended to use. const signingSecret = - config.signingSecret ?? process.env.SLACK_SIGNING_SECRET; - if (!signingSecret && (config.mode ?? "webhook") === "webhook") { + config.signingSecret ?? + (webhookVerifier ? undefined : process.env.SLACK_SIGNING_SECRET); + if ( + !(signingSecret || webhookVerifier) && + (config.mode ?? "webhook") === "webhook" + ) { throw new ValidationError( "slack", - "signingSecret is required for webhook mode. Set SLACK_SIGNING_SECRET or provide it in config." + "signingSecret or webhookVerifier is required for webhook mode. Set SLACK_SIGNING_SECRET, provide signingSecret in config, or provide a webhookVerifier." ); } @@ -484,15 +547,21 @@ export class SlackAdapter implements Adapter { config.clientSecret ); - const botToken = + const botTokenConfig = config.botToken ?? (zeroConfig ? process.env.SLACK_BOT_TOKEN : undefined); + const botTokenProvider = normalizeBotTokenProvider(botTokenConfig); const slackApiUrl = config.apiUrl ?? process.env.SLACK_API_URL; - this.client = new WebClient(botToken, { + // WebClient token argument is only a fallback; every API call below routes + // through withToken() which resolves the current provider per-call. + this.client = new WebClient(undefined, { ...(slackApiUrl ? { slackApiUrl } : {}), }); + // webhookVerifier takes precedence when signingSecret is not configured; + // if both are provided, signingSecret wins. this.signingSecret = signingSecret; - this.defaultBotToken = botToken; + this.webhookVerifier = signingSecret ? undefined : webhookVerifier; + this.defaultBotTokenProvider = botTokenProvider; this.logger = config.logger ?? new ConsoleLogger("info").child("slack"); this.userName = config.userName || "bot"; this._botUserId = config.botUserId || null; @@ -519,15 +588,16 @@ export class SlackAdapter implements Adapter { /** * Get the current bot token for API calls. - * Checks request context (multi-workspace) → default token (single-workspace) → throws. + * Checks request context (multi-workspace) → default token provider + * (single-workspace) → throws. */ - private getToken(): string { + private async getToken(): Promise { const ctx = this.requestContext.getStore(); if (ctx?.token) { return ctx.token; } - if (this.defaultBotToken) { - return this.defaultBotToken; + if (this.defaultBotTokenProvider) { + return await this.defaultBotTokenProvider(); } throw new AuthenticationError( "slack", @@ -539,20 +609,22 @@ export class SlackAdapter implements Adapter { * Add the current token to API call options. * Workaround for Slack WebClient types not including `token` in per-method args. */ - // biome-ignore lint/suspicious/noExplicitAny: Slack types don't include token in method args - private withToken>( - options: T - ): T & { token: string } { - return { ...options, token: this.getToken() }; + private async withToken< + // biome-ignore lint/suspicious/noExplicitAny: Slack types don't include token in method args + T extends Record, + >(options: T): Promise { + return { ...options, token: await this.getToken() }; } async initialize(chat: ChatInstance): Promise { this.chat = chat; // Only fetch bot user ID in single-workspace mode (when default token is available) - if (this.defaultBotToken && !this._botUserId) { + if (this.defaultBotTokenProvider && !this._botUserId) { try { - const authResult = await this.client.auth.test(this.withToken({})); + const authResult = await this.client.auth.test( + await this.withToken({}) + ); this._botUserId = authResult.user_id as string; this._botId = (authResult.bot_id as string) || null; if (authResult.user) { @@ -567,7 +639,7 @@ export class SlackAdapter implements Adapter { } } - if (!this.defaultBotToken) { + if (!this.defaultBotTokenProvider) { this.logger.info("Slack adapter initialized in multi-workspace mode"); } @@ -796,7 +868,7 @@ export class SlackAdapter implements Adapter { try { const result = await this.client.users.info( - this.withToken({ user: userId }) + await this.withToken({ user: userId }) ); const user = result.user as { name?: string; @@ -866,7 +938,7 @@ export class SlackAdapter implements Adapter { try { const result = await this.client.conversations.info( - this.withToken({ channel: channelId }) + await this.withToken({ channel: channelId }) ); const name = (result.channel as { name?: string } | undefined)?.name || channelId; @@ -928,15 +1000,33 @@ export class SlackAdapter implements Adapter { }); } - const body = await request.text(); + let body = await request.text(); this.logger.debug("Slack webhook raw body", { body }); - // Verify request signature - const timestamp = request.headers.get("x-slack-request-timestamp"); - const signature = request.headers.get("x-slack-signature"); - - if (!this.verifySignature(body, timestamp, signature)) { - return new Response("Invalid signature", { status: 401 }); + // Verify request using dynamic verifier or signature. + if (this.webhookVerifier) { + try { + const verified = await this.webhookVerifier(request, body); + if (!verified) { + this.logger.warn("Webhook verifier rejected request"); + return new Response("Invalid signature", { status: 401 }); + } + // If the verifier returns a string, use it as the verified body for + // downstream parsing. Other truthy values (boolean, object) are + // treated as a pure verification signal. + if (typeof verified === "string") { + body = verified; + } + } catch (error) { + this.logger.warn("Webhook verifier rejected request", { error }); + return new Response("Invalid signature", { status: 401 }); + } + } else { + const timestamp = request.headers.get("x-slack-request-timestamp"); + const signature = request.headers.get("x-slack-signature"); + if (!this.verifySignature(body, timestamp, signature)) { + return new Response("Invalid signature", { status: 401 }); + } } // Check if this is a form-urlencoded payload @@ -945,7 +1035,7 @@ export class SlackAdapter implements Adapter { const params = new URLSearchParams(body); if (params.has("command") && !params.has("payload")) { const teamId = params.get("team_id"); - if (!this.defaultBotToken && teamId) { + if (!this.defaultBotTokenProvider && teamId) { const ctx = await this.resolveTokenForTeam(teamId); if (ctx) { return this.requestContext.run(ctx, () => @@ -957,7 +1047,7 @@ export class SlackAdapter implements Adapter { return this.handleSlashCommand(params, options); } // In multi-workspace mode, resolve token before processing - if (!this.defaultBotToken) { + if (!this.defaultBotTokenProvider) { const teamId = this.extractTeamIdFromInteractive(body); if (teamId) { const ctx = await this.resolveTokenForTeam(teamId); @@ -986,7 +1076,7 @@ export class SlackAdapter implements Adapter { } // In multi-workspace mode, resolve token from team_id before processing events - if (!this.defaultBotToken && payload.type === "event_callback") { + if (!this.defaultBotTokenProvider && payload.type === "event_callback") { const teamId = payload.team_id; if (teamId) { const ctx = await this.resolveTokenForTeam(teamId); @@ -1037,7 +1127,14 @@ export class SlackAdapter implements Adapter { event.type === "reaction_added" || event.type === "reaction_removed" ) { - this.handleReactionEvent(event as SlackReactionEvent, options); + // Outer prep work (conversations.replies, users.info) runs before + // processReaction registers its own waitUntil task — register the + // outer promise too so callers can await full completion. + const task = this.handleReactionEvent( + event as SlackReactionEvent, + options + ); + options?.waitUntil?.(task); } else if (event.type === "assistant_thread_started") { this.handleAssistantThreadStarted( event as SlackAssistantThreadStartedEvent, @@ -1905,7 +2002,7 @@ export class SlackAdapter implements Adapter { let parentTs = event.item.ts; try { const result = await this.client.conversations.replies( - this.withToken({ + await this.withToken({ channel: event.item.channel, ts: event.item.ts, limit: 1, @@ -2140,7 +2237,7 @@ export class SlackAdapter implements Adapter { ): Promise { await this.client.views.publish( // biome-ignore lint/suspicious/noExplicitAny: view blocks are consumer-defined - this.withToken({ user_id: userId, view }) as any + (await this.withToken({ user_id: userId, view })) as any ); } @@ -2155,7 +2252,7 @@ export class SlackAdapter implements Adapter { title?: string ): Promise { await this.client.assistant.threads.setSuggestedPrompts( - this.withToken({ + await this.withToken({ channel_id: channelId, thread_ts: threadTs, prompts, @@ -2175,7 +2272,7 @@ export class SlackAdapter implements Adapter { loadingMessages?: string[] ): Promise { await this.client.assistant.threads.setStatus( - this.withToken({ + await this.withToken({ channel_id: channelId, thread_ts: threadTs, status, @@ -2194,7 +2291,7 @@ export class SlackAdapter implements Adapter { title: string ): Promise { await this.client.assistant.threads.setTitle( - this.withToken({ + await this.withToken({ channel_id: channelId, thread_ts: threadTs, title, @@ -2365,7 +2462,7 @@ export class SlackAdapter implements Adapter { url, fetchMessage: async () => { const result = await this.client.conversations.history( - this.withToken({ + await this.withToken({ channel, latest: ts, inclusive: true, @@ -2475,8 +2572,10 @@ export class SlackAdapter implements Adapter { teamId?: string ): Attachment { const url = file.url_private; - // Capture token at attachment creation time (during webhook processing context) - const botToken = this.getToken(); + // Capture per-request token from the active webhook context so fetchData + // can run later without being inside the AsyncLocalStorage context. + // For single-workspace, the default provider is resolved at fetch time. + const ctxToken = this.requestContext.getStore()?.token; // Determine type based on mimetype let type: Attachment["type"] = "file"; @@ -2505,7 +2604,10 @@ export class SlackAdapter implements Adapter { width: file.original_w, height: file.original_h, fetchMetadata: Object.keys(fetchMeta).length > 0 ? fetchMeta : undefined, - fetchData: url ? () => this.fetchSlackFile(url, botToken) : undefined, + fetchData: url + ? async () => + this.fetchSlackFile(url, ctxToken ?? (await this.getToken())) + : undefined, }; } @@ -2552,7 +2654,7 @@ export class SlackAdapter implements Adapter { } token = installation.botToken; } else { - token = this.getToken(); + token = await this.getToken(); } return this.fetchSlackFile(url, token); }, @@ -2774,7 +2876,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.postMessage( - this.withToken({ + await this.withToken({ channel, thread_ts: threadTs, text: fallbackText, // Fallback for notifications @@ -2806,7 +2908,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.postMessage( - this.withToken({ + await this.withToken({ channel, thread_ts: threadTs, text: tableResult.text, @@ -2841,7 +2943,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.postMessage( - this.withToken({ + await this.withToken({ channel, thread_ts: threadTs, text, @@ -2891,7 +2993,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.postEphemeral( - this.withToken({ + await this.withToken({ channel, thread_ts: threadTs, user: userId, @@ -2924,7 +3026,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.postEphemeral( - this.withToken({ + await this.withToken({ channel, thread_ts: threadTs, user: userId, @@ -2960,7 +3062,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.postEphemeral( - this.withToken({ + await this.withToken({ channel, thread_ts: threadTs, user: userId, @@ -3007,8 +3109,15 @@ export class SlackAdapter implements Adapter { ); } - // Capture token now so cancel() works outside request context - const token = this.getToken(); + // For multi-workspace mode, snapshot the per-team token from the active + // request context — cancel() may run outside the AsyncLocalStorage frame. + // For single-workspace mode, defer resolution so cancel() picks up the + // current token (supporting rotation). + const ctxToken = this.requestContext.getStore()?.token; + const resolveCancelToken = ctxToken + ? () => Promise.resolve(ctxToken) + : () => this.getToken(); + const token = ctxToken ?? (await this.getToken()); try { const card = extractCard(message); @@ -3045,7 +3154,7 @@ export class SlackAdapter implements Adapter { raw: result, async cancel() { await adapter.client.chat.deleteScheduledMessage({ - token, + token: await resolveCancelToken(), channel, scheduled_message_id: scheduledMessageId, }); @@ -3086,7 +3195,7 @@ export class SlackAdapter implements Adapter { raw: result, async cancel() { await adapter.client.chat.deleteScheduledMessage({ - token, + token: await resolveCancelToken(), channel, scheduled_message_id: scheduledMessageId, }); @@ -3115,7 +3224,7 @@ export class SlackAdapter implements Adapter { try { const result = await this.client.views.open( - this.withToken({ + await this.withToken({ trigger_id: triggerId, view, }) @@ -3145,7 +3254,7 @@ export class SlackAdapter implements Adapter { try { const result = await this.client.views.update( - this.withToken({ + await this.withToken({ view_id: viewId, view, }) @@ -3204,7 +3313,7 @@ export class SlackAdapter implements Adapter { if (threadTs) { uploadArgs.thread_ts = threadTs; } - uploadArgs.token = this.getToken(); + uploadArgs.token = await this.getToken(); const result = (await this.client.files.uploadV2(uploadArgs)) as { ok: boolean; files?: Array<{ files?: Array<{ id?: string }> }>; @@ -3264,7 +3373,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.update( - this.withToken({ + await this.withToken({ channel, ts: messageId, text: fallbackText, @@ -3294,7 +3403,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.update( - this.withToken({ + await this.withToken({ channel, ts: messageId, text: tableResult.text, @@ -3327,7 +3436,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.chat.update( - this.withToken({ + await this.withToken({ channel, ts: messageId, text, @@ -3375,7 +3484,7 @@ export class SlackAdapter implements Adapter { blockCount: blocks.length, }); const result = await this.client.chat.postMessage( - this.withToken({ + await this.withToken({ channel, thread_ts: threadTs, text, @@ -3414,7 +3523,7 @@ export class SlackAdapter implements Adapter { blockCount: blocks.length, }); const result = await this.client.chat.update( - this.withToken({ + await this.withToken({ channel, ts: messageId, text, @@ -3531,7 +3640,7 @@ export class SlackAdapter implements Adapter { this.logger.debug("Slack API: chat.delete", { channel, messageId }); await this.client.chat.delete( - this.withToken({ + await this.withToken({ channel, ts: messageId, }) @@ -3561,7 +3670,7 @@ export class SlackAdapter implements Adapter { }); await this.client.reactions.add( - this.withToken({ + await this.withToken({ channel, timestamp: messageId, name, @@ -3592,7 +3701,7 @@ export class SlackAdapter implements Adapter { }); await this.client.reactions.remove( - this.withToken({ + await this.withToken({ channel, timestamp: messageId, name, @@ -3630,7 +3739,7 @@ export class SlackAdapter implements Adapter { }); try { await this.client.assistant.threads.setStatus( - this.withToken({ + await this.withToken({ channel_id: channel, thread_ts: threadTs, status: status ?? "Typing...", @@ -3682,7 +3791,7 @@ export class SlackAdapter implements Adapter { } this.logger.debug("Slack: starting stream", { channel, threadTs }); - const token = this.getToken(); + const token = await this.getToken(); const streamer = this.client.chatStream({ channel, thread_ts: threadTs, @@ -3812,7 +3921,7 @@ export class SlackAdapter implements Adapter { this.logger.debug("Slack API: conversations.open", { userId }); const result = await this.client.conversations.open( - this.withToken({ users: userId }) + await this.withToken({ users: userId }) ); if (!result.channel?.id) { @@ -3892,7 +4001,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.conversations.replies( - this.withToken({ + await this.withToken({ channel, ts: threadTs, limit, @@ -3955,7 +4064,7 @@ export class SlackAdapter implements Adapter { const fetchLimit = Math.min(1000, Math.max(limit * 2, 200)); const result = await this.client.conversations.replies( - this.withToken({ + await this.withToken({ channel, ts: threadTs, limit: fetchLimit, @@ -4006,7 +4115,7 @@ export class SlackAdapter implements Adapter { this.logger.debug("Slack API: conversations.info", { channel }); const result = await this.client.conversations.info( - this.withToken({ channel }) + await this.withToken({ channel }) ); const channelInfo = result.channel as | { @@ -4062,7 +4171,7 @@ export class SlackAdapter implements Adapter { try { const result = await this.client.conversations.replies( - this.withToken({ + await this.withToken({ channel, ts: threadTs, oldest: messageId, @@ -4254,7 +4363,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.conversations.history( - this.withToken({ + await this.withToken({ channel, limit, oldest: cursor, @@ -4301,7 +4410,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.conversations.history( - this.withToken({ + await this.withToken({ channel, limit, latest: cursor, @@ -4364,7 +4473,7 @@ export class SlackAdapter implements Adapter { }); const result = await this.client.conversations.history( - this.withToken({ + await this.withToken({ channel, limit: Math.min(limit * 3, 200), // Fetch extra since not all have threads cursor: options.cursor, @@ -4429,7 +4538,7 @@ export class SlackAdapter implements Adapter { this.logger.debug("Slack API: conversations.info (channel)", { channel }); const result = await this.client.conversations.info( - this.withToken({ channel }) + await this.withToken({ channel }) ); const info = result.channel as { @@ -4703,12 +4812,17 @@ export function createSlackAdapter( } } + const webhookVerifier = config?.webhookVerifier; + // An explicit webhookVerifier in config opts out of the SLACK_SIGNING_SECRET + // env fallback — otherwise an env-configured deployment would silently + // shadow the verifier the caller intended to use. const signingSecret = - config?.signingSecret ?? process.env.SLACK_SIGNING_SECRET; - if (mode === "webhook" && !signingSecret) { + config?.signingSecret ?? + (webhookVerifier ? undefined : process.env.SLACK_SIGNING_SECRET); + if (mode === "webhook" && !(signingSecret || webhookVerifier)) { throw new ValidationError( "slack", - "signingSecret is required. Set SLACK_SIGNING_SECRET or provide it in config." + "signingSecret or webhookVerifier is required. Set SLACK_SIGNING_SECRET, provide signingSecret in config, or provide a webhookVerifier." ); } @@ -4739,6 +4853,7 @@ export function createSlackAdapter( process.env.SLACK_SOCKET_FORWARDING_SECRET, userName: config?.userName, botUserId: config?.botUserId, + webhookVerifier, }; return new SlackAdapter(resolved); } diff --git a/packages/integration-tests/src/test-scenarios.ts b/packages/integration-tests/src/test-scenarios.ts index 72e19986..61677ed9 100644 --- a/packages/integration-tests/src/test-scenarios.ts +++ b/packages/integration-tests/src/test-scenarios.ts @@ -17,8 +17,11 @@ export function createWaitUntilTracker(): WaitUntilTracker { tasks.push(task); }, waitForAll: async () => { - await Promise.all(tasks); - tasks.length = 0; + // drain in a loop: awaited tasks may register more via waitUntil mid-flight + while (tasks.length > 0) { + const pending = tasks.splice(0); + await Promise.all(pending); + } }, }; }