diff --git a/README.md b/README.md index b9fc59b..523316a 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,24 @@ Use it to parse and validate [Seam webhook events](https://docs.seam.co/latest/d Refer to the [Svix docs on Consuming Webhooks](https://docs.svix.com/receiving/introduction) for an in-depth guide on best-practices for handling webhooks in your application. +Verification failures throw Svix's `WebhookVerificationError`, re-exported as +`SeamWebhookVerificationError`. +A payload that is correctly signed but unreadable throws a `SeamInvalidWebhookPayloadError`. + +```js +import { isSeamInvalidWebhookPayloadError, SeamWebhook } from '@seamapi/webhook' + +try { + data = webhook.verify(req.body, req.headers) +} catch (err) { + if (isSeamInvalidWebhookPayloadError(err)) { + console.error('Unreadable Seam webhook payload', err) + return res.status(204).send() + } + return res.status(400).send() +} +``` + > [!TIP] > This example is for [Express](https://expressjs.com/), > see the [Svix docs for more examples in specific frameworks](https://docs.svix.com/receiving/verifying-payloads/how). diff --git a/src/lib/index.ts b/src/lib/index.ts index bc7b291..0d3082a 100644 --- a/src/lib/index.ts +++ b/src/lib/index.ts @@ -1,2 +1,3 @@ +export * from './seam-invalid-webhook-payload-error.js' export * from './seam-webhook.js' export { WebhookVerificationError as SeamWebhookVerificationError } from 'svix' diff --git a/src/lib/seam-invalid-webhook-payload-error.ts b/src/lib/seam-invalid-webhook-payload-error.ts new file mode 100644 index 0000000..742b6b4 --- /dev/null +++ b/src/lib/seam-invalid-webhook-payload-error.ts @@ -0,0 +1,19 @@ +/** + * Thrown when a webhook payload passes signature verification + * but cannot be read as a Seam event. + * + * Verification has already succeeded by this point, so the payload is + * genuinely from Seam and will never become readable. Report it as a bug + * instead of letting the sender retry it, and do not treat it as forgery. + */ +export class SeamInvalidWebhookPayloadError extends Error { + constructor(message: string, options?: ErrorOptions) { + super(message, options) + this.name = 'SeamInvalidWebhookPayloadError' + } +} + +export const isSeamInvalidWebhookPayloadError = ( + error: unknown, +): error is SeamInvalidWebhookPayloadError => + error instanceof SeamInvalidWebhookPayloadError diff --git a/src/lib/seam-webhook.test.ts b/src/lib/seam-webhook.test.ts index 6092b3f..5d9de2b 100644 --- a/src/lib/seam-webhook.test.ts +++ b/src/lib/seam-webhook.test.ts @@ -1,7 +1,145 @@ import test from 'ava' +import { Webhook } from 'svix' +import { + isSeamInvalidWebhookPayloadError, + SeamInvalidWebhookPayloadError, +} from './seam-invalid-webhook-payload-error.js' import { SeamWebhook } from './seam-webhook.js' +const secret = 'MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw' + +const eventPayload = JSON.stringify({ + event_id: '11111111-1111-1111-1111-111111111111', + event_type: 'device.connected', + workspace_id: '22222222-2222-2222-2222-222222222222', + device_id: '33333333-3333-3333-3333-333333333333', + created_at: '2026-08-27T00:00:00.000Z', + occurred_at: '2026-08-27T00:00:00.000Z', +}) + +const signHeaders = ( + payload: string, + { msgId = 'msg_1', timestamp = new Date(), signingSecret = secret } = {}, +): Record => ({ + 'svix-id': msgId, + 'svix-timestamp': Math.floor(timestamp.getTime() / 1000).toString(), + 'svix-signature': new Webhook(signingSecret).sign(msgId, timestamp, payload), +}) + test('SeamWebhook: constructor', (t) => { t.truthy(new SeamWebhook('1234')) }) + +test('SeamWebhook: verifies and parses a signed event', (t) => { + const webhook = new SeamWebhook(secret) + + const event = webhook.verify(eventPayload, signHeaders(eventPayload)) + + t.is(event.event_type, 'device.connected') + t.is(event.event_id, '11111111-1111-1111-1111-111111111111') +}) + +test('SeamWebhook: accepts mixed case headers', (t) => { + const webhook = new SeamWebhook(secret) + const headers = Object.fromEntries( + Object.entries(signHeaders(eventPayload)).map(([key, value]) => [ + key.toUpperCase(), + value, + ]), + ) + + t.is(webhook.verify(eventPayload, headers).event_type, 'device.connected') +}) + +test('SeamWebhook: a tampered payload fails verification', (t) => { + const webhook = new SeamWebhook(secret) + const headers = signHeaders(eventPayload) + const tampered = eventPayload.replace( + 'device.connected', + 'device.disconnected', + ) + + const error = t.throws(() => webhook.verify(tampered, headers)) + + t.false(isSeamInvalidWebhookPayloadError(error)) + t.regex(error?.message ?? '', /No matching signature/) +}) + +test('SeamWebhook: a wrong secret fails verification', (t) => { + const webhook = new SeamWebhook(secret) + const headers = signHeaders(eventPayload, { + signingSecret: 'WrongQ9r8GKYqrTwjUPD8ILPZIo2LaLa', + }) + + const error = t.throws(() => webhook.verify(eventPayload, headers)) + + t.regex(error?.message ?? '', /No matching signature/) +}) + +test('SeamWebhook: an expired timestamp fails verification', (t) => { + const webhook = new SeamWebhook(secret) + const headers = signHeaders(eventPayload, { + timestamp: new Date(Date.now() - 60 * 60 * 1000), + }) + + const error = t.throws(() => webhook.verify(eventPayload, headers)) + + t.regex(error?.message ?? '', /too old/) +}) + +for (const missing of ['svix-id', 'svix-timestamp', 'svix-signature']) { + test(`SeamWebhook: a missing ${missing} header fails verification`, (t) => { + const webhook = new SeamWebhook(secret) + const { [missing]: _, ...headers } = signHeaders(eventPayload) + + const error = t.throws(() => webhook.verify(eventPayload, headers)) + + t.false(isSeamInvalidWebhookPayloadError(error)) + t.regex(error?.message ?? '', /Missing required headers/) + }) +} + +test('SeamWebhook: a signed but unparseable payload is not forgery', (t) => { + const webhook = new SeamWebhook(secret) + const payload = '{"event_id": "trailing-comma",}' + + const error = t.throws(() => webhook.verify(payload, signHeaders(payload)), { + instanceOf: SeamInvalidWebhookPayloadError, + }) + + t.regex(error?.message ?? '', /not valid JSON/) + t.truthy(error?.cause) +}) + +for (const payload of ['null', '[1]', '42', '"event"', '{}']) { + test(`SeamWebhook: a signed non-event payload ${payload} is not forgery`, (t) => { + const webhook = new SeamWebhook(secret) + + const error = t.throws( + () => webhook.verify(payload, signHeaders(payload)), + { + instanceOf: SeamInvalidWebhookPayloadError, + }, + ) + + t.regex(error?.message ?? '', /did not contain a Seam event/) + }) +} + +test('SeamWebhook: an unknown event type still parses', (t) => { + const webhook = new SeamWebhook(secret) + const payload = JSON.stringify({ + event_id: '11111111-1111-1111-1111-111111111111', + event_type: 'future.event_type', + }) + + // event_type is a closed union of known types, so widen it to assert + // that an unrecognized type still round-trips. + const event: { event_type?: string | undefined } = webhook.verify( + payload, + signHeaders(payload), + ) + + t.is(event.event_type, 'future.event_type') +}) diff --git a/src/lib/seam-webhook.ts b/src/lib/seam-webhook.ts index c793463..12ac10c 100644 --- a/src/lib/seam-webhook.ts +++ b/src/lib/seam-webhook.ts @@ -1,5 +1,7 @@ import type { SeamEvent } from '@seamapi/http/connect' -import { Webhook } from 'svix' +import { Webhook, WebhookVerificationError } from 'svix' + +import { SeamInvalidWebhookPayloadError } from './seam-invalid-webhook-payload-error.js' export class SeamWebhook { readonly #webhook: Webhook @@ -8,11 +10,48 @@ export class SeamWebhook { this.#webhook = new Webhook(secret) } + /** + * Verifies an incoming webhook request and returns the event it carries. + * + * @throws {WebhookVerificationError} When the signature does not match. + * @throws {SeamInvalidWebhookPayloadError} When the signature matches + * but the body is not a Seam event. + */ verify(payload: string, headers: Record): SeamEvent { const normalizedHeaders = Object.fromEntries( Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]), ) - return this.#webhook.verify(payload, normalizedHeaders) as SeamEvent + let data: unknown + + try { + data = this.#webhook.verify(payload, normalizedHeaders) + } catch (error) { + if (error instanceof WebhookVerificationError) throw error + + throw new SeamInvalidWebhookPayloadError( + `The verified webhook payload is not valid JSON: ${getErrorMessage(error)}`, + { cause: error }, + ) + } + + if (!isSeamEvent(data)) { + throw new SeamInvalidWebhookPayloadError( + 'The verified webhook payload did not contain a Seam event', + ) + } + + return data } } + +const isSeamEvent = (data: unknown): data is SeamEvent => + typeof data === 'object' && + data !== null && + 'event_id' in data && + typeof data.event_id === 'string' && + 'event_type' in data && + typeof data.event_type === 'string' + +const getErrorMessage = (error: unknown): string => + error instanceof Error ? error.message : String(error)