diff --git a/apps/server/src/api/v2/slack/README.md b/apps/server/src/api/v2/slack/README.md new file mode 100644 index 000000000..0c0b8d86a --- /dev/null +++ b/apps/server/src/api/v2/slack/README.md @@ -0,0 +1,58 @@ +# Slack API Routes + +This directory contains the Slack integration API endpoints. + +## Events Endpoint + +The `/events` endpoint handles Slack Events API webhooks, including: +- URL verification challenges +- App mention events + +### Implementation Details + +The endpoint uses a custom `slackWebhookValidator` middleware that: +1. Verifies the request signature using HMAC-SHA256 +2. Validates the request timestamp (within 5 minutes) +3. Parses and validates the payload using Zod schemas +4. Handles URL verification challenges automatically + +### Using Slack Schemas with zValidator + +If you need to validate Slack payloads in other routes, you can use the exported schemas: + +```typescript +import { zValidator } from '@hono/zod-validator'; +import { appMentionEventSchema, urlVerificationSchema } from '@buster/slack'; + +// Example: Validate just the inner event +.post('/custom-slack-handler', + zValidator('json', appMentionEventSchema), + async (c) => { + const event = c.req.valid('json'); + // event is fully typed as AppMentionEvent + } +) + +// Example: Validate URL verification +.post('/verify', + zValidator('json', urlVerificationSchema), + async (c) => { + const { challenge } = c.req.valid('json'); + return c.text(challenge); + } +) +``` + +### Available Schemas + +From `@buster/slack`: +- `urlVerificationSchema` - URL verification challenge +- `appMentionEventSchema` - App mention event +- `eventCallbackSchema` - Event callback envelope +- `slackWebhookPayloadSchema` - Union of all webhook payloads + +### Security Notes + +- Always verify webhook signatures before processing +- Respond with 200 OK even for errors to prevent Slack retries +- Keep the signing secret (`SLACK_SIGNING_SECRET`) secure \ No newline at end of file diff --git a/apps/server/src/api/v2/slack/events.ts b/apps/server/src/api/v2/slack/events.ts index c0eb4c3f2..d76ca1d34 100644 --- a/apps/server/src/api/v2/slack/events.ts +++ b/apps/server/src/api/v2/slack/events.ts @@ -1,4 +1,33 @@ -export async function eventsHandler(_body: unknown): Promise<{ success: boolean }> { - // Simply accept any JSON payload and return success - return { success: true }; -} +import { isEventCallback, type SlackWebhookPayload } from '@buster/slack'; +import type { SlackEventsResponse } from '@buster/server-shared/slack'; + +/** + * Handles Slack Events API webhook requests + * Processes validated webhook payloads + */ +export async function eventsHandler( + payload: SlackWebhookPayload +): Promise { + try { + // Handle the event based on type + if (isEventCallback(payload)) { + // Handle app_mention event + const event = payload.event; + + console.info('App mentioned:', { + team_id: payload.team_id, + channel: event.channel, + user: event.user, + text: event.text, + event_id: payload.event_id, + }); + + // TODO: Process app mention and respond + } + + return { success: true }; + } catch (error) { + console.error('Failed to process Slack event:', error); + throw error; + } +} \ No newline at end of file diff --git a/apps/server/src/api/v2/slack/index.ts b/apps/server/src/api/v2/slack/index.ts index e88ff66d2..7b21f4b0c 100644 --- a/apps/server/src/api/v2/slack/index.ts +++ b/apps/server/src/api/v2/slack/index.ts @@ -2,6 +2,7 @@ import { SlackError } from '@buster/server-shared/slack'; import { Hono } from 'hono'; import { HTTPException } from 'hono/http-exception'; import { requireAuth } from '../../../middleware/auth'; +import { slackWebhookValidator } from '../../../middleware/slack-webhook-validator'; import { eventsHandler } from './events'; import { slackHandler } from './handler'; @@ -15,38 +16,23 @@ const app = new Hono() .get('/channels', requireAuth, (c) => slackHandler.getChannels(c)) .delete('/integration', requireAuth, (c) => slackHandler.removeIntegration(c)) // Events endpoint (no auth required for Slack webhooks) - .post('/events', async (c) => { - try { - // Slack sends different content types for different events - // For URL verification, it's application/x-www-form-urlencoded - // For actual events, it's application/json - const contentType = c.req.header('content-type'); - - if (contentType?.includes('application/x-www-form-urlencoded')) { - // Handle URL verification challenge - const formData = await c.req.parseBody(); - if (formData.challenge) { - return c.text(formData.challenge as string); - } - } - - // For JSON payloads, try to parse but don't fail - let body = null; - if (contentType?.includes('application/json')) { - try { - body = await c.req.json(); - } catch { - // If JSON parsing fails, just continue - } - } - - const response = await eventsHandler(body); - return c.json(response); - } catch (error) { - // Log the error but always return 200 OK for Slack - console.error('Error processing Slack event:', error); - return c.json({ success: true }); + .post('/events', slackWebhookValidator(), async (c) => { + // Check if this is a URL verification challenge + const challenge = c.get('slackChallenge'); + if (challenge) { + return c.text(challenge); } + + // Get the validated payload + const payload = c.get('slackPayload'); + if (!payload) { + // This shouldn't happen if middleware works correctly + return c.json({ success: false }); + } + + // Process the event + const response = await eventsHandler(payload); + return c.json(response); }) // Error handling .onError((e, c) => { diff --git a/apps/server/src/middleware/slack-webhook-validator.ts b/apps/server/src/middleware/slack-webhook-validator.ts new file mode 100644 index 000000000..a89b6d837 --- /dev/null +++ b/apps/server/src/middleware/slack-webhook-validator.ts @@ -0,0 +1,76 @@ +import { + handleUrlVerification, + parseSlackWebhookPayload, + verifySlackRequest, + type SlackWebhookPayload, +} from '@buster/slack'; +import type { Context, MiddlewareHandler } from 'hono'; +import { HTTPException } from 'hono/http-exception'; + +/** + * Middleware to validate Slack webhook requests + * Combines signature verification and payload parsing + */ +export function slackWebhookValidator(): MiddlewareHandler { + return async (c: Context, next) => { + console.info('Slack webhook received'); + try { + // Get the raw body for signature verification + const rawBody = await c.req.text(); + console.info('Raw body length:', rawBody.length); + + // Get headers for verification + const headers: Record = {}; + c.req.raw.headers.forEach((value, key) => { + headers[key] = value; + }); + + // Get signing secret from environment + const signingSecret = process.env.SLACK_SIGNING_SECRET; + if (!signingSecret) { + throw new HTTPException(500, { + message: 'SLACK_SIGNING_SECRET not configured', + }); + } + + // Verify the request signature + verifySlackRequest(rawBody, headers, signingSecret); + + // Parse the request body + const parsedBody = JSON.parse(rawBody); + + // Check if this is a URL verification request + const challenge = handleUrlVerification(parsedBody); + if (challenge) { + // Set the challenge in context for the handler to use + c.set('slackChallenge', challenge); + c.set('slackPayload', null); + return next(); + } + + // Parse and validate the webhook payload + const payload = parseSlackWebhookPayload(parsedBody); + + // Set the validated payload in context + c.set('slackPayload', payload); + c.set('slackChallenge', null); + + return next(); + } catch (error) { + // For Slack webhooks, we should always return 200 OK + // to prevent retries, but log the error + console.error('Slack webhook validation error:', error); + + // Return 200 OK with success: false + return c.json({ success: false }); + } + }; +} + +// Type extensions for Hono context +declare module 'hono' { + interface ContextVariableMap { + slackPayload: SlackWebhookPayload | null; + slackChallenge: string | null; + } +} \ No newline at end of file diff --git a/packages/server-shared/src/slack/responses.types.ts b/packages/server-shared/src/slack/responses.types.ts index 095c92916..9106914c6 100644 --- a/packages/server-shared/src/slack/responses.types.ts +++ b/packages/server-shared/src/slack/responses.types.ts @@ -115,3 +115,12 @@ export type RemoveIntegrationResult = z.infer; diff --git a/packages/slack/src/index.ts b/packages/slack/src/index.ts index 68007ff35..504cda691 100644 --- a/packages/slack/src/index.ts +++ b/packages/slack/src/index.ts @@ -1,11 +1,28 @@ // Types export * from './types'; export * from './types/errors'; +export * from './types/webhooks'; + +// Schemas for validation (useful with zValidator) +export { + urlVerificationSchema, + slackRequestHeadersSchema, + appMentionEventSchema, + eventCallbackSchema, + slackEventEnvelopeSchema, + slackWebhookPayloadSchema, +} from './types/webhooks'; // Services export { SlackAuthService } from './services/auth'; export { SlackChannelService } from './services/channels'; export { SlackMessagingService } from './services/messaging'; +export { + verifySlackRequest, + handleUrlVerification, + parseSlackWebhookPayload, + getRawBody, +} from './services/webhook-verification'; // Interfaces export type { diff --git a/packages/slack/src/services/auth.ts b/packages/slack/src/services/auth.ts index 97d9af92d..fd1594efd 100644 --- a/packages/slack/src/services/auth.ts +++ b/packages/slack/src/services/auth.ts @@ -185,6 +185,27 @@ export class SlackAuthService { } } + /** + * Get an authenticated Slack WebClient for a team + * @param teamId The Slack team ID + * @returns WebClient instance configured with the team's access token + */ + async getSlackClient(teamId: string): Promise { + const accessToken = await this.tokenStorage.getToken(teamId); + if (!accessToken) { + throw new SlackIntegrationError( + 'TOKEN_NOT_FOUND', + `No access token found for team ${teamId}`, + false + ); + } + + // Validate token before returning client + await this.validateToken(accessToken); + + return new WebClient(accessToken); + } + /** * Exchange authorization code for access token * @param code Authorization code from Slack diff --git a/packages/slack/src/services/webhook-verification.int.test.ts b/packages/slack/src/services/webhook-verification.int.test.ts new file mode 100644 index 000000000..d54d2a559 --- /dev/null +++ b/packages/slack/src/services/webhook-verification.int.test.ts @@ -0,0 +1,198 @@ +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; +import { SlackIntegrationError } from '../types/errors'; +import type { SlackEventEnvelope, UrlVerification } from '../types/webhooks'; +import { + handleUrlVerification, + parseSlackWebhookPayload, + verifySlackRequest, +} from './webhook-verification'; + +describe('webhook-verification integration tests', () => { + // Mock signing secret (in real scenario, this would be from environment) + const MOCK_SIGNING_SECRET = 'b6a8e2d4f9c3e1a7d5b2f8e4c9a3d6f1'; + + describe('Full webhook verification flow', () => { + it('should handle URL verification challenge end-to-end', () => { + // 1. Slack sends URL verification + const urlVerificationPayload: UrlVerification = { + token: 'Jhj5dZrVaK7ZwHHjRyZWjbDl', + challenge: '3eZbrw1aBm2rZgRNFdxV2595E9CY3gmdALWMmHkvFXO7tYXAYM8P', + type: 'url_verification', + }; + + const rawBody = JSON.stringify(urlVerificationPayload); + const timestamp = Math.floor(Date.now() / 1000).toString(); + + // 2. Generate signature like Slack would + const baseString = `v0:${timestamp}:${rawBody}`; + const hmac = createHmac('sha256', MOCK_SIGNING_SECRET); + hmac.update(baseString, 'utf8'); + const signature = `v0=${hmac.digest('hex')}`; + + const headers = { + 'x-slack-request-timestamp': timestamp, + 'x-slack-signature': signature, + }; + + // 3. Verify the request + const isValid = verifySlackRequest(rawBody, headers, MOCK_SIGNING_SECRET); + expect(isValid).toBe(true); + + // 4. Parse the payload + const parsedPayload = parseSlackWebhookPayload(JSON.parse(rawBody)); + expect(parsedPayload).toEqual(urlVerificationPayload); + + // 5. Handle URL verification + const challenge = handleUrlVerification(parsedPayload); + expect(challenge).toBe(urlVerificationPayload.challenge); + }); + + it('should handle event callback with verification', () => { + // 1. Slack sends an event + const eventPayload: SlackEventEnvelope = { + token: 'XXYYZZ', + team_id: 'TXXXXXXXX', + api_app_id: 'AXXXXXXXXX', + event: { + type: 'app_mention', + user: 'U123456', + text: '<@U0LAN0Z89> is it a bird?', + ts: '1515449522.000016', + channel: 'C123456', + event_ts: '1515449522000016', + }, + type: 'event_callback', + event_id: 'Ev08MFMKH6', + event_time: 1234567890, + }; + + const rawBody = JSON.stringify(eventPayload); + const timestamp = Math.floor(Date.now() / 1000).toString(); + + // 2. Generate signature + const baseString = `v0:${timestamp}:${rawBody}`; + const hmac = createHmac('sha256', MOCK_SIGNING_SECRET); + hmac.update(baseString, 'utf8'); + const signature = `v0=${hmac.digest('hex')}`; + + const headers = { + 'x-slack-request-timestamp': timestamp, + 'x-slack-signature': signature, + }; + + // 3. Verify the request + const isValid = verifySlackRequest(rawBody, headers, MOCK_SIGNING_SECRET); + expect(isValid).toBe(true); + + // 4. Parse the payload + const parsedPayload = parseSlackWebhookPayload(JSON.parse(rawBody)); + expect(parsedPayload).toEqual(eventPayload); + + // 5. Verify it's not a URL verification + const challenge = handleUrlVerification(parsedPayload); + expect(challenge).toBeNull(); + }); + + it('should reject tampered requests', () => { + const payload = { + token: 'test-token', + type: 'event_callback', + event: { type: 'message', text: 'hello' }, + }; + + const rawBody = JSON.stringify(payload); + const timestamp = Math.floor(Date.now() / 1000).toString(); + + // Generate signature with correct secret + const baseString = `v0:${timestamp}:${rawBody}`; + const hmac = createHmac('sha256', MOCK_SIGNING_SECRET); + hmac.update(baseString, 'utf8'); + const signature = `v0=${hmac.digest('hex')}`; + + // Tamper with the body after signature generation + const tamperedBody = JSON.stringify({ ...payload, text: 'tampered' }); + + const headers = { + 'x-slack-request-timestamp': timestamp, + 'x-slack-signature': signature, + }; + + // Should fail verification due to body tampering + expect(() => verifySlackRequest(tamperedBody, headers, MOCK_SIGNING_SECRET)).toThrow( + SlackIntegrationError + ); + expect(() => verifySlackRequest(tamperedBody, headers, MOCK_SIGNING_SECRET)).toThrow( + 'Invalid request signature' + ); + }); + + it('should handle replay attack prevention', () => { + const payload = { type: 'event_callback', event: {} }; + const rawBody = JSON.stringify(payload); + + // Create timestamp 6 minutes ago + const oldTimestamp = (Math.floor(Date.now() / 1000) - 360).toString(); + + const baseString = `v0:${oldTimestamp}:${rawBody}`; + const hmac = createHmac('sha256', MOCK_SIGNING_SECRET); + hmac.update(baseString, 'utf8'); + const signature = `v0=${hmac.digest('hex')}`; + + const headers = { + 'x-slack-request-timestamp': oldTimestamp, + 'x-slack-signature': signature, + }; + + // Should reject due to old timestamp + expect(() => verifySlackRequest(rawBody, headers, MOCK_SIGNING_SECRET)).toThrow( + 'Request timestamp is too old' + ); + }); + }); + + describe('Edge cases and error scenarios', () => { + it('should handle malformed JSON gracefully', () => { + const malformedBody = '{"type": "event_callback", "event": {'; + const timestamp = Math.floor(Date.now() / 1000).toString(); + + // Even with valid signature, parsing should fail + const baseString = `v0:${timestamp}:${malformedBody}`; + const hmac = createHmac('sha256', MOCK_SIGNING_SECRET); + hmac.update(baseString, 'utf8'); + const signature = `v0=${hmac.digest('hex')}`; + + const headers = { + 'x-slack-request-timestamp': timestamp, + 'x-slack-signature': signature, + }; + + // Verification passes (signature is valid for the malformed body) + const isValid = verifySlackRequest(malformedBody, headers, MOCK_SIGNING_SECRET); + expect(isValid).toBe(true); + + // But parsing should fail + expect(() => parseSlackWebhookPayload(malformedBody)).toThrow('Invalid webhook payload'); + }); + + it('should handle different content types', () => { + // Form-encoded body (though Slack sends JSON for events) + const formBody = 'token=test&challenge=abc123&type=url_verification'; + const timestamp = Math.floor(Date.now() / 1000).toString(); + + const baseString = `v0:${timestamp}:${formBody}`; + const hmac = createHmac('sha256', MOCK_SIGNING_SECRET); + hmac.update(baseString, 'utf8'); + const signature = `v0=${hmac.digest('hex')}`; + + const headers = { + 'x-slack-request-timestamp': timestamp, + 'x-slack-signature': signature, + }; + + // Should verify successfully (signature is valid) + const isValid = verifySlackRequest(formBody, headers, MOCK_SIGNING_SECRET); + expect(isValid).toBe(true); + }); + }); +}); diff --git a/packages/slack/src/services/webhook-verification.test.ts b/packages/slack/src/services/webhook-verification.test.ts new file mode 100644 index 000000000..d1bce5ee3 --- /dev/null +++ b/packages/slack/src/services/webhook-verification.test.ts @@ -0,0 +1,211 @@ +import { createHmac } from 'node:crypto'; +import { describe, expect, it, vi } from 'vitest'; +import { SlackIntegrationError } from '../types/errors'; +import { + getRawBody, + handleUrlVerification, + parseSlackWebhookPayload, + verifySlackRequest, +} from './webhook-verification'; + +describe('webhook-verification', () => { + const signingSecret = 'test-signing-secret'; + const timestamp = Math.floor(Date.now() / 1000).toString(); + const rawBody = '{"test":"data"}'; + + const generateSignature = (body: string, ts: string, secret: string): string => { + const baseString = `v0:${ts}:${body}`; + const hmac = createHmac('sha256', secret); + hmac.update(baseString, 'utf8'); + return `v0=${hmac.digest('hex')}`; + }; + + describe('verifySlackRequest', () => { + it('should verify a valid request', () => { + const signature = generateSignature(rawBody, timestamp, signingSecret); + const headers = { + 'x-slack-request-timestamp': timestamp, + 'x-slack-signature': signature, + }; + + const result = verifySlackRequest(rawBody, headers, signingSecret); + expect(result).toBe(true); + }); + + it('should handle headers with different casing', () => { + const signature = generateSignature(rawBody, timestamp, signingSecret); + const headers = { + 'X-Slack-Request-Timestamp': timestamp, + 'X-SLACK-SIGNATURE': signature, + }; + + const result = verifySlackRequest(rawBody, headers, signingSecret); + expect(result).toBe(true); + }); + + it('should handle array header values', () => { + const signature = generateSignature(rawBody, timestamp, signingSecret); + const headers = { + 'x-slack-request-timestamp': [timestamp], + 'x-slack-signature': [signature], + }; + + const result = verifySlackRequest(rawBody, headers, signingSecret); + expect(result).toBe(true); + }); + + it('should throw error for missing headers', () => { + const headers = {}; + + expect(() => verifySlackRequest(rawBody, headers, signingSecret)).toThrow( + SlackIntegrationError + ); + expect(() => verifySlackRequest(rawBody, headers, signingSecret)).toThrow( + 'Missing required Slack headers' + ); + }); + + it('should throw error for old timestamp', () => { + const oldTimestamp = (Math.floor(Date.now() / 1000) - 400).toString(); // 6+ minutes old + const signature = generateSignature(rawBody, oldTimestamp, signingSecret); + const headers = { + 'x-slack-request-timestamp': oldTimestamp, + 'x-slack-signature': signature, + }; + + expect(() => verifySlackRequest(rawBody, headers, signingSecret)).toThrow( + 'Request timestamp is too old' + ); + }); + + it('should throw error for invalid signature', () => { + const headers = { + 'x-slack-request-timestamp': timestamp, + 'x-slack-signature': 'v0=invalid_signature', + }; + + expect(() => verifySlackRequest(rawBody, headers, signingSecret)).toThrow( + 'Invalid request signature' + ); + }); + + it('should throw error for wrong signing secret', () => { + const signature = generateSignature(rawBody, timestamp, 'wrong-secret'); + const headers = { + 'x-slack-request-timestamp': timestamp, + 'x-slack-signature': signature, + }; + + expect(() => verifySlackRequest(rawBody, headers, signingSecret)).toThrow( + 'Invalid request signature' + ); + }); + }); + + describe('handleUrlVerification', () => { + it('should return challenge for valid URL verification', () => { + const body = { + token: 'test-token', + challenge: 'test-challenge-string', + type: 'url_verification', + }; + + const result = handleUrlVerification(body); + expect(result).toBe('test-challenge-string'); + }); + + it('should return null for non-URL verification requests', () => { + const body = { + type: 'event_callback', + event: { type: 'message' }, + }; + + const result = handleUrlVerification(body); + expect(result).toBeNull(); + }); + + it('should return null for invalid body', () => { + const result = handleUrlVerification('invalid'); + expect(result).toBeNull(); + }); + }); + + describe('parseSlackWebhookPayload', () => { + it('should parse URL verification payload', () => { + const payload = { + token: 'test-token', + challenge: 'test-challenge', + type: 'url_verification', + }; + + const result = parseSlackWebhookPayload(payload); + expect(result).toEqual(payload); + }); + + it('should parse event callback payload', () => { + const payload = { + token: 'test-token', + team_id: 'T123456', + api_app_id: 'A123456', + event: { + type: 'app_mention', + user: 'U123456', + text: '<@U0LAN0Z89> hello', + ts: '1515449522.000016', + channel: 'C123456', + event_ts: '1515449522000016', + }, + type: 'event_callback', + event_id: 'Ev123456', + event_time: 1234567890, + }; + + const result = parseSlackWebhookPayload(payload); + expect(result).toEqual(payload); + }); + + it('should throw error for invalid payload', () => { + const invalidPayload = { + type: 'invalid_type', + }; + + expect(() => parseSlackWebhookPayload(invalidPayload)).toThrow(SlackIntegrationError); + expect(() => parseSlackWebhookPayload(invalidPayload)).toThrow('Invalid webhook payload'); + }); + + it('should throw error for non-object payload', () => { + expect(() => parseSlackWebhookPayload('string')).toThrow('Invalid webhook payload'); + expect(() => parseSlackWebhookPayload(123)).toThrow('Invalid webhook payload'); + expect(() => parseSlackWebhookPayload(null)).toThrow('Invalid webhook payload'); + }); + }); + + describe('getRawBody', () => { + it('should return string as-is', () => { + const body = 'test string'; + expect(getRawBody(body)).toBe(body); + }); + + it('should convert Buffer to string', () => { + const body = Buffer.from('test buffer', 'utf8'); + expect(getRawBody(body)).toBe('test buffer'); + }); + + it('should stringify objects', () => { + const body = { test: 'data' }; + expect(getRawBody(body)).toBe('{"test":"data"}'); + }); + + it('should handle null', () => { + expect(getRawBody(null)).toBe(''); + }); + + it('should handle undefined', () => { + expect(getRawBody(undefined)).toBe(''); + }); + + it('should handle numbers', () => { + expect(getRawBody(123)).toBe(''); + }); + }); +}); diff --git a/packages/slack/src/services/webhook-verification.ts b/packages/slack/src/services/webhook-verification.ts new file mode 100644 index 000000000..4fc0e70c9 --- /dev/null +++ b/packages/slack/src/services/webhook-verification.ts @@ -0,0 +1,136 @@ +import { createHmac } from 'node:crypto'; +import { z } from 'zod'; +import { SlackIntegrationError } from '../types/errors'; +import { + type SlackRequestHeaders, + type SlackWebhookPayload, + type UrlVerification, + slackRequestHeadersSchema, + slackWebhookPayloadSchema, + urlVerificationSchema, +} from '../types/webhooks'; + +/** + * Maximum age for a valid request (5 minutes in seconds) + */ +const MAX_REQUEST_AGE_SECONDS = 300; + +/** + * Verifies that a request is from Slack by checking the signature + * @param rawBody - The raw request body as a string + * @param headers - The request headers containing Slack signature and timestamp + * @param signingSecret - Your app's signing secret from Slack + * @returns true if the request is valid, throws an error otherwise + */ +export function verifySlackRequest( + rawBody: string, + headers: Record, + signingSecret: string +): boolean { + // Normalize headers to lowercase + const normalizedHeaders: Record = {}; + for (const [key, value] of Object.entries(headers)) { + normalizedHeaders[key.toLowerCase()] = value; + } + + // Extract and validate required headers + const timestamp = normalizedHeaders['x-slack-request-timestamp']; + const signature = normalizedHeaders['x-slack-signature']; + + if (!timestamp || !signature) { + throw new SlackIntegrationError('VERIFICATION_FAILED', 'Missing required Slack headers'); + } + + // Ensure headers are strings + const timestampStr = Array.isArray(timestamp) ? timestamp[0] : timestamp; + const signatureStr = Array.isArray(signature) ? signature[0] : signature; + + if (!timestampStr || !signatureStr) { + throw new SlackIntegrationError('VERIFICATION_FAILED', 'Missing required Slack headers'); + } + + // Validate headers with schema + try { + slackRequestHeadersSchema.parse({ + 'x-slack-request-timestamp': timestampStr, + 'x-slack-signature': signatureStr, + }); + } catch { + throw new SlackIntegrationError('VERIFICATION_FAILED', 'Invalid Slack headers format'); + } + + // Check timestamp to prevent replay attacks + const requestTimestamp = Number.parseInt(timestampStr, 10); + const currentTimestamp = Math.floor(Date.now() / 1000); + + if (Math.abs(currentTimestamp - requestTimestamp) > MAX_REQUEST_AGE_SECONDS) { + throw new SlackIntegrationError('VERIFICATION_FAILED', 'Request timestamp is too old'); + } + + // Create the base string for signature + const baseString = `v0:${timestampStr}:${rawBody}`; + + // Calculate expected signature + const hmac = createHmac('sha256', signingSecret); + hmac.update(baseString, 'utf8'); + const expectedSignature = `v0=${hmac.digest('hex')}`; + + // Compare signatures + if (expectedSignature !== signatureStr) { + throw new SlackIntegrationError('VERIFICATION_FAILED', 'Invalid request signature'); + } + + return true; +} + +/** + * Handles URL verification challenges from Slack + * @param body - The request body + * @returns The challenge value if it's a URL verification, null otherwise + */ +export function handleUrlVerification(body: unknown): string | null { + try { + const parsed = urlVerificationSchema.parse(body); + return parsed.challenge; + } catch { + // Not a URL verification request + return null; + } +} + +/** + * Parses and validates a Slack webhook payload + * @param body - The request body + * @returns The parsed and validated webhook payload + */ +export function parseSlackWebhookPayload(body: unknown): SlackWebhookPayload { + try { + return slackWebhookPayloadSchema.parse(body); + } catch (error) { + if (error instanceof z.ZodError) { + throw new SlackIntegrationError( + 'INVALID_PAYLOAD', + `Invalid webhook payload: ${error.errors.map((e) => e.message).join(', ')}` + ); + } + throw new SlackIntegrationError('INVALID_PAYLOAD', 'Failed to parse webhook payload'); + } +} + +/** + * Helper to get the raw body string from various request formats + * @param body - The request body in various formats + * @returns The raw body as a string + */ +export function getRawBody(body: unknown): string { + if (typeof body === 'string') { + return body; + } + if (Buffer.isBuffer(body)) { + return body.toString('utf8'); + } + if (typeof body === 'object' && body !== null) { + return JSON.stringify(body); + } + return ''; +} diff --git a/packages/slack/src/types/errors.ts b/packages/slack/src/types/errors.ts index 373b92d64..e1abbcf5a 100644 --- a/packages/slack/src/types/errors.ts +++ b/packages/slack/src/types/errors.ts @@ -5,11 +5,14 @@ export const SlackErrorCodeSchema = z.enum([ 'OAUTH_INVALID_STATE', 'OAUTH_TOKEN_EXCHANGE_FAILED', 'INVALID_TOKEN', + 'TOKEN_NOT_FOUND', 'CHANNEL_NOT_FOUND', 'NOT_IN_CHANNEL', 'RATE_LIMITED', 'NETWORK_ERROR', 'UNKNOWN_ERROR', + 'VERIFICATION_FAILED', + 'INVALID_PAYLOAD', ]); export type SlackErrorCode = z.infer; diff --git a/packages/slack/src/types/webhooks.ts b/packages/slack/src/types/webhooks.ts new file mode 100644 index 000000000..b76dd4613 --- /dev/null +++ b/packages/slack/src/types/webhooks.ts @@ -0,0 +1,89 @@ +import { z } from 'zod'; + +/** + * URL Verification Challenge + * Sent by Slack when configuring a Request URL for the Events API + */ +export const urlVerificationSchema = z.object({ + token: z.string(), + challenge: z.string(), + type: z.literal('url_verification'), +}); + +export type UrlVerification = z.infer; + +/** + * Slack Request Headers + * Headers sent with every request from Slack + */ +export const slackRequestHeadersSchema = z.object({ + 'x-slack-request-timestamp': z.string(), + 'x-slack-signature': z.string(), +}); + +export type SlackRequestHeaders = z.infer; + +/** + * App Mention Event + * Sent when a user mentions your app in a message + */ +export const appMentionEventSchema = z.object({ + type: z.literal('app_mention'), + user: z.string(), + text: z.string(), + ts: z.string(), + channel: z.string(), + event_ts: z.string(), +}); + +export type AppMentionEvent = z.infer; + +/** + * Event Callback Envelope + * The wrapper for all event_callback type events + */ +export const eventCallbackSchema = z.object({ + token: z.string(), + team_id: z.string(), + api_app_id: z.string(), + event: appMentionEventSchema, + type: z.literal('event_callback'), + event_id: z.string(), + event_time: z.number(), +}); + +export type EventCallback = z.infer; + +/** + * Base Event Envelope (for backwards compatibility) + * Uses record for event field to accept any event type + */ +export const slackEventEnvelopeSchema = z.object({ + token: z.string(), + team_id: z.string(), + api_app_id: z.string(), + event: z.record(z.unknown()), + type: z.literal('event_callback'), + event_id: z.string(), + event_time: z.number(), +}); + +export type SlackEventEnvelope = z.infer; + +/** + * Union type for all possible Slack webhook payloads + */ +export const slackWebhookPayloadSchema = z.union([urlVerificationSchema, eventCallbackSchema]); + +export type SlackWebhookPayload = z.infer; + +/** + * Helper function to determine event type + */ +export function isUrlVerification(payload: SlackWebhookPayload): payload is UrlVerification { + return payload.type === 'url_verification'; +} + +export function isEventCallback(payload: SlackWebhookPayload): payload is EventCallback { + return payload.type === 'event_callback'; +}