Skip to content

Commit f7a24a6

Browse files
committed
Fix validateWebhook interfaces and update tests
1 parent a385004 commit f7a24a6

File tree

3 files changed

+82
-19
lines changed

3 files changed

+82
-19
lines changed

index.d.ts

+14-10
Original file line numberDiff line numberDiff line change
@@ -340,16 +340,20 @@ declare module "replicate" {
340340
}
341341

342342
export function validateWebhook(
343-
requestData:
344-
| Request
345-
| {
346-
id?: string;
347-
timestamp?: string;
348-
body: string;
349-
secret?: string;
350-
signature?: string;
351-
},
352-
secret: string
343+
request: Request,
344+
secret: string,
345+
crypto?: Crypto
346+
): Promise<boolean>;
347+
348+
export function validateWebhook(
349+
requestData: {
350+
id: string;
351+
timestamp: string;
352+
signature: string;
353+
body: string;
354+
secret: string;
355+
},
356+
crypto?: Crypto
353357
): Promise<boolean>;
354358

355359
export function parseProgressFromLogs(logs: Prediction | string): {

index.test.ts

+34-2
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import Replicate, {
99
} from "replicate";
1010
import nock from "nock";
1111
import { createReadableStream } from "./lib/stream";
12+
import { webcrypto } from "node:crypto";
1213

1314
let client: Replicate;
1415
const BASE_URL = "https://api.replicate.com/v1";
@@ -1779,9 +1780,40 @@ describe("Replicate client", () => {
17791780

17801781
// This is a test secret and should not be used in production
17811782
const secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
1783+
if (globalThis.crypto) {
1784+
const isValid = await validateWebhook(request, secret);
1785+
expect(isValid).toBe(true);
1786+
} else {
1787+
const isValid = await validateWebhook(
1788+
request,
1789+
secret,
1790+
webcrypto as Crypto // Node 18 requires this to be passed manually
1791+
);
1792+
expect(isValid).toBe(true);
1793+
}
1794+
});
17821795

1783-
const isValid = await validateWebhook(request, secret);
1784-
expect(isValid).toBe(true);
1796+
test("Can be used to validate webhook", async () => {
1797+
// Test case from https://github.com/svix/svix-webhooks/blob/b41728cd98a7e7004a6407a623f43977b82fcba4/javascript/src/webhook.test.ts#L190-L200
1798+
const requestData = {
1799+
id: "msg_p5jXN8AQM9LWM0D4loKWxJek",
1800+
timestamp: "1614265330",
1801+
signature: "v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=",
1802+
body: `{"test": 2432232314}`,
1803+
secret: "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw",
1804+
};
1805+
1806+
// This is a test secret and should not be used in production
1807+
if (globalThis.crypto) {
1808+
const isValid = await validateWebhook(requestData);
1809+
expect(isValid).toBe(true);
1810+
} else {
1811+
const isValid = await validateWebhook(
1812+
requestData,
1813+
webcrypto as Crypto // Node 18 requires this to be passed manually
1814+
);
1815+
expect(isValid).toBe(true);
1816+
}
17851817
});
17861818

17871819
// Add more tests for error handling, edge cases, etc.

lib/util.js

+34-7
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ const { create: createFile } = require("./files");
1010
* @param {string} requestData.body - The raw body of the incoming webhook request.
1111
* @param {string} requestData.secret - The webhook secret, obtained from `replicate.webhooks.defaul.secret` method.
1212
* @param {string} requestData.signature - The webhook signature header from the incoming request, comprising one or more space-delimited signatures.
13+
* @param {Crypto} [crypto] - An optional `Crypto` implementation that conforms to the [browser Crypto interface](https://developer.mozilla.org/en-US/docs/Web/API/Window/crypto)
1314
*/
1415

1516
/**
@@ -22,6 +23,7 @@ const { create: createFile } = require("./files");
2223
* @param {string} requestData.headers["webhook-signature"] - The webhook signature header from the incoming request, comprising one or more space-delimited signatures
2324
* @param {string} requestData.body - The raw body of the incoming webhook request
2425
* @param {string} secret - The webhook secret, obtained from `replicate.webhooks.defaul.secret` method
26+
* @param {Crypto} [crypto] - An optional `Crypto` implementation that conforms to the [browser Crypto interface](https://developer.mozilla.org/en-US/docs/Web/API/Window/crypto)
2527
*/
2628

2729
/**
@@ -30,9 +32,13 @@ const { create: createFile } = require("./files");
3032
* @returns {Promise<boolean>} - True if the signature is valid
3133
* @throws {Error} - If the request is missing required headers, body, or secret
3234
*/
33-
async function validateWebhook(requestData, secret) {
34-
let { id, timestamp, body, signature } = requestData;
35-
const signingSecret = secret || requestData.secret;
35+
async function validateWebhook(requestData, secretOrCrypto, customCrypto) {
36+
let id;
37+
let body;
38+
let timestamp;
39+
let signature;
40+
let secret;
41+
let crypto = globalThis.crypto;
3642

3743
if (requestData && requestData.headers && requestData.body) {
3844
if (typeof requestData.headers.get === "function") {
@@ -47,6 +53,19 @@ async function validateWebhook(requestData, secret) {
4753
signature = requestData.headers["webhook-signature"];
4854
}
4955
body = requestData.body;
56+
secret = secretOrCrypto;
57+
if (customCrypto) {
58+
crypto = customCrypto;
59+
}
60+
} else {
61+
id = requestData.id;
62+
body = requestData.body;
63+
timestamp = requestData.timestamp;
64+
signature = requestData.signature;
65+
secret = requestData.secret;
66+
if (secretOrCrypto) {
67+
crypto = secretOrCrypto;
68+
}
5069
}
5170

5271
if (body instanceof ReadableStream || body.readable) {
@@ -71,15 +90,22 @@ async function validateWebhook(requestData, secret) {
7190
throw new Error("Missing required body");
7291
}
7392

74-
if (!signingSecret) {
93+
if (!secret) {
7594
throw new Error("Missing required secret");
7695
}
7796

97+
if (!crypto) {
98+
throw new Error(
99+
'Missing `crypto` implementation. If using Node 18 pass in require("node:crypto").webcrypto'
100+
);
101+
}
102+
78103
const signedContent = `${id}.${timestamp}.${body}`;
79104

80105
const computedSignature = await createHMACSHA256(
81-
signingSecret.split("_").pop(),
82-
signedContent
106+
secret.split("_").pop(),
107+
signedContent,
108+
crypto
83109
);
84110

85111
const expectedSignatures = signature
@@ -94,8 +120,9 @@ async function validateWebhook(requestData, secret) {
94120
/**
95121
* @param {string} secret - base64 encoded string
96122
* @param {string} data - text body of request
123+
* @param {Crypto} crypto - an implementation of the web Crypto api
97124
*/
98-
async function createHMACSHA256(secret, data) {
125+
async function createHMACSHA256(secret, data, crypto) {
99126
const encoder = new TextEncoder();
100127
const key = await crypto.subtle.importKey(
101128
"raw",

0 commit comments

Comments
 (0)