Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \
Expand Down
10 changes: 10 additions & 0 deletions src/workos/config-validator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
}
});
}
}
Expand Down
8 changes: 7 additions & 1 deletion src/workos/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down
158 changes: 158 additions & 0 deletions src/workos/seed-webhook-secret.spec.ts
Original file line number Diff line number Diff line change
@@ -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<void>;
}

/** Capture the raw body and signature header, which is what a verifying consumer reads. */
function startWebhookReceiver(): Promise<WebhookReceiver> {
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<void> {
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');
});
});
Loading