diff --git a/README.md b/README.md index 9bb5921..cfe2e26 100644 --- a/README.md +++ b/README.md @@ -504,10 +504,14 @@ Seed it (an empty `events` list subscribes to everything): ```yaml webhookEndpoints: - endpoint_url: http://localhost:5005/webhooks + secret: whsec_test # optional; generated if omitted events: [] ``` -Or register at runtime and choose your own signing secret: +Pin `secret` when your consumer verifies signatures: the API masks an endpoint's secret after +creation, so a generated one can't be recovered for the consumer's environment. + +Or register at runtime: ```bash curl -X POST http://localhost:4100/webhook_endpoints \ diff --git a/src/workos/config-validator.ts b/src/workos/config-validator.ts index 93dedd9..822cdae 100644 --- a/src/workos/config-validator.ts +++ b/src/workos/config-validator.ts @@ -620,6 +620,16 @@ export function validateSeedConfig(config: WorkOSSeedConfig): ConfigValidationRe value: endpoint.events, }); } + // The secret keys every signature the endpoint will ever receive; a non-string here + // (only YAML or JSON could write one) would sign deliveries with a coerced value no + // consumer's verifier was configured with. + if (endpoint.secret !== undefined && (typeof endpoint.secret !== 'string' || !endpoint.secret)) { + errors.push({ + path: `webhookEndpoints[${index}].secret`, + message: 'secret must be a non-empty string if provided', + value: endpoint.secret, + }); + } }); } } diff --git a/src/workos/index.ts b/src/workos/index.ts index 5467e34..b29c910 100644 --- a/src/workos/index.ts +++ b/src/workos/index.ts @@ -231,6 +231,12 @@ export interface WorkOSSeedWebhookEndpoint { url?: string; events?: string[]; enabled?: boolean; + /** + * Pinned signing secret. Generated if omitted. Pin it to bake the secret into a + * consumer's environment, so it can verify `WorkOS-Signature` headers on seeded + * deliveries without first discovering the secret via `GET /webhook_endpoints`. + */ + secret?: string; } export interface WorkOSSeedConnectApplication { @@ -590,7 +596,7 @@ export function seedFromConfig(store: Store, _baseUrl: string, config: WorkOSSee ws.webhookEndpoints.insert({ object: 'webhook_endpoint', endpoint_url: endpointUrl, - secret: randomBytes(32).toString('hex'), + secret: whConfig.secret ?? randomBytes(32).toString('hex'), enabled: whConfig.enabled !== false, events: whConfig.events ?? [], description: null, diff --git a/src/workos/seed-webhook-secret.spec.ts b/src/workos/seed-webhook-secret.spec.ts new file mode 100644 index 0000000..299c2c6 --- /dev/null +++ b/src/workos/seed-webhook-secret.spec.ts @@ -0,0 +1,158 @@ +/** + * Pinning a webhook endpoint's signing secret in a seed file. A consumer that verifies + * `WorkOS-Signature` headers needs the secret in its environment before anything talks to + * the emulator, and the API masks the secret after creation (`abc12345****`), so a generated + * one is unrecoverable — like `apiKeys[].value` and `connectApplications[].client_secret`, + * the seed must be able to declare it. These tests prove a pinned secret is what actually + * signs deliveries, and that a seed without one still gets a generated secret. + */ +import { describe, it, expect, afterEach } from 'bun:test'; +import { createServer, type Server } from 'node:http'; +import { createHmac } from 'node:crypto'; +import { createEmulator, type Emulator } from '../index.js'; +import { validateSeedConfig } from './config-validator.js'; + +interface ReceivedWebhook { + signature: string; + rawBody: string; + event: string; +} + +interface WebhookReceiver { + url: string; + received: ReceivedWebhook[]; + close: () => Promise; +} + +/** Capture the raw body and signature header, which is what a verifying consumer reads. */ +function startWebhookReceiver(): Promise { + const received: ReceivedWebhook[] = []; + const server: Server = createServer((req, res) => { + let rawBody = ''; + req.on('data', (chunk) => (rawBody += chunk)); + req.on('end', () => { + received.push({ + signature: (req.headers['workos-signature'] as string) ?? '', + rawBody, + event: JSON.parse(rawBody).event, + }); + res.writeHead(200).end(); + }); + }); + return new Promise((resolve) => { + server.listen(0, '127.0.0.1', () => { + const addr = server.address(); + const port = typeof addr === 'object' && addr ? addr.port : 0; + resolve({ + url: `http://127.0.0.1:${port}/webhooks`, + received, + close: () => new Promise((res, rej) => server.close((err) => (err ? rej(err) : res()))), + }); + }); + }); +} + +/** + * Poll until a predicate over received webhooks holds, or a timeout elapses. Webhook + * delivery is fire-and-forget, so a fixed sleep races delivery on a slow runner. + */ +async function waitForWebhooks( + receiver: WebhookReceiver, + predicate: (received: ReceivedWebhook[]) => boolean, + timeoutMs = 2000, +): Promise { + const start = Date.now(); + while (Date.now() - start < timeoutMs) { + if (predicate(receiver.received)) return; + await new Promise((resolve) => setTimeout(resolve, 25)); + } +} + +/** Verify the way the SDKs' `webhooks.constructEvent` does: HMAC-SHA256 over `{t}.{body}`. */ +function verifySignature(delivery: ReceivedWebhook, secret: string): boolean { + const match = delivery.signature.match(/^t=(\d+), v1=([0-9a-f]+)$/); + if (!match) return false; + const expected = createHmac('sha256', secret).update(`${match[1]}.${delivery.rawBody}`).digest('hex'); + return match[2] === expected; +} + +const PINNED_SECRET = 'whsec_seeded_for_tests'; + +describe('Seeding a pinned webhook signing secret', () => { + let emulator: Emulator | undefined; + let receiver: WebhookReceiver | undefined; + + afterEach(async () => { + await emulator?.close(); + await receiver?.close(); + emulator = undefined; + receiver = undefined; + }); + + const auth = (apiKey: string) => ({ Authorization: `Bearer ${apiKey}`, 'Content-Type': 'application/json' }); + + it('signs deliveries with the pinned secret', async () => { + receiver = await startWebhookReceiver(); + emulator = await createEmulator({ + port: 0, + seed: { + webhookEndpoints: [{ endpoint_url: receiver.url, secret: PINNED_SECRET, events: [] }], + }, + }); + + const res = await fetch(`${emulator.url}/user_management/users`, { + method: 'POST', + headers: auth(emulator.apiKey), + body: JSON.stringify({ email: 'verified-consumer@acme.com' }), + }); + expect(res.status).toBe(201); + + await waitForWebhooks(receiver, (r) => r.some((d) => d.event === 'user.created')); + const delivery = receiver.received.find((d) => d.event === 'user.created'); + expect(delivery).toBeDefined(); + expect(verifySignature(delivery!, PINNED_SECRET)).toBe(true); + }); + + it('still generates a secret when the seed omits one', async () => { + receiver = await startWebhookReceiver(); + emulator = await createEmulator({ + port: 0, + seed: { + webhookEndpoints: [{ endpoint_url: receiver.url, events: [] }], + }, + }); + + const res = await fetch(`${emulator.url}/user_management/users`, { + method: 'POST', + headers: auth(emulator.apiKey), + body: JSON.stringify({ email: 'generated-secret@acme.com' }), + }); + expect(res.status).toBe(201); + + await waitForWebhooks(receiver, (r) => r.some((d) => d.event === 'user.created')); + const delivery = receiver.received.find((d) => d.event === 'user.created'); + expect(delivery).toBeDefined(); + // Signed, but not with the pinned value — a generated secret still signs every delivery. + expect(delivery!.signature).toMatch(/^t=\d+, v1=[0-9a-f]{64}$/); + expect(verifySignature(delivery!, PINNED_SECRET)).toBe(false); + }); + + it('accepts a string secret and rejects anything else', () => { + const ok = validateSeedConfig({ + webhookEndpoints: [{ endpoint_url: 'http://localhost:5005/webhooks', secret: PINNED_SECRET }], + }); + expect(ok.valid).toBe(true); + + const bad = validateSeedConfig({ + webhookEndpoints: [{ endpoint_url: 'http://localhost:5005/webhooks', secret: 12345 as unknown as string }], + }); + expect(bad.valid).toBe(false); + expect(bad.errors[0].path).toBe('webhookEndpoints[0].secret'); + + const empty = validateSeedConfig({ + webhookEndpoints: [{ endpoint_url: 'http://localhost:5005/webhooks', secret: '' }], + }); + expect(empty.valid).toBe(false); + expect(empty.errors[0].path).toBe('webhookEndpoints[0].secret'); + }); +});