-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
5 changed files
with
227 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -81,3 +81,4 @@ export { | |
UsageListWithGroupsParams, | ||
Usage, | ||
} from './usage'; | ||
export { Webhooks } from './webhooks'; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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'); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)."`, | ||
); | ||
}); | ||
}); |