diff --git a/src/index.ts b/src/index.ts index f5aaf4b9..438b4fca 100644 --- a/src/index.ts +++ b/src/index.ts @@ -16,6 +16,7 @@ import type { WebhookEventHandlerError, EmitterWebhookEventWithStringPayloadAndSignature, } from "./types.ts"; +import { verifyAndParse } from "./verify-and-parse.ts"; export { createNodeMiddleware } from "./middleware/node/index.ts"; export { createWebMiddleware } from "./middleware/web/index.ts"; @@ -47,6 +48,9 @@ class Webhooks { public verifyAndReceive: ( options: EmitterWebhookEventWithStringPayloadAndSignature, ) => Promise; + verifyAndParse: ( + event: EmitterWebhookEventWithStringPayloadAndSignature, + ) => Promise; constructor(options: Options & { secret: string }) { if (!options || !options.secret) { @@ -73,6 +77,11 @@ class Webhooks { this.removeListener = state.eventHandler.removeListener; this.receive = state.eventHandler.receive; this.verifyAndReceive = verifyAndReceive.bind(null, state); + this.verifyAndParse = async ( + event: EmitterWebhookEventWithStringPayloadAndSignature, + ) => { + return verifyAndParse(state.secret, event, state.additionalSecrets); + }; } } diff --git a/src/verify-and-parse.ts b/src/verify-and-parse.ts new file mode 100644 index 00000000..9bd2aab2 --- /dev/null +++ b/src/verify-and-parse.ts @@ -0,0 +1,44 @@ +import { verifyWithFallback } from "@octokit/webhooks-methods"; + +import type { + EmitterWebhookEvent, + EmitterWebhookEventWithStringPayloadAndSignature, + WebhookError, +} from "./types.ts"; + +export async function verifyAndParse( + secret: string, + event: EmitterWebhookEventWithStringPayloadAndSignature, + additionalSecrets?: string[] | undefined, +): Promise { + // verify will validate that the secret is not undefined + const matchesSignature = await verifyWithFallback( + secret, + event.payload, + event.signature, + additionalSecrets, + ).catch(() => false); + + if (!matchesSignature) { + const error = new Error( + "[@octokit/webhooks] signature does not match event payload and secret", + ); + + return Object.assign(error, { event, status: 400 }) as WebhookError; + } + + let payload: EmitterWebhookEvent["payload"]; + try { + payload = JSON.parse(event.payload); + } catch (error: any) { + error.message = "Invalid JSON"; + error.status = 400; + throw new AggregateError([error], error.message); + } + + return { + id: event.id, + name: event.name, + payload, + } as EmitterWebhookEvent; +} diff --git a/src/verify-and-receive.ts b/src/verify-and-receive.ts index f89ae19e..0206560d 100644 --- a/src/verify-and-receive.ts +++ b/src/verify-and-receive.ts @@ -1,10 +1,7 @@ -import { verifyWithFallback } from "@octokit/webhooks-methods"; - +import { verifyAndParse } from "./verify-and-parse.ts"; import type { - EmitterWebhookEvent, EmitterWebhookEventWithStringPayloadAndSignature, State, - WebhookError, } from "./types.ts"; import type { EventHandler } from "./event-handler/index.ts"; @@ -12,36 +9,7 @@ export async function verifyAndReceive( state: State & { secret: string; eventHandler: EventHandler }, event: EmitterWebhookEventWithStringPayloadAndSignature, ): Promise { - // verify will validate that the secret is not undefined - const matchesSignature = await verifyWithFallback( - state.secret, - event.payload, - event.signature, - state.additionalSecrets, - ).catch(() => false); - - if (!matchesSignature) { - const error = new Error( - "[@octokit/webhooks] signature does not match event payload and secret", - ); - - return state.eventHandler.receive( - Object.assign(error, { event, status: 400 }) as WebhookError, - ); - } - - let payload: EmitterWebhookEvent["payload"]; - try { - payload = JSON.parse(event.payload); - } catch (error: any) { - error.message = "Invalid JSON"; - error.status = 400; - throw new AggregateError([error], error.message); - } - - return state.eventHandler.receive({ - id: event.id, - name: event.name, - payload, - } as EmitterWebhookEvent); + return state.eventHandler.receive( + await verifyAndParse(state.secret, event, state.additionalSecrets), + ); } diff --git a/test/integration/smoke.test.ts b/test/integration/smoke.test.ts index 116bc0e3..040000c4 100644 --- a/test/integration/smoke.test.ts +++ b/test/integration/smoke.test.ts @@ -24,6 +24,7 @@ it("check exports of @octokit/webhooks", () => { assert(typeof api.removeListener === "function"); assert(typeof api.receive === "function"); assert(typeof api.verifyAndReceive === "function"); + assert(typeof api.verifyAndParse === "function"); assert(typeof api.onAny === "function"); assert(warned === false); diff --git a/test/integration/webhooks.test.ts b/test/integration/webhooks.test.ts index 2d8b45ee..7825efd7 100644 --- a/test/integration/webhooks.test.ts +++ b/test/integration/webhooks.test.ts @@ -1,4 +1,4 @@ -import { describe, it, assert } from "../testrunner.ts"; +import { describe, it, assert, deepEqual } from "../testrunner.ts"; import { readFileSync } from "node:fs"; import { sign } from "@octokit/webhooks-methods"; @@ -82,6 +82,24 @@ describe("Webhooks", () => { } }); + it("webhooks.verifyAndParse(event) with correct signature", async () => { + const secret = "mysecret"; + const webhooks = new Webhooks({ secret }); + + const event = await webhooks.verifyAndParse({ + id: "1", + name: "push", + payload: pushEventPayloadString, + signature: await sign(secret, pushEventPayloadString), + }); + assert(typeof event === "object"); + deepEqual(event, { + name: "push", + id: "1", + payload: JSON.parse(pushEventPayloadString), + }); + }); + it("webhooks.receive(error)", async () => { const webhooks = new Webhooks({ secret: "mysecret" }); diff --git a/test/testrunner.ts b/test/testrunner.ts index 8650a767..69e93596 100644 --- a/test/testrunner.ts +++ b/test/testrunner.ts @@ -2,7 +2,7 @@ declare global { var Bun: any; } -let describe: Function, it: Function, assert: Function; +let describe: Function, it: Function, assert: Function, deepEqual: Function; if ("Bun" in globalThis) { describe = function describe(name: string, fn: Function) { return globalThis.Bun.jest(caller()).describe(name, fn); @@ -13,6 +13,11 @@ if ("Bun" in globalThis) { assert = function assert(value: unknown, message?: string) { return globalThis.Bun.jest(caller()).expect(value, message); }; + deepEqual = function deepEqual(expected: any, actual: any, message?: string) { + return globalThis.Bun.jest(caller()) + .expect(actual) + .toEqual(expected, message); + }; /** Retrieve caller test file. */ function caller() { const Trace = Error as unknown as { @@ -35,10 +40,15 @@ if ("Bun" in globalThis) { describe = nodeTest.describe; it = nodeTest.it; assert = nodeAssert.strict; + deepEqual = nodeAssert.deepStrictEqual; } else if (process.env.VITEST_WORKER_ID) { describe = await import("vitest").then((module) => module.describe); it = await import("vitest").then((module) => module.it); assert = await import("vitest").then((module) => module.assert); + deepEqual = await import("vitest").then( + (module) => (expected: any, actual: any) => + module.expect(actual).toEqual(expected), + ); } else { const nodeTest = await import("node:test"); const nodeAssert = await import("node:assert"); @@ -46,6 +56,7 @@ if ("Bun" in globalThis) { describe = nodeTest.describe; it = nodeTest.it; assert = nodeAssert.strict; + deepEqual = nodeAssert.deepStrictEqual; } -export { describe, it, assert }; +export { describe, it, assert, deepEqual };