diff --git a/packages/trigger-sdk/src/v3/webhooks.test-fixtures.ts b/packages/trigger-sdk/src/v3/webhooks.test-fixtures.ts new file mode 100644 index 00000000000..049e915e273 --- /dev/null +++ b/packages/trigger-sdk/src/v3/webhooks.test-fixtures.ts @@ -0,0 +1,103 @@ +import { subtle } from "../imports/uncrypto.js"; + +/** + * Test fixtures for {@link standardWebhooks.verify}. + * + * The secret is 32 raw bytes encoded as base64 to match the format + * Standard Webhooks providers hand out. `sign()` mirrors the on-the-wire + * format exactly: `v1,`. + */ + +export const TEST_SECRET_BYTES = Buffer.from( + "d2hzZWNfdGVzdF9zdXBlcl9zZWNyZXRfa2V5X2Zvcl9zdGFuZGFyZF93ZWJob29rcw==", + "base64" +); + +export const TEST_SECRET_BASE64 = TEST_SECRET_BYTES.toString("base64"); + +export const TEST_BODY = JSON.stringify({ + type: "order.created", + data: { id: "ord_123", amount: 4200 }, +}); + +export const TEST_ID = "msg_2YuG3w7H1n7R8zXK9mN4pQ"; + +/** + * Computes the `v1,` portion of the signature header for the + * given id / timestamp / body / secret bytes. Mirrors the algorithm in + * `verifyStandardWebhooks`. The timestamp is stringified exactly as + * received so that a request with a non-numeric timestamp can still be + * signed for negative tests. + */ +export async function signV1( + secretBytes: Uint8Array, + id: string, + timestamp: number | string, + body: string +): Promise { + const signedContent = `${id}.${timestamp}.${body}`; + const key = await subtle.importKey( + "raw", + // Wrap in a fresh Uint8Array so the generic is bound to ArrayBuffer + // rather than ArrayBufferLike; matches what Buffer.from(string, ...) + // infers and avoids an ArrayBufferLike/ArrayBuffer mismatch. + new Uint8Array(secretBytes), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const digest = await subtle.sign("HMAC", key, Buffer.from(signedContent, "utf-8")); + return `v1,${Buffer.from(digest).toString("base64")}`; +} + +export type BuildRequestOptions = { + id?: string; + timestamp?: number | string; + body?: string; + signatureHeader?: string; + /** If true, omit the given header. Useful for negative tests. */ + omit?: "id" | "timestamp" | "signature"; +}; + +export async function buildSignedRequest(opts: BuildRequestOptions = {}): Promise { + const id = opts.id ?? TEST_ID; + const timestamp = opts.timestamp ?? Math.floor(Date.now() / 1000); + const body = opts.body ?? TEST_BODY; + const signature = + opts.signatureHeader ?? (await signV1(TEST_SECRET_BYTES, id, Number(timestamp), body)); + + const headers = new Headers({ "content-type": "application/json" }); + if (opts.omit !== "id") headers.set("webhook-id", id); + if (opts.omit !== "timestamp") headers.set("webhook-timestamp", String(timestamp)); + if (opts.omit !== "signature") headers.set("webhook-signature", signature); + + return new Request("https://example.com/webhook", { + method: "POST", + headers, + body, + }); +} + +/** + * Convenience for tests that need to call {@link standardWebhooks.verify} + * twice on logically-equal inputs — `Request.body` is a single-use stream. + */ +export async function buildSignedRequestPair( + opts: BuildRequestOptions = {} +): Promise<[Request, Request]> { + return [await buildSignedRequest(opts), await buildSignedRequest(opts)]; +} + +/** + * Static, reviewable happy-path fixture. Captured at test-suite authoring + * time so the values can be diffed in code review. The timestamp is + * 1700000000 (2023-11-14), well outside the default 300s tolerance — tests + * that use this fixture must pass `tolerance: 0` or generate a current + * timestamp. + */ +export const STATIC_HAPPY_PATH = { + secretBase64: TEST_SECRET_BASE64, + id: "msg_static_review_fixture", + timestamp: 1700000000, + body: '{"hello":"world"}', +} as const; diff --git a/packages/trigger-sdk/src/v3/webhooks.test.ts b/packages/trigger-sdk/src/v3/webhooks.test.ts new file mode 100644 index 00000000000..03c45c6467a --- /dev/null +++ b/packages/trigger-sdk/src/v3/webhooks.test.ts @@ -0,0 +1,311 @@ +import { describe, expect, it } from "vitest"; +import { + STANDARD_WEBHOOKS_ID_HEADER_NAME, + STANDARD_WEBHOOKS_SIGNATURE_HEADER_NAME, + STANDARD_WEBHOOKS_SIGNATURE_VERSION, + STANDARD_WEBHOOKS_TIMESTAMP_HEADER_NAME, + STANDARD_WEBHOOKS_TOLERANCE_SECONDS, + standardWebhooks, + WebhookError, +} from "./webhooks.js"; +import { + STATIC_HAPPY_PATH, + TEST_BODY, + TEST_ID, + TEST_SECRET_BASE64, + TEST_SECRET_BYTES, + buildSignedRequest, + buildSignedRequestPair, + signV1, +} from "./webhooks.test-fixtures.js"; + +describe("standardWebhooks", () => { + describe("exports", () => { + it("exposes the spec header names", () => { + expect(STANDARD_WEBHOOKS_ID_HEADER_NAME).toBe("webhook-id"); + expect(STANDARD_WEBHOOKS_TIMESTAMP_HEADER_NAME).toBe("webhook-timestamp"); + expect(STANDARD_WEBHOOKS_SIGNATURE_HEADER_NAME).toBe("webhook-signature"); + expect(STANDARD_WEBHOOKS_SIGNATURE_VERSION).toBe("v1"); + expect(STANDARD_WEBHOOKS_TOLERANCE_SECONDS).toBe(300); + }); + + it("mirrors the header names and tolerance on the namespace object", () => { + expect(standardWebhooks.ID_HEADER_NAME).toBe(STANDARD_WEBHOOKS_ID_HEADER_NAME); + expect(standardWebhooks.TIMESTAMP_HEADER_NAME).toBe(STANDARD_WEBHOOKS_TIMESTAMP_HEADER_NAME); + expect(standardWebhooks.SIGNATURE_HEADER_NAME).toBe(STANDARD_WEBHOOKS_SIGNATURE_HEADER_NAME); + expect(standardWebhooks.TOLERANCE_SECONDS).toBe(STANDARD_WEBHOOKS_TOLERANCE_SECONDS); + }); + }); + + describe("happy path", () => { + it("verifies a freshly-signed request and returns { payload, raw }", async () => { + const request = await buildSignedRequest(); + const result = await standardWebhooks.verify(request, TEST_SECRET_BASE64); + + expect(result.raw).toBe(TEST_BODY); + expect(result.payload).toEqual({ + type: "order.created", + data: { id: "ord_123", amount: 4200 }, + }); + }); + + it("parses the body even when it contains nested arrays and unicode", async () => { + const body = JSON.stringify({ items: ["café", "日本語"], count: 2 }); + const request = await buildSignedRequest({ body }); + const result = await standardWebhooks.verify(request, TEST_SECRET_BASE64); + + expect(result.raw).toBe(body); + expect(result.payload).toEqual({ items: ["café", "日本語"], count: 2 }); + }); + + it("uses a frozen reviewable fixture when tolerance is disabled", async () => { + const signedContent = `${STATIC_HAPPY_PATH.id}.${STATIC_HAPPY_PATH.timestamp}.${STATIC_HAPPY_PATH.body}`; + const { createHmac } = await import("node:crypto"); + const sig = + "v1," + + createHmac("sha256", Buffer.from(STATIC_HAPPY_PATH.secretBase64, "base64")) + .update(signedContent) + .digest("base64"); + + const request = await buildSignedRequest({ + id: STATIC_HAPPY_PATH.id, + timestamp: STATIC_HAPPY_PATH.timestamp, + body: STATIC_HAPPY_PATH.body, + signatureHeader: sig, + }); + + const result = await standardWebhooks.verify(request, STATIC_HAPPY_PATH.secretBase64, { + tolerance: 0, + }); + expect(result.payload).toEqual({ hello: "world" }); + expect(result.raw).toBe(STATIC_HAPPY_PATH.body); + }); + }); + + describe("multi-signature support", () => { + it("accepts a request whose first signature entry matches", async () => { + const now = Math.floor(Date.now() / 1000); + const real = await signV1(TEST_SECRET_BYTES, TEST_ID, now, TEST_BODY); + const fake = "v1," + Buffer.from("not-a-real-signature-but-valid-base64").toString("base64"); + const request = await buildSignedRequest({ + timestamp: now, + signatureHeader: `${real} ${fake}`, + }); + + const result = await standardWebhooks.verify(request, TEST_SECRET_BASE64); + expect(result.payload).toEqual({ + type: "order.created", + data: { id: "ord_123", amount: 4200 }, + }); + }); + + it("accepts a request whose second signature entry matches", async () => { + const now = Math.floor(Date.now() / 1000); + const real = await signV1(TEST_SECRET_BYTES, TEST_ID, now, TEST_BODY); + const fake = "v1," + Buffer.from("not-a-real-signature-but-valid-base64").toString("base64"); + const request = await buildSignedRequest({ + timestamp: now, + signatureHeader: `${fake} ${real}`, + }); + + const result = await standardWebhooks.verify(request, TEST_SECRET_BASE64); + expect(result.payload).toEqual({ + type: "order.created", + data: { id: "ord_123", amount: 4200 }, + }); + }); + + it("accepts version variants like v1a and v1b for the same key", async () => { + const now = Math.floor(Date.now() / 1000); + const sig = await signV1(TEST_SECRET_BYTES, TEST_ID, now, TEST_BODY); + const base64 = sig.slice("v1,".length); + const request = await buildSignedRequest({ + timestamp: now, + signatureHeader: `v1a,${base64} v1b,${base64}`, + }); + + const result = await standardWebhooks.verify(request, TEST_SECRET_BASE64); + expect(result.payload).toEqual({ + type: "order.created", + data: { id: "ord_123", amount: 4200 }, + }); + }); + + it("rejects when none of the signature entries match", async () => { + const bogus = + "v1," + Buffer.from("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=").toString("base64"); + const request = await buildSignedRequest({ signatureHeader: bogus }); + + await expect(standardWebhooks.verify(request, TEST_SECRET_BASE64)).rejects.toThrow( + new WebhookError("invalid signature") + ); + }); + }); + + describe("signature verification", () => { + it("rejects a signature computed with the wrong secret", async () => { + const wrongSecretBytes = Buffer.alloc(32); + const now = Math.floor(Date.now() / 1000); + const sig = await signV1(wrongSecretBytes, TEST_ID, now, TEST_BODY); + const request = await buildSignedRequest({ timestamp: now, signatureHeader: sig }); + + await expect(standardWebhooks.verify(request, TEST_SECRET_BASE64)).rejects.toThrow( + new WebhookError("invalid signature") + ); + }); + + it("rejects when the body has been tampered with after signing", async () => { + const now = Math.floor(Date.now() / 1000); + const sig = await signV1(TEST_SECRET_BYTES, TEST_ID, now, TEST_BODY); + const tampered = await buildSignedRequest({ + timestamp: now, + signatureHeader: sig, + body: TEST_BODY.replace("4200", "9999"), + }); + + await expect(standardWebhooks.verify(tampered, TEST_SECRET_BASE64)).rejects.toThrow( + new WebhookError("invalid signature") + ); + }); + }); + + describe("secret handling", () => { + it("rejects an empty secret as 'invalid secret'", async () => { + const request = await buildSignedRequest(); + await expect(standardWebhooks.verify(request, "")).rejects.toThrow( + new WebhookError("invalid secret") + ); + }); + + it("wraps crypto failures as WebhookError when the secret is malformed", async () => { + const request = await buildSignedRequest(); + // Not a real base64 secret but not empty either — subtle.importKey should + // still reject it because the byte length isn't a valid HMAC secret. + await expect( + standardWebhooks.verify(request, Buffer.alloc(0).toString("base64")) + ).rejects.toThrow(WebhookError); + }); + }); + + describe("header validation", () => { + it("throws 'missing headers' when webhook-id is absent", async () => { + const request = await buildSignedRequest({ omit: "id" }); + await expect(standardWebhooks.verify(request, TEST_SECRET_BASE64)).rejects.toThrow( + new WebhookError("missing headers") + ); + }); + + it("throws 'missing headers' when webhook-timestamp is absent", async () => { + const request = await buildSignedRequest({ omit: "timestamp" }); + await expect(standardWebhooks.verify(request, TEST_SECRET_BASE64)).rejects.toThrow( + new WebhookError("missing headers") + ); + }); + + it("throws 'missing headers' when webhook-signature is absent", async () => { + const request = await buildSignedRequest({ omit: "signature" }); + await expect(standardWebhooks.verify(request, TEST_SECRET_BASE64)).rejects.toThrow( + new WebhookError("missing headers") + ); + }); + + it("throws 'unsupported signature version' when the only entries are v0", async () => { + const request = await buildSignedRequest({ + signatureHeader: "v0,abcd v0,efgh", + }); + await expect(standardWebhooks.verify(request, TEST_SECRET_BASE64)).rejects.toThrow( + new WebhookError("unsupported signature version") + ); + }); + + it("throws 'unsupported signature version' when there are no comma-prefixed entries", async () => { + const request = await buildSignedRequest({ signatureHeader: "garbage" }); + await expect(standardWebhooks.verify(request, TEST_SECRET_BASE64)).rejects.toThrow( + new WebhookError("unsupported signature version") + ); + }); + }); + + describe("tolerance", () => { + it("accepts a request whose timestamp is within the default 300s window", async () => { + const request = await buildSignedRequest(); + await expect(standardWebhooks.verify(request, TEST_SECRET_BASE64)).resolves.toBeDefined(); + }); + + it("rejects a request whose timestamp is older than the default window", async () => { + const oldTimestamp = Math.floor(Date.now() / 1000) - STANDARD_WEBHOOKS_TOLERANCE_SECONDS - 1; + const sig = await signV1(TEST_SECRET_BYTES, TEST_ID, oldTimestamp, TEST_BODY); + const request = await buildSignedRequest({ timestamp: oldTimestamp, signatureHeader: sig }); + + await expect(standardWebhooks.verify(request, TEST_SECRET_BASE64)).rejects.toThrow( + new WebhookError("timestamp outside tolerance window") + ); + }); + + it("honors an explicit tolerance option", async () => { + const fiveMinutesAgo = Math.floor(Date.now() / 1000) - 5 * 60; + const sig = await signV1(TEST_SECRET_BYTES, TEST_ID, fiveMinutesAgo, TEST_BODY); + const [requestA, requestB] = await buildSignedRequestPair({ + timestamp: fiveMinutesAgo, + signatureHeader: sig, + }); + + // 4 minutes < 5 minute age, so default 300s tolerance rejects. + await expect(standardWebhooks.verify(requestA, TEST_SECRET_BASE64)).rejects.toThrow( + new WebhookError("timestamp outside tolerance window") + ); + + // ...but raising the tolerance to 600s accepts it. + await expect( + standardWebhooks.verify(requestB, TEST_SECRET_BASE64, { tolerance: 600 }) + ).resolves.toBeDefined(); + }); + + it("disables the anti-replay check entirely when tolerance = 0", async () => { + const ancientTimestamp = 1700000000; // 2023-11-14 + const sig = await signV1(TEST_SECRET_BYTES, TEST_ID, ancientTimestamp, TEST_BODY); + const request = await buildSignedRequest({ + timestamp: ancientTimestamp, + signatureHeader: sig, + }); + + await expect( + standardWebhooks.verify(request, TEST_SECRET_BASE64, { tolerance: 0 }) + ).resolves.toBeDefined(); + }); + + it("rejects a non-numeric timestamp when tolerance is enabled", async () => { + const sig = await signV1(TEST_SECRET_BYTES, TEST_ID, "not-a-number", TEST_BODY); + const request = await buildSignedRequest({ + timestamp: "not-a-number", + signatureHeader: sig, + }); + await expect(standardWebhooks.verify(request, TEST_SECRET_BASE64)).rejects.toThrow( + new WebhookError("invalid timestamp") + ); + }); + }); + + describe("payload parsing", () => { + it("rejects a body that is not valid JSON", async () => { + const body = "this is not json"; + const now = Math.floor(Date.now() / 1000); + const sig = await signV1(TEST_SECRET_BYTES, TEST_ID, now, body); + const request = await buildSignedRequest({ timestamp: now, body, signatureHeader: sig }); + + await expect(standardWebhooks.verify(request, TEST_SECRET_BASE64)).rejects.toThrow( + /invalid payload/ + ); + }); + + it("preserves non-object payloads (arrays, primitives)", async () => { + const body = "[1,2,3]"; + const now = Math.floor(Date.now() / 1000); + const sig = await signV1(TEST_SECRET_BYTES, TEST_ID, now, body); + const request = await buildSignedRequest({ timestamp: now, body, signatureHeader: sig }); + + const result = await standardWebhooks.verify(request, TEST_SECRET_BASE64); + expect(result.payload).toEqual([1, 2, 3]); + }); + }); +}); diff --git a/packages/trigger-sdk/src/v3/webhooks.ts b/packages/trigger-sdk/src/v3/webhooks.ts index 040b7ee6638..ea2ae652f2c 100644 --- a/packages/trigger-sdk/src/v3/webhooks.ts +++ b/packages/trigger-sdk/src/v3/webhooks.ts @@ -14,6 +14,236 @@ export class WebhookError extends Error { /** Header name used for webhook signatures */ const SIGNATURE_HEADER_NAME = "x-trigger-signature-hmacsha256"; +/** Standard Webhooks spec header carrying the unique message id */ +export const STANDARD_WEBHOOKS_ID_HEADER_NAME = "webhook-id"; + +/** Standard Webhooks spec header carrying the unix-timestamp at which the message was sent */ +export const STANDARD_WEBHOOKS_TIMESTAMP_HEADER_NAME = "webhook-timestamp"; + +/** Standard Webhooks spec header carrying one or more space-separated `v1,` signatures */ +export const STANDARD_WEBHOOKS_SIGNATURE_HEADER_NAME = "webhook-signature"; + +/** Signature version we currently accept */ +export const STANDARD_WEBHOOKS_SIGNATURE_VERSION = "v1"; + +/** Default tolerance window for the anti-replay check, in seconds */ +export const STANDARD_WEBHOOKS_TOLERANCE_SECONDS = 300; + +/** + * Options accepted by {@link StandardWebhooks.verify}. + */ +export type VerifyStandardWebhooksOptions = { + /** + * Maximum allowed age of the webhook in seconds. The age is derived from the + * `webhook-timestamp` header compared against the current time. Defaults to + * {@link STANDARD_WEBHOOKS_TOLERANCE_SECONDS}. Pass `0` to disable the + * anti-replay check entirely. + */ + tolerance?: number; +}; + +/** + * Result returned by {@link StandardWebhooks.verify}. + * + * - `payload`: the parsed JSON body. + * - `raw`: the raw request body as text, useful for replaying or auditing. + */ +export type VerifyStandardWebhooksResult = { + payload: unknown; + raw: string; +}; + +/** + * Interface describing the Standard Webhooks verification utilities. + */ +interface StandardWebhooks { + /** + * Verifies the signature on an incoming Standard Webhooks request and + * returns the parsed JSON body alongside the raw text that was signed. + * + * The `secret` is the base64-encoded shared secret that the sender used + * to sign the request — the same value the sender gets from their + * provider dashboard. Headers are matched case-insensitively per the + * Fetch API Request contract. + * + * @param request - The incoming webhook request. + * @param secret - Base64-encoded shared secret. + * @param options - Optional behavior overrides. + * @returns The parsed payload and the raw body text. + * @throws {WebhookError} If any of the three spec headers are missing, the + * signature version is unsupported, the signature does not match, the + * secret is empty, the timestamp is outside the tolerance window, or + * the body is not valid JSON. + * + * @example + * // Express handler + * app.post("/webhooks/stripe", async (req, res) => { + * try { + * const { payload, raw } = await standardWebhooks.verify( + * req as unknown as Request, + * process.env.STRIPE_WEBHOOK_SECRET!, + * ); + * console.log("event:", payload.type, "body was:", raw); + * res.sendStatus(200); + * } catch (err) { + * if (err instanceof WebhookError) { + * res.status(400).send(err.message); + * return; + * } + * throw err; + * } + * }); + * + * @example + * // Disable anti-replay (NOT recommended in production) + * await standardWebhooks.verify(request, secret, { tolerance: 0 }); + */ + verify( + request: Request, + secret: string, + options?: VerifyStandardWebhooksOptions + ): Promise; + + /** Default anti-replay tolerance in seconds (also exported as {@link STANDARD_WEBHOOKS_TOLERANCE_SECONDS}). */ + TOLERANCE_SECONDS: number; + + /** Header name carrying the unique message id. Mirror of {@link STANDARD_WEBHOOKS_ID_HEADER_NAME}. */ + ID_HEADER_NAME: string; + + /** Header name carrying the unix timestamp the message was sent. Mirror of {@link STANDARD_WEBHOOKS_TIMESTAMP_HEADER_NAME}. */ + TIMESTAMP_HEADER_NAME: string; + + /** Header name carrying one or more `v1,` signatures. Mirror of {@link STANDARD_WEBHOOKS_SIGNATURE_HEADER_NAME}. */ + SIGNATURE_HEADER_NAME: string; +} + +/** + * Utilities for verifying incoming webhooks that follow the Standard Webhooks + * specification (https://github.com/standard-webhooks/standard-webhooks), + * used by providers like Stripe, Svix, and ngrok. + * + * The shared shape is straightforward: a provider signs + * `${webhook-id}.${webhook-timestamp}.${raw-body}` with HMAC-SHA256 using a + * shared secret, and ships the result plus the id and timestamp as three + * headers. `verify` reverses the process and returns the parsed body so you + * can dispatch on it. + * + * @example + * // Basic usage in a Next.js route handler + * import { standardWebhooks, WebhookError } from "@trigger.dev/sdk"; + * + * export async function POST(request: Request) { + * try { + * const { payload } = await standardWebhooks.verify( + * request, + * process.env.WEBHOOK_SECRET!, + * ); + * // payload is the parsed JSON body + * return Response.json({ received: true }); + * } catch (err) { + * if (err instanceof WebhookError) { + * return new Response(err.message, { status: 400 }); + * } + * throw err; + * } + * } + */ +export const standardWebhooks: StandardWebhooks = { + verify: verifyStandardWebhooks, + TOLERANCE_SECONDS: STANDARD_WEBHOOKS_TOLERANCE_SECONDS, + ID_HEADER_NAME: STANDARD_WEBHOOKS_ID_HEADER_NAME, + TIMESTAMP_HEADER_NAME: STANDARD_WEBHOOKS_TIMESTAMP_HEADER_NAME, + SIGNATURE_HEADER_NAME: STANDARD_WEBHOOKS_SIGNATURE_HEADER_NAME, +}; + +async function verifyStandardWebhooks( + request: Request, + secret: string, + options?: VerifyStandardWebhooksOptions +): Promise { + const id = request.headers.get(STANDARD_WEBHOOKS_ID_HEADER_NAME); + const timestampHeader = request.headers.get(STANDARD_WEBHOOKS_TIMESTAMP_HEADER_NAME); + const signatureHeader = request.headers.get(STANDARD_WEBHOOKS_SIGNATURE_HEADER_NAME); + + if (!id || !timestampHeader || !signatureHeader) { + throw new WebhookError("missing headers"); + } + + const rawBody = await request.text(); + + const secretBytes = Buffer.from(secret, "base64"); + if (secretBytes.length === 0) { + throw new WebhookError("invalid secret"); + } + + let computedBase64: string; + try { + const signedContent = `${id}.${timestampHeader}.${rawBody}`; + const key = await subtle.importKey( + "raw", + secretBytes, + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"] + ); + const computed = await subtle.sign("HMAC", key, Buffer.from(signedContent, "utf-8")); + computedBase64 = Buffer.from(computed).toString("base64"); + } catch (_error) { + throw new WebhookError("Signature verification failed"); + } + + const entries = signatureHeader.split(" "); + let matched = false; + let hasAcceptedVersion = false; + for (const entry of entries) { + const dotIndex = entry.indexOf(","); + const version = dotIndex === -1 ? entry : entry.slice(0, dotIndex); + if (!version.startsWith(STANDARD_WEBHOOKS_SIGNATURE_VERSION)) { + continue; + } + hasAcceptedVersion = true; + const provided = dotIndex === -1 ? "" : entry.slice(dotIndex + 1); + if (timingSafeEqual(computedBase64, provided)) { + matched = true; + break; + } + } + + if (!hasAcceptedVersion) { + throw new WebhookError("unsupported signature version"); + } + + if (!matched) { + throw new WebhookError("invalid signature"); + } + + const tolerance = + options?.tolerance === undefined ? STANDARD_WEBHOOKS_TOLERANCE_SECONDS : options.tolerance; + + if (tolerance !== 0) { + const timestampSeconds = Number(timestampHeader); + if (!Number.isFinite(timestampSeconds)) { + throw new WebhookError("invalid timestamp"); + } + const ageSeconds = Math.abs(Date.now() / 1000 - timestampSeconds); + if (ageSeconds > tolerance) { + throw new WebhookError("timestamp outside tolerance window"); + } + } + + let payload: unknown; + try { + payload = JSON.parse(rawBody); + } catch (error) { + if (error instanceof Error) { + throw new WebhookError(`invalid payload: ${error.message}`); + } + throw new WebhookError("invalid payload"); + } + + return { payload, raw: rawBody }; +} + /** * Options for constructing a webhook event */