Skip to content

Commit

Permalink
feat(api): add webhook helpers
Browse files Browse the repository at this point in the history
  • Loading branch information
meorphis committed Jun 6, 2024
1 parent 10c779d commit ea63380
Show file tree
Hide file tree
Showing 5 changed files with 227 additions and 0 deletions.
7 changes: 7 additions & 0 deletions api.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,13 @@ Methods:

- <code title="post /dashboards/getEmbeddableUrl">client.dashboards.<a href="./src/resources/dashboards.ts">getEmbeddableURL</a>({ ...params }) -> DashboardGetEmbeddableURLResponse</code>

# Webhooks

Methods:

- <code>client.webhooks.<a href="./src/resources/webhooks.ts">unwrap</a>(payload, headers, secret) -> Object</code>
- <code>client.webhooks.<a href="./src/resources/webhooks.ts">verifySignature</a>(body, headers, secret) -> void</code>

# Usage

Types:
Expand Down
3 changes: 3 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,7 @@ export class Metronome extends Core.APIClient {
creditTypes: API.CreditTypes = new API.CreditTypes(this);
customers: API.Customers = new API.Customers(this);
dashboards: API.Dashboards = new API.Dashboards(this);
webhooks: API.Webhooks = new API.Webhooks(this);
usage: API.Usage = new API.Usage(this);
auditLogs: API.AuditLogs = new API.AuditLogs(this);
customFields: API.CustomFields = new API.CustomFields(this);
Expand Down Expand Up @@ -276,6 +277,8 @@ export namespace Metronome {
export import DashboardGetEmbeddableURLResponse = API.DashboardGetEmbeddableURLResponse;
export import DashboardGetEmbeddableURLParams = API.DashboardGetEmbeddableURLParams;

export import Webhooks = API.Webhooks;

export import Usage = API.Usage;
export import UsageListResponse = API.UsageListResponse;
export import UsageListWithGroupsResponse = API.UsageListWithGroupsResponse;
Expand Down
1 change: 1 addition & 0 deletions src/resources/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,3 +81,4 @@ export {
UsageListWithGroupsParams,
Usage,
} from './usage';
export { Webhooks } from './webhooks';
120 changes: 120 additions & 0 deletions src/resources/webhooks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

import { APIResource } from '../resource';
import { createHmac } from 'crypto';
import { getRequiredHeader, HeadersLike } from '../core';

export class Webhooks extends APIResource {
/**
* Validates that the given payload was sent by Metronome and parses the payload.
*/
unwrap(
payload: string,
headers: HeadersLike,
secret: string | undefined | null = this._client.webhookSecret,
): Object {
this.verifySignature(payload, headers, secret);
return JSON.parse(payload);
}

private validateSecret(secret: string | null | undefined): asserts secret is string {
if (typeof secret !== 'string') {
throw new Error(
"The webhook secret must either be set using the env var, METRONOME_WEBHOOK_SECRET, on the client class, Metronome({ webhook_secret: '123' }), or passed to this function",
);
}

return;
}

private signPayload(payload: string, { date, secret }: { date: string; secret: string }) {
const encoder = new TextEncoder();
const toSign = encoder.encode(`${date}\n${payload}`);

const hmac = createHmac('sha256', secret);
hmac.update(toSign);

return hmac.digest('hex');
}

/** Make an assertion, if not `true`, then throw. */
private assert(expr: unknown, msg = ''): asserts expr {
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. */
private timingSafeEqual(
a: ArrayBufferView | ArrayBufferLike | DataView,
b: ArrayBufferView | ArrayBufferLike | DataView,
): boolean {
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 Metronome.
*
* An error will be raised if the webhook payload was not sent by Metronome.
*/
verifySignature(
body: string,
headers: HeadersLike,
secret: string | undefined | null = this._client.webhookSecret,
): void {
this.validateSecret(secret);

const msgDate = getRequiredHeader(headers, 'Date');
const msgSignature = getRequiredHeader(headers, 'Metronome-Webhook-Signature');

const now = Date.now();
const timestampSeconds = Math.floor(new Date(msgDate).valueOf());

if (isNaN(timestampSeconds)) {
throw new Error(`Invalid timestamp header: ${msgDate}`);
}

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 webhook_tolerance_in_seconds = 5 * 60; // 5 minutes
if (now - timestampSeconds > webhook_tolerance_in_seconds) {
throw new Error('Webhook timestamp is too old');
}

if (timestampSeconds > now + webhook_tolerance_in_seconds) {
throw new Error('Webhook timestamp is too new');
}

const expectedSignature = this.signPayload(body, { date: msgDate, secret });
const encoder = new globalThis.TextEncoder();

if (this.timingSafeEqual(encoder.encode(msgSignature), encoder.encode(expectedSignature))) {
// valid!
return;
}

throw new Error('The given webhook signature does not match the expected signature');
}
}
96 changes: 96 additions & 0 deletions tests/api-resources/webhooks.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// File generated from our OpenAPI spec by Stainless. See CONTRIBUTING.md for details.

import Metronome from 'metronome';

const metronome = new Metronome({
bearerToken: 'My Bearer Token',
baseURL: process.env['TEST_API_BASE_URL'] ?? 'http://127.0.0.1:4010',
});

describe('resource webhooks', () => {
const requestBody = `{
"id": "b2c9e307-624e-4e7d-a5a4-1b74107d78c4",
"type": "widget_created",
"properties": {
"customer_id": "5f794d50-085a-4db6-8d15-286e518b7225",
"widget_id": "0891458d-b6f0-4fdd-a41e-380aae1a1e38"
}
}`;
const payload = Buffer.from(requestBody).toString('utf8');
const signature = 'b82652fa2246cf1d8a27e591f155c865f68b46c19b9213fd9c052f2419b4742b';
const date = 'Mon, 02 Jan 2006 22:04:05 GMT';
const headers = {
Date: date,
'Metronome-Webhook-Signature': signature,
};
const secret = 'correct-horse-battery-staple';
const fakeNow = new Date(date).valueOf();

beforeEach(() => {
jest.spyOn(global.Date, 'now').mockImplementation(() => fakeNow);
});

afterEach(() => {
// restore the spy created with spyOn
jest.restoreAllMocks();
});

describe('unwrap', () => {
it('deserializes the payload object', () => {
metronome.webhooks.unwrap(payload, headers, secret);
});
});

it('should pass for valid signature', () => {
metronome.webhooks.verifySignature(payload, headers, secret);
});

it('should throw for timestamp outside threshold', () => {
jest.spyOn(global.Date, 'now').mockImplementation(() => fakeNow + 360000); // 6 minutes
expect(() =>
metronome.webhooks.verifySignature(payload, headers, secret),
).toThrowErrorMatchingInlineSnapshot(`"Webhook timestamp is too old"`);

jest.spyOn(global.Date, 'now').mockImplementation(() => fakeNow - 360000); // 6 minutes
expect(() =>
metronome.webhooks.verifySignature(payload, headers, secret),
).toThrowErrorMatchingInlineSnapshot(`"Webhook timestamp is too new"`);
});

it('should throw an error for invalid secret format', () => {
expect(() => {
metronome.webhooks.verifySignature(payload, headers, 'invalid secret');
}).toThrowErrorMatchingInlineSnapshot(
`"The given webhook signature does not match the expected signature"`,
);
});

it('should throw for invalid signature', () => {
expect(() =>
metronome.webhooks.verifySignature(payload, headers, Buffer.from('foo').toString('base64')),
).toThrowErrorMatchingInlineSnapshot(
`"The given webhook signature does not match the expected signature"`,
);
});

it('should throw for invalid timestamp', () => {
expect(() =>
metronome.webhooks.verifySignature(
payload,
{
...headers,
Date: 'nan',
},
secret,
),
).toThrowErrorMatchingInlineSnapshot(`"Invalid timestamp header: nan"`);
});

it('should throw if payload is not a string', () => {
expect(() =>
metronome.webhooks.verifySignature({ payload: 'not a string' } as any, headers, secret),
).toThrowErrorMatchingInlineSnapshot(
`"Webhook body must be passed as the raw JSON string sent from the server (do not parse it first)."`,
);
});
});

0 comments on commit ea63380

Please sign in to comment.