UNPKG

orb-billing

Version:

The official TypeScript library for the Orb API

106 lines 4.73 kB
// File generated from our OpenAPI spec by Stainless. import { APIResource } from 'orb-billing/resource'; import { createHmac } from 'node:crypto'; import { debug, getRequiredHeader } from 'orb-billing/core'; export class Webhooks extends APIResource { /** * Validates that the given payload was sent by Orb and parses the payload. * * An error will be raised if the webhook payload was not sent by Orb. */ unwrap(payload, headers, secret = this._client.webhookSecret) { this.verifySignature(payload, headers, secret); return JSON.parse(payload); } parseSecret(secret) { if (!secret) { throw new Error("The webhook secret must either be set using the env var, ORB_WEBHOOK_SECRET, on the client class, Orb({ webhookSecret: '123' }), or passed to this function"); } const buf = Buffer.from(secret, 'utf-8'); if (buf.toString('utf-8') !== secret) { throw new Error(`Given secret is not valid`); } return new Uint8Array(buf); } signPayload(payload, { timestamp, secret }) { const encoder = new TextEncoder(); const toSign = encoder.encode(`v1:${timestamp}:${payload}`); const hmac = createHmac('sha256', secret); hmac.update(toSign); return `v1=${hmac.digest('hex')}`; } /** Make an assertion, if not `true`, then throw. */ assert(expr, msg = '') { if (!expr) { throw new Error(msg); } } /** Compare to array buffers or data views in a way that timing based attacks * cannot gain information about the platform. */ timingSafeEqual(a, b) { if (a.byteLength !== b.byteLength) { return false; } if (!(a instanceof DataView)) { a = new DataView(ArrayBuffer.isView(a) ? a.buffer : a); } if (!(b instanceof DataView)) { b = new DataView(ArrayBuffer.isView(b) ? b.buffer : b); } this.assert(a instanceof DataView); this.assert(b instanceof DataView); const length = a.byteLength; let out = 0; let i = -1; while (++i < length) { out |= a.getUint8(i) ^ b.getUint8(i); } return out === 0; } /** * Validates whether or not the webhook payload was sent by Orb. * * An error will be raised if the webhook payload was not sent by Orb. */ verifySignature(body, headers, secret = this._client.webhookSecret) { const whsecret = this.parseSecret(secret); const msgTimestamp = getRequiredHeader(headers, 'X-Orb-Timestamp'); const msgSignature = getRequiredHeader(headers, 'X-Orb-Signature'); const nowSeconds = Math.floor(Date.now() / 1000); // The timestamp header does not include a timezone (it is UTC by default) const timezoneSuffix = msgTimestamp.includes('Z') || msgTimestamp.includes('+') ? '' : 'Z'; const timestamp = new Date(msgTimestamp + timezoneSuffix); const timestampSeconds = Math.floor(timestamp.getTime() / 1000); if (isNaN(timestampSeconds)) { throw new Error('Invalid timestamp header'); } const webhookToleranceInSeconds = 5 * 60; // 5 minutes if (nowSeconds - timestampSeconds > webhookToleranceInSeconds) { throw new Error('Webhook timestamp is too old'); } if (timestampSeconds > nowSeconds + webhookToleranceInSeconds) { console.warn({ timestampSeconds, nowSeconds, webhookToleranceInSeconds }); throw new Error('Webhook timestamp is too new'); } if (typeof body !== 'string') { throw new Error('Webhook body must be passed as the raw JSON string sent from the server (do not parse it first).'); } const computedSignature = this.signPayload(body, { timestamp: msgTimestamp, secret: whsecret }); const expectedSignature = computedSignature.split('=')[1]; const passedSignatures = msgSignature.split(' '); const encoder = new globalThis.TextEncoder(); for (const versionedSignature of passedSignatures) { const [version, signature] = versionedSignature.split('='); debug('verifySignature', { version, signature, expectedSignature, computedSignature }); if (version !== 'v1') { continue; } if (this.timingSafeEqual(encoder.encode(signature), encoder.encode(expectedSignature))) { // valid! return; } } throw new Error('None of the given webhook signatures match the expected signature'); } } //# sourceMappingURL=webhooks.mjs.map