|
| 1 | +const crypto = require("node:crypto"); |
| 2 | + |
1 | 3 | const ApiError = require("./error");
|
2 | 4 |
|
| 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 | + |
3 | 91 | /**
|
4 | 92 | * Automatically retry a request if it fails with an appropriate status code.
|
5 | 93 | *
|
@@ -68,4 +156,4 @@ async function withAutomaticRetries(request, options = {}) {
|
68 | 156 | return request();
|
69 | 157 | }
|
70 | 158 |
|
71 |
| -module.exports = { withAutomaticRetries }; |
| 159 | +module.exports = { validateWebhook, withAutomaticRetries }; |
0 commit comments