diff --git a/src/app.ts b/src/app.ts index 88e01b4..c80c657 100644 --- a/src/app.ts +++ b/src/app.ts @@ -146,6 +146,10 @@ app.use('/api/integrations/keys', createApiKeyRouter()) // Policy engine – fine-grained org permissions app.use('/api/orgs/:orgId/policies', createPolicyRouter()) +// Webhook management (secret rotation, etc.) +const webhookStore = new MemoryWebhookStore() +app.use('/api/webhooks', createWebhookRouter(webhookStore, auditLogService)) + const analyticsThresholdSeconds = Number(process.env.ANALYTICS_STALENESS_SECONDS ?? '300') const analyticsService = process.env.DATABASE_URL ? new AnalyticsService(pool, analyticsThresholdSeconds) diff --git a/src/routes/webhooks.ts b/src/routes/webhooks.ts new file mode 100644 index 0000000..af7d739 --- /dev/null +++ b/src/routes/webhooks.ts @@ -0,0 +1,79 @@ +import { Router, Request, Response } from 'express' +import { AuthenticatedRequest, requireUserAuth, requireAdminRole } from '../middleware/auth.js' +import { WebhookRotationService, WebhookNotFoundError } from '../services/webhooks/rotationService.js' +import type { WebhookStore } from '../services/webhooks/types.js' +import type { AuditLogService } from '../services/audit/index.js' + +/** + * Create the webhook management router. + * + * Injecting store and audit allows tests to supply in-memory doubles + * without touching module-level singletons. + */ +export function createWebhookRouter(store: WebhookStore, audit: AuditLogService): Router { + const router = Router() + const rotationService = new WebhookRotationService(store, audit) + + /** + * POST /api/webhooks/:webhookId/rotate-secret + * + * Rotate the HMAC signing secret for a webhook. + * + * Safe-rollout: the previous secret remains valid for 24 h so that + * consumers can migrate without dropping events. + * + * The new secret is returned exactly once in this response and is never + * stored in plain text. Store it securely immediately. + * + * @requires Admin role + * + * @param webhookId - ID of the webhook whose secret should be rotated + * + * @returns {object} Rotation result + * @returns {string} .webhookId + * @returns {string} .newSecret — plain-text secret, shown once only + * @returns {string} .rotatedAt — ISO timestamp of rotation + * @returns {string} .previousSecretExpiresAt — ISO timestamp when old secret expires + */ + router.post( + '/:webhookId/rotate-secret', + requireUserAuth, + requireAdminRole, + async (req: Request, res: Response): Promise => { + const { webhookId } = req.params + const authReq = req as AuthenticatedRequest + const actor = authReq.user! + const ipAddress = req.ip ?? req.socket.remoteAddress + + try { + const result = await rotationService.rotateSecret( + webhookId, + actor.id, + actor.email, + ipAddress, + ) + + res.status(200).json({ + success: true, + data: result, + }) + } catch (err) { + if (err instanceof WebhookNotFoundError) { + res.status(404).json({ + error: 'NotFound', + message: err.message, + }) + return + } + + const message = err instanceof Error ? err.message : 'Unknown error' + res.status(500).json({ + error: 'InternalError', + message, + }) + } + }, + ) + + return router +} diff --git a/src/services/webhooks/memoryStore.ts b/src/services/webhooks/memoryStore.ts index 0b66818..23586ae 100644 --- a/src/services/webhooks/memoryStore.ts +++ b/src/services/webhooks/memoryStore.ts @@ -18,4 +18,23 @@ export class MemoryWebhookStore implements WebhookStore { async set(config: WebhookConfig): Promise { this.webhooks.set(config.id, config) } + + async rotateSecret( + id: string, + newSecret: string, + previousSecret: string, + previousSecretExpiresAt: string, + ): Promise { + const existing = this.webhooks.get(id) + if (!existing) throw new Error(`Webhook not found: ${id}`) + const updated: WebhookConfig = { + ...existing, + secret: newSecret, + previousSecret, + secretRotatedAt: new Date().toISOString(), + previousSecretExpiresAt, + } + this.webhooks.set(id, updated) + return updated + } } diff --git a/src/services/webhooks/rotationService.ts b/src/services/webhooks/rotationService.ts new file mode 100644 index 0000000..8a73626 --- /dev/null +++ b/src/services/webhooks/rotationService.ts @@ -0,0 +1,73 @@ +import { randomBytes } from 'crypto' +import type { WebhookStore, WebhookSecretRotationResult } from './types.js' +import type { AuditLogService } from '../audit/index.js' +import { AuditAction } from '../audit/index.js' + +/** Grace period during which the previous secret remains valid for client verification. */ +const PREVIOUS_SECRET_TTL_MS = 24 * 60 * 60 * 1000 // 24 hours + +export class WebhookNotFoundError extends Error { + constructor(webhookId: string) { + super(`Webhook not found: ${webhookId}`) + this.name = 'WebhookNotFoundError' + } +} + +/** + * Handles webhook signing-secret rotation with safe-rollout semantics: + * - Generates a cryptographically random new secret. + * - Keeps the old secret alive as `previousSecret` for 24 h so consumers + * can migrate without downtime. + * - Emits an audit log entry for every rotation attempt (success or failure). + */ +export class WebhookRotationService { + constructor( + private readonly store: WebhookStore, + private readonly audit: AuditLogService, + ) {} + + async rotateSecret( + webhookId: string, + actorId: string, + actorEmail: string, + ipAddress?: string, + ): Promise { + const webhook = await this.store.get(webhookId) + + if (!webhook) { + this.audit.logAction( + actorId, + actorEmail, + AuditAction.ROTATE_WEBHOOK_SECRET, + webhookId, + '', + { webhookId }, + 'failure', + 'Webhook not found', + ipAddress, + ) + throw new WebhookNotFoundError(webhookId) + } + + const newSecret = randomBytes(32).toString('hex') + const now = new Date() + const previousSecretExpiresAt = new Date(now.getTime() + PREVIOUS_SECRET_TTL_MS).toISOString() + const rotatedAt = now.toISOString() + + await this.store.rotateSecret(webhookId, newSecret, webhook.secret, previousSecretExpiresAt) + + this.audit.logAction( + actorId, + actorEmail, + AuditAction.ROTATE_WEBHOOK_SECRET, + webhookId, + '', + { webhookId, url: webhook.url, previousSecretExpiresAt }, + 'success', + undefined, + ipAddress, + ) + + return { webhookId, newSecret, rotatedAt, previousSecretExpiresAt } + } +} diff --git a/src/services/webhooks/types.ts b/src/services/webhooks/types.ts index 3e43a34..c46637d 100644 --- a/src/services/webhooks/types.ts +++ b/src/services/webhooks/types.ts @@ -13,7 +13,7 @@ export interface WebhookConfig { url: string /** Events this webhook is subscribed to. */ events: WebhookEventType[] - /** Secret key for HMAC signature verification. */ + /** Current HMAC signing secret. */ secret: string /** Previously active secret (during grace period). */ previousSecret?: string @@ -21,6 +21,23 @@ export interface WebhookConfig { secretUpdatedAt: Date /** Whether this webhook is active. */ active: boolean + /** Previous secret kept alive during safe-rollout grace period. */ + previousSecret?: string + /** ISO timestamp when the secret was last rotated. */ + secretRotatedAt?: string + /** ISO timestamp after which previousSecret is no longer valid. */ + previousSecretExpiresAt?: string +} + +/** + * Result returned to the caller after a successful secret rotation. + * newSecret is shown exactly once — it is never persisted in plain text. + */ +export interface WebhookSecretRotationResult { + webhookId: string + newSecret: string + rotatedAt: string + previousSecretExpiresAt: string } /** @@ -96,4 +113,15 @@ export interface WebhookStore { get(id: string): Promise /** Save or update webhook config. */ set(config: WebhookConfig): Promise + /** + * Atomically swap in a new signing secret while preserving the old one + * for the given grace period. Implementations must treat this as a single + * operation so concurrent rotations cannot race. + */ + rotateSecret( + id: string, + newSecret: string, + previousSecret: string, + previousSecretExpiresAt: string, + ): Promise } diff --git a/tests/routes/webhooks.test.ts b/tests/routes/webhooks.test.ts new file mode 100644 index 0000000..a7160cf --- /dev/null +++ b/tests/routes/webhooks.test.ts @@ -0,0 +1,242 @@ +/** + * @file Integration tests for webhook management routes. + * + * Covers: + * ─ POST /:webhookId/rotate-secret — happy path, 404, 401, 403 + */ + +import { describe, it, expect, beforeEach } from 'vitest' +import express, { type Express } from 'express' + +import { MemoryWebhookStore } from '../../src/services/webhooks/memoryStore.js' +import { AuditLogService } from '../../src/services/audit/index.js' +import { createWebhookRouter } from '../../src/routes/webhooks.js' +import type { WebhookConfig } from '../../src/services/webhooks/types.js' + +// ── Lightweight fetch helper ────────────────────────────────────────────── + +async function request( + app: Express, + method: 'GET' | 'POST' | 'DELETE', + path: string, + options: { headers?: Record; body?: unknown } = {}, +): Promise<{ status: number; body: unknown }> { + return new Promise((resolve, reject) => { + const server = app.listen(0, () => { + const addr = server.address() + if (!addr || typeof addr === 'string') { + server.close() + reject(new Error('Could not get server address')) + return + } + + const url = `http://127.0.0.1:${addr.port}${path}` + const opts: RequestInit = { + method, + headers: { + 'Content-Type': 'application/json', + ...(options.headers ?? {}), + }, + } + if (options.body !== undefined) opts.body = JSON.stringify(options.body) + + fetch(url, opts) + .then(async (res) => { + const json = await res.json() + server.close() + resolve({ status: res.status, body: json }) + }) + .catch((err) => { + server.close() + reject(err) + }) + }) + }) +} + +// ── Test data ───────────────────────────────────────────────────────────── + +const ADMIN_BEARER = 'Bearer admin-key-12345' +const VERIFIER_BEARER = 'Bearer verifier-key-67890' + +const SEED_WEBHOOK: WebhookConfig = { + id: 'wh-test-001', + url: 'https://example.com/hook', + events: ['bond.created'], + secret: 'initial-secret-value', + active: true, +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +describe('Webhook Routes', () => { + let app: Express + let store: MemoryWebhookStore + let audit: AuditLogService + const BASE = '/api/webhooks' + + beforeEach(async () => { + store = new MemoryWebhookStore() + audit = new AuditLogService() + app = express() + app.use(express.json()) + app.use(BASE, createWebhookRouter(store, audit)) + + await store.set({ ...SEED_WEBHOOK }) + }) + + // ═══════════════════════════════════════════════════════════════════════ + // POST /:webhookId/rotate-secret + // ═══════════════════════════════════════════════════════════════════════ + + describe('POST /:webhookId/rotate-secret', () => { + it('rotates the secret and returns rotation metadata', async () => { + const { status, body } = await request( + app, + 'POST', + `${BASE}/${SEED_WEBHOOK.id}/rotate-secret`, + { headers: { Authorization: ADMIN_BEARER } }, + ) + + expect(status).toBe(200) + const data = (body as { success: boolean; data: Record }).data + expect((body as { success: boolean }).success).toBe(true) + expect(data.webhookId).toBe(SEED_WEBHOOK.id) + expect(data.newSecret).toBeTruthy() + expect(data.newSecret).not.toBe(SEED_WEBHOOK.secret) + expect(data.rotatedAt).toBeTruthy() + expect(data.previousSecretExpiresAt).toBeTruthy() + }) + + it('new secret is a 64-char hex string', async () => { + const { body } = await request( + app, + 'POST', + `${BASE}/${SEED_WEBHOOK.id}/rotate-secret`, + { headers: { Authorization: ADMIN_BEARER } }, + ) + + const { newSecret } = (body as { data: { newSecret: string } }).data + expect(newSecret).toMatch(/^[0-9a-f]{64}$/) + }) + + it('updates the store — old secret is preserved as previousSecret', async () => { + await request(app, 'POST', `${BASE}/${SEED_WEBHOOK.id}/rotate-secret`, { + headers: { Authorization: ADMIN_BEARER }, + }) + + const updated = await store.get(SEED_WEBHOOK.id) + expect(updated).not.toBeNull() + expect(updated!.secret).not.toBe(SEED_WEBHOOK.secret) + expect(updated!.previousSecret).toBe(SEED_WEBHOOK.secret) + expect(updated!.previousSecretExpiresAt).toBeTruthy() + expect(updated!.secretRotatedAt).toBeTruthy() + }) + + it('previousSecretExpiresAt is ~24 h in the future', async () => { + const before = Date.now() + const { body } = await request( + app, + 'POST', + `${BASE}/${SEED_WEBHOOK.id}/rotate-secret`, + { headers: { Authorization: ADMIN_BEARER } }, + ) + + const { previousSecretExpiresAt } = (body as { data: { previousSecretExpiresAt: string } }).data + const expiresMs = new Date(previousSecretExpiresAt).getTime() + const expectedMin = before + 23 * 60 * 60 * 1000 + const expectedMax = before + 25 * 60 * 60 * 1000 + expect(expiresMs).toBeGreaterThan(expectedMin) + expect(expiresMs).toBeLessThan(expectedMax) + }) + + it('writes an audit log entry on success', async () => { + await request(app, 'POST', `${BASE}/${SEED_WEBHOOK.id}/rotate-secret`, { + headers: { Authorization: ADMIN_BEARER }, + }) + + const { logs } = audit.getLogs() + expect(logs).toHaveLength(1) + expect(logs[0].action).toBe('ROTATE_WEBHOOK_SECRET') + expect(logs[0].status).toBe('success') + expect(logs[0].targetUserId).toBe(SEED_WEBHOOK.id) + }) + + it('two consecutive rotations produce different secrets', async () => { + const { body: b1 } = await request( + app, + 'POST', + `${BASE}/${SEED_WEBHOOK.id}/rotate-secret`, + { headers: { Authorization: ADMIN_BEARER } }, + ) + const { body: b2 } = await request( + app, + 'POST', + `${BASE}/${SEED_WEBHOOK.id}/rotate-secret`, + { headers: { Authorization: ADMIN_BEARER } }, + ) + + const secret1 = (b1 as { data: { newSecret: string } }).data.newSecret + const secret2 = (b2 as { data: { newSecret: string } }).data.newSecret + expect(secret1).not.toBe(secret2) + }) + + it('returns 404 when webhook does not exist', async () => { + const { status, body } = await request( + app, + 'POST', + `${BASE}/nonexistent-webhook/rotate-secret`, + { headers: { Authorization: ADMIN_BEARER } }, + ) + + expect(status).toBe(404) + expect((body as { error: string }).error).toBe('NotFound') + }) + + it('writes a failure audit log entry when webhook is not found', async () => { + await request(app, 'POST', `${BASE}/nonexistent-webhook/rotate-secret`, { + headers: { Authorization: ADMIN_BEARER }, + }) + + const { logs } = audit.getLogs() + expect(logs).toHaveLength(1) + expect(logs[0].action).toBe('ROTATE_WEBHOOK_SECRET') + expect(logs[0].status).toBe('failure') + }) + + it('returns 401 when no Authorization header is provided', async () => { + const { status, body } = await request( + app, + 'POST', + `${BASE}/${SEED_WEBHOOK.id}/rotate-secret`, + ) + + expect(status).toBe(401) + expect((body as { error: string }).error).toBe('Unauthorized') + }) + + it('returns 401 for an invalid Bearer token', async () => { + const { status, body } = await request( + app, + 'POST', + `${BASE}/${SEED_WEBHOOK.id}/rotate-secret`, + { headers: { Authorization: 'Bearer bogus-token' } }, + ) + + expect(status).toBe(401) + expect((body as { error: string }).error).toBe('Unauthorized') + }) + + it('returns 403 when caller has verifier role (not admin)', async () => { + const { status, body } = await request( + app, + 'POST', + `${BASE}/${SEED_WEBHOOK.id}/rotate-secret`, + { headers: { Authorization: VERIFIER_BEARER } }, + ) + + expect(status).toBe(403) + expect((body as { error: string }).error).toBe('Forbidden') + }) + }) +})