mirror of https://github.com/buster-so/buster.git
initial slack challenge verification and receive messages
This commit is contained in:
parent
4ab3ae97a2
commit
a751d22baa
|
@ -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
|
|
@ -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<SlackEventsResponse> {
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -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) => {
|
||||
|
|
|
@ -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<string, string | string[] | undefined> = {};
|
||||
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;
|
||||
}
|
||||
}
|
|
@ -115,3 +115,12 @@ export type RemoveIntegrationResult = z.infer<typeof RemoveIntegrationResultSche
|
|||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
/**
|
||||
* Response schema for Slack events endpoint
|
||||
*/
|
||||
export const SlackEventsResponseSchema = z.object({
|
||||
success: z.boolean(),
|
||||
});
|
||||
|
||||
export type SlackEventsResponse = z.infer<typeof SlackEventsResponseSchema>;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<WebClient> {
|
||||
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
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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('');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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<string, string | string[] | undefined>,
|
||||
signingSecret: string
|
||||
): boolean {
|
||||
// Normalize headers to lowercase
|
||||
const normalizedHeaders: Record<string, string | string[] | undefined> = {};
|
||||
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 '';
|
||||
}
|
|
@ -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<typeof SlackErrorCodeSchema>;
|
||||
|
|
|
@ -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<typeof urlVerificationSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof slackRequestHeadersSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof appMentionEventSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof eventCallbackSchema>;
|
||||
|
||||
/**
|
||||
* 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<typeof slackEventEnvelopeSchema>;
|
||||
|
||||
/**
|
||||
* Union type for all possible Slack webhook payloads
|
||||
*/
|
||||
export const slackWebhookPayloadSchema = z.union([urlVerificationSchema, eventCallbackSchema]);
|
||||
|
||||
export type SlackWebhookPayload = z.infer<typeof slackWebhookPayloadSchema>;
|
||||
|
||||
/**
|
||||
* 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';
|
||||
}
|
Loading…
Reference in New Issue