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
3 changes: 3 additions & 0 deletions apps/node-fastify/.env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions apps/node-fastify/src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
130 changes: 130 additions & 0 deletions apps/node-fastify/src/test/webhook-signature.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
4 changes: 4 additions & 0 deletions apps/node-fastify/src/utils/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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'),
Expand Down
15 changes: 14 additions & 1 deletion packages/orb-sync-lib/src/orb-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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();
Expand Down