From 49767b753c767f58657cb654e09f1a657c2b6761 Mon Sep 17 00:00:00 2001 From: Ignacio Dobronich Date: Wed, 10 Jun 2026 17:41:32 -0300 Subject: [PATCH 1/2] feat: orb webhook secret alt --- apps/node-fastify/.env.sample | 3 +++ apps/node-fastify/src/app.ts | 1 + apps/node-fastify/src/utils/config.ts | 4 ++++ packages/orb-sync-lib/src/orb-sync.ts | 15 ++++++++++++++- 4 files changed, 22 insertions(+), 1 deletion(-) diff --git a/apps/node-fastify/.env.sample b/apps/node-fastify/.env.sample index f16cbcc..a010ba7 100644 --- a/apps/node-fastify/.env.sample +++ b/apps/node-fastify/.env.sample @@ -16,6 +16,9 @@ ORB_API_KEY=test_ # Alternative API key for easier key rotation #API_KEY_SYNC_ALT= +# Alternative webhook secret for easier of ORB_WEBHOOK_SECRET +#ORB_WEBHOOK_SECRET_ALT= + # Optional PORT=8080 diff --git a/apps/node-fastify/src/app.ts b/apps/node-fastify/src/app.ts index 2404e85..5c51101 100644 --- a/apps/node-fastify/src/app.ts +++ b/apps/node-fastify/src/app.ts @@ -96,6 +96,7 @@ export async function createApp( new OrbSync({ databaseUrl: config.DATABASE_URL, orbWebhookSecret: config.ORB_WEBHOOK_SECRET, + orbWebhookSecretAlt: config.ORB_WEBHOOK_SECRET_ALT, databaseSchema: config.DATABASE_SCHEMA, orbApiKey: config.ORB_API_KEY, verifyWebhookSignature: config.VERIFY_WEBHOOK_SIGNATURE, diff --git a/apps/node-fastify/src/utils/config.ts b/apps/node-fastify/src/utils/config.ts index c2a8bbb..70ce3f6 100644 --- a/apps/node-fastify/src/utils/config.ts +++ b/apps/node-fastify/src/utils/config.ts @@ -17,6 +17,9 @@ type configType = { /** Secret to validate signatures of Orb webhooks */ ORB_WEBHOOK_SECRET: string; + /** Alternative webhook secret for easier rotation */ + ORB_WEBHOOK_SECRET_ALT?: string; + /** Defaults to Orb */ DATABASE_SCHEMA: string; @@ -54,6 +57,7 @@ export function getConfig(): configType { DATABASE_SCHEMA: getConfigFromEnv('DATABASE_SCHEMA', 'orb'), DATABASE_URL: getConfigFromEnv('DATABASE_URL'), ORB_WEBHOOK_SECRET: getConfigFromEnv('ORB_WEBHOOK_SECRET'), + ORB_WEBHOOK_SECRET_ALT: getConfigFromEnv('ORB_WEBHOOK_SECRET_ALT'), PORT: Number(getConfigFromEnv('PORT', '8080')), VERIFY_WEBHOOK_SIGNATURE: getConfigFromEnv('VERIFY_WEBHOOK_SIGNATURE', 'true') === 'true', SENTRY_DSN: getConfigFromEnv('SENTRY_DSN'), diff --git a/packages/orb-sync-lib/src/orb-sync.ts b/packages/orb-sync-lib/src/orb-sync.ts index a63a53d..ac9936c 100644 --- a/packages/orb-sync-lib/src/orb-sync.ts +++ b/packages/orb-sync-lib/src/orb-sync.ts @@ -44,6 +44,11 @@ export type OrbSyncConfig = { /** Needed to verify the signature of a webhook */ orbWebhookSecret: string; + /** Alternative webhook secret for zero-downtime rotation. When set, webhook + * signature verification will try the primary secret first and fall back to + * this one before rejecting the request. */ + orbWebhookSecretAlt?: string; + /** Control whether webhook signatures should be verified. Defaults to true */ verifyWebhookSignature?: boolean; @@ -96,7 +101,15 @@ export class OrbSync { async processWebhook(payload: string, headers: HeadersLike | undefined) { if (this.config.verifyWebhookSignature ?? true) { - this.orb.webhooks.verifySignature(payload, headers || {}, this.config.orbWebhookSecret); + try { + this.orb.webhooks.verifySignature(payload, headers || {}, this.config.orbWebhookSecret); + } catch (e) { + if (this.config.orbWebhookSecretAlt) { + this.orb.webhooks.verifySignature(payload, headers || {}, this.config.orbWebhookSecretAlt); + } else { + throw e; + } + } } const now = new Date().getTime(); From 2ec6a5b604c794a884d4e807a7516706417584b1 Mon Sep 17 00:00:00 2001 From: Ignacio Dobronich Date: Thu, 11 Jun 2026 09:45:41 -0300 Subject: [PATCH 2/2] Integration test --- .../src/test/webhook-signature.test.ts | 130 ++++++++++++++++++ 1 file changed, 130 insertions(+) create mode 100644 apps/node-fastify/src/test/webhook-signature.test.ts diff --git a/apps/node-fastify/src/test/webhook-signature.test.ts b/apps/node-fastify/src/test/webhook-signature.test.ts new file mode 100644 index 0000000..10366db --- /dev/null +++ b/apps/node-fastify/src/test/webhook-signature.test.ts @@ -0,0 +1,130 @@ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { FastifyInstance } from 'fastify'; +import { createHmac } from 'node:crypto'; +import pino from 'pino'; +import { OrbSync } from 'orb-sync-lib'; +import { createApp } from '../app'; + +const PRIMARY_SECRET = 'test-webhook-secret-primary'; +const ALT_SECRET = 'test-webhook-secret-alt'; +const WRONG_SECRET = 'test-webhook-secret-wrong'; + +const TEST_PAYLOAD = JSON.stringify({ + id: 'test-event-id', + created_at: new Date().toISOString(), + type: 'resource_event.test', + properties: {}, +}); + +/** + * Sign a payload the same way Orb does: + * HMAC-SHA256 of "v1:{timestamp}:{payload}" → "v1={hex}" + */ +function signPayload(payload: string, secret: string): { signature: string; timestamp: string } { + const timestamp = new Date().toISOString(); + const toSign = `v1:${timestamp}:${payload}`; + const hmac = createHmac('sha256', Buffer.from(secret, 'utf-8')); + hmac.update(new TextEncoder().encode(toSign)); + const signature = `v1=${hmac.digest('hex')}`; + return { signature, timestamp }; +} + +function createSignedRequest(payload: string, secret: string) { + const { signature, timestamp } = signPayload(payload, secret); + return { + method: 'POST' as const, + url: '/webhooks', + headers: { + 'Content-Type': 'application/json', + 'X-Orb-Signature': signature, + 'X-Orb-Timestamp': timestamp, + }, + payload, + }; +} + +describe('Webhook signature verification with dual secrets', () => { + let app: FastifyInstance; + + beforeAll(async () => { + const logger = pino({ level: 'silent' }); + + const orbSync = new OrbSync({ + databaseUrl: process.env.DATABASE_URL!, + orbWebhookSecret: PRIMARY_SECRET, + orbWebhookSecretAlt: ALT_SECRET, + verifyWebhookSignature: true, + logger, + }); + + app = await createApp( + { + loggerInstance: logger, + disableRequestLogging: true, + requestIdHeader: 'Request-Id', + }, + logger, + orbSync + ); + }); + + afterAll(async () => { + if (app) { + await app.close(); + } + }); + + it('should accept a webhook signed with the primary secret', async () => { + const response = await app.inject(createSignedRequest(TEST_PAYLOAD, PRIMARY_SECRET)); + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ received: true }); + }); + + it('should accept a webhook signed with the alt secret', async () => { + const response = await app.inject(createSignedRequest(TEST_PAYLOAD, ALT_SECRET)); + expect(response.statusCode).toBe(200); + expect(response.json()).toMatchObject({ received: true }); + }); + + it('should reject a webhook signed with an unknown secret', async () => { + const response = await app.inject(createSignedRequest(TEST_PAYLOAD, WRONG_SECRET)); + expect(response.statusCode).toBe(500); + }); +}); + +describe('Webhook signature verification without alt secret', () => { + it('should reject when signed with a different secret and no alt is configured', async () => { + const orbSync = new OrbSync({ + databaseUrl: process.env.DATABASE_URL!, + orbWebhookSecret: PRIMARY_SECRET, + verifyWebhookSignature: true, + logger: pino({ level: 'silent' }), + }); + + const { signature, timestamp } = signPayload(TEST_PAYLOAD, ALT_SECRET); + const headers = { + 'X-Orb-Signature': signature, + 'X-Orb-Timestamp': timestamp, + }; + + await expect(orbSync.processWebhook(TEST_PAYLOAD, headers)).rejects.toThrow(); + }); + + it('should accept when signed with the primary secret and no alt is configured', async () => { + const orbSync = new OrbSync({ + databaseUrl: process.env.DATABASE_URL!, + orbWebhookSecret: PRIMARY_SECRET, + verifyWebhookSignature: true, + logger: pino({ level: 'silent' }), + }); + + const { signature, timestamp } = signPayload(TEST_PAYLOAD, PRIMARY_SECRET); + const headers = { + 'X-Orb-Signature': signature, + 'X-Orb-Timestamp': timestamp, + }; + + // processWebhook should not throw, signature is valid + await expect(orbSync.processWebhook(TEST_PAYLOAD, headers)).resolves.not.toThrow(); + }); +});