Skip to content

Commit c0d2a01

Browse files
authored
Add support for validating webhooks (#200)
* Add support for validating webhooks * Use test case from official Svix repo * Add comment about test secret
1 parent c6fbd33 commit c0d2a01

File tree

5 files changed

+185
-3
lines changed

5 files changed

+185
-3
lines changed

index.d.ts

+25
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,10 @@ declare module "replicate" {
8989
retry?: number;
9090
}
9191

92+
export interface WebhookSecret {
93+
key: string;
94+
}
95+
9296
export default class Replicate {
9397
constructor(options?: {
9498
auth?: string;
@@ -233,5 +237,26 @@ declare module "replicate" {
233237
cancel(training_id: string): Promise<Training>;
234238
list(): Promise<Page<Training>>;
235239
};
240+
241+
webhooks: {
242+
default: {
243+
secret: {
244+
get(): Promise<WebhookSecret>;
245+
};
246+
};
247+
};
236248
}
249+
250+
export function validateWebhook(
251+
requestData:
252+
| Request
253+
| {
254+
id?: string;
255+
timestamp?: string;
256+
body: string;
257+
secret?: string;
258+
signature?: string;
259+
},
260+
secret: string
261+
): boolean;
237262
}

index.js

+11-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
const ApiError = require("./lib/error");
22
const ModelVersionIdentifier = require("./lib/identifier");
33
const { Stream } = require("./lib/stream");
4-
const { withAutomaticRetries } = require("./lib/util");
4+
const { withAutomaticRetries, validateWebhook } = require("./lib/util");
55

66
const accounts = require("./lib/accounts");
77
const collections = require("./lib/collections");
@@ -10,6 +10,7 @@ const hardware = require("./lib/hardware");
1010
const models = require("./lib/models");
1111
const predictions = require("./lib/predictions");
1212
const trainings = require("./lib/trainings");
13+
const webhooks = require("./lib/webhooks");
1314

1415
const packageJSON = require("./package.json");
1516

@@ -90,6 +91,14 @@ class Replicate {
9091
cancel: trainings.cancel.bind(this),
9192
list: trainings.list.bind(this),
9293
};
94+
95+
this.webhooks = {
96+
default: {
97+
secret: {
98+
get: webhooks.default.secret.get.bind(this),
99+
},
100+
},
101+
};
93102
}
94103

95104
/**
@@ -364,3 +373,4 @@ class Replicate {
364373
}
365374

366375
module.exports = Replicate;
376+
module.exports.validateWebhook = validateWebhook;

index.test.ts

+40-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
import { expect, jest, test } from "@jest/globals";
2-
import Replicate, { ApiError, Model, Prediction } from "replicate";
2+
import Replicate, {
3+
ApiError,
4+
Model,
5+
Prediction,
6+
validateWebhook,
7+
} from "replicate";
38
import nock from "nock";
49
import fetch from "cross-fetch";
510

@@ -996,5 +1001,39 @@ describe("Replicate client", () => {
9961001
});
9971002
});
9981003

1004+
describe("webhooks.default.secret.get", () => {
1005+
test("Calls the correct API route", async () => {
1006+
nock(BASE_URL).get("/webhooks/default/secret").reply(200, {
1007+
key: "whsec_5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH",
1008+
});
1009+
1010+
const secret = await client.webhooks.default.secret.get();
1011+
expect(secret.key).toBe("whsec_5WbX5kEWLlfzsGNjH64I8lOOqUB6e8FH");
1012+
});
1013+
1014+
test("Can be used to validate webhook", async () => {
1015+
// Test case from https://github.com/svix/svix-webhooks/blob/b41728cd98a7e7004a6407a623f43977b82fcba4/javascript/src/webhook.test.ts#L190-L200
1016+
const request = new Request("http://test.host/webhook", {
1017+
method: "POST",
1018+
headers: {
1019+
"Content-Type": "application/json",
1020+
"Webhook-ID": "msg_p5jXN8AQM9LWM0D4loKWxJek",
1021+
"Webhook-Timestamp": "1614265330",
1022+
"Webhook-Signature":
1023+
"v1,g0hM9SsE+OTPJTGt/tmIKtSyZlE3uFJELVlNIOLJ1OE=",
1024+
},
1025+
body: `{"test": 2432232314}`,
1026+
});
1027+
1028+
// This is a test secret and should not be used in production
1029+
const secret = "whsec_MfKQ9r8GKYqrTwjUPD8ILPZIo2LaLaSw";
1030+
1031+
const isValid = await validateWebhook(request, secret);
1032+
expect(isValid).toBe(true);
1033+
});
1034+
1035+
// Add more tests for error handling, edge cases, etc.
1036+
});
1037+
9991038
// Continue with tests for other methods
10001039
});

lib/util.js

+89-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,93 @@
1+
const crypto = require("node:crypto");
2+
13
const ApiError = require("./error");
24

5+
/**
6+
* @see {@link validateWebhook}
7+
* @overload
8+
* @param {object} requestData - The request data
9+
* @param {string} requestData.id - The webhook ID header from the incoming request.
10+
* @param {string} requestData.timestamp - The webhook timestamp header from the incoming request.
11+
* @param {string} requestData.body - The raw body of the incoming webhook request.
12+
* @param {string} requestData.secret - The webhook secret, obtained from `replicate.webhooks.defaul.secret` method.
13+
* @param {string} requestData.signature - The webhook signature header from the incoming request, comprising one or more space-delimited signatures.
14+
*/
15+
16+
/**
17+
* @see {@link validateWebhook}
18+
* @overload
19+
* @param {object} requestData - The request object
20+
* @param {object} requestData.headers - The request headers
21+
* @param {string} requestData.headers["webhook-id"] - The webhook ID header from the incoming request
22+
* @param {string} requestData.headers["webhook-timestamp"] - The webhook timestamp header from the incoming request
23+
* @param {string} requestData.headers["webhook-signature"] - The webhook signature header from the incoming request, comprising one or more space-delimited signatures
24+
* @param {string} requestData.body - The raw body of the incoming webhook request
25+
* @param {string} secret - The webhook secret, obtained from `replicate.webhooks.defaul.secret` method
26+
*/
27+
28+
/**
29+
* Validate a webhook signature
30+
*
31+
* @returns {boolean} - True if the signature is valid
32+
* @throws {Error} - If the request is missing required headers, body, or secret
33+
*/
34+
async function validateWebhook(requestData, secret) {
35+
let { id, timestamp, body, signature } = requestData;
36+
const signingSecret = secret || requestData.secret;
37+
38+
if (requestData && requestData.headers && requestData.body) {
39+
id = requestData.headers.get("webhook-id");
40+
timestamp = requestData.headers.get("webhook-timestamp");
41+
signature = requestData.headers.get("webhook-signature");
42+
body = requestData.body;
43+
}
44+
45+
if (body instanceof ReadableStream || body.readable) {
46+
try {
47+
const chunks = [];
48+
for await (const chunk of body) {
49+
chunks.push(Buffer.from(chunk));
50+
}
51+
body = Buffer.concat(chunks).toString("utf8");
52+
} catch (err) {
53+
throw new Error(`Error reading body: ${err.message}`);
54+
}
55+
} else if (body instanceof Buffer) {
56+
body = body.toString("utf8");
57+
} else if (typeof body !== "string") {
58+
throw new Error("Invalid body type");
59+
}
60+
61+
if (!id || !timestamp || !signature) {
62+
throw new Error("Missing required webhook headers");
63+
}
64+
65+
if (!body) {
66+
throw new Error("Missing required body");
67+
}
68+
69+
if (!signingSecret) {
70+
throw new Error("Missing required secret");
71+
}
72+
73+
const signedContent = `${id}.${timestamp}.${body}`;
74+
75+
const secretBytes = Buffer.from(signingSecret.split("_")[1], "base64");
76+
77+
const computedSignature = crypto
78+
.createHmac("sha256", secretBytes)
79+
.update(signedContent)
80+
.digest("base64");
81+
82+
const expectedSignatures = signature
83+
.split(" ")
84+
.map((sig) => sig.split(",")[1]);
85+
86+
return expectedSignatures.some(
87+
(expectedSignature) => expectedSignature === computedSignature
88+
);
89+
}
90+
391
/**
492
* Automatically retry a request if it fails with an appropriate status code.
593
*
@@ -68,4 +156,4 @@ async function withAutomaticRetries(request, options = {}) {
68156
return request();
69157
}
70158

71-
module.exports = { withAutomaticRetries };
159+
module.exports = { validateWebhook, withAutomaticRetries };

lib/webhooks.js

+20
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* Get the default webhook signing secret
3+
*
4+
* @returns {Promise<object>} Resolves with the signing secret for the default webhook
5+
*/
6+
async function getDefaultWebhookSecret() {
7+
const response = await this.request("/webhooks/default/secret", {
8+
method: "GET",
9+
});
10+
11+
return response.json();
12+
}
13+
14+
module.exports = {
15+
default: {
16+
secret: {
17+
get: getDefaultWebhookSecret,
18+
},
19+
},
20+
};

0 commit comments

Comments
 (0)