From 2f79087da51c16e7769e605dc9c07fa5395cc4f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Morel=20Se=CC=81bastien?= Date: Wed, 13 Mar 2024 15:02:57 -0700 Subject: [PATCH] feat(js-api-client): introducing createAsyncSignatureVerifier --- components/js-api-client/README.md | 2 +- components/js-api-client/package.json | 2 +- .../js-api-client/src/core/verifySignature.ts | 89 ++++++++++++----- .../js-api-client/tests/signature.test.js | 97 ++++++++++++++----- 4 files changed, 140 insertions(+), 50 deletions(-) diff --git a/components/js-api-client/README.md b/components/js-api-client/README.md index 98472e75..8f1719d2 100644 --- a/components/js-api-client/README.md +++ b/components/js-api-client/README.md @@ -533,7 +533,7 @@ This library makes it simple, assuming: - you have your `CRYSTALLIZE_SIGNATURE_SECRET` from the environment variable - you retrieve the Signature from the Header in `signatureJwt` -you can use the `createSignatureVerifier` +you can use the `createSignatureVerifier` OR `createAsyncSignatureVerifier` based on your preferences/libs you are using to verify and/or hash ```javascript const guard = createSignatureVerifier({ diff --git a/components/js-api-client/package.json b/components/js-api-client/package.json index 4b083f40..5f4e2084 100644 --- a/components/js-api-client/package.json +++ b/components/js-api-client/package.json @@ -1,7 +1,7 @@ { "name": "@crystallize/js-api-client", "license": "MIT", - "version": "2.2.1", + "version": "2.3.0", "author": "Crystallize (https://crystallize.com)", "contributors": [ "Sébastien Morel ", diff --git a/components/js-api-client/src/core/verifySignature.ts b/components/js-api-client/src/core/verifySignature.ts index d86edd3d..c7a832b6 100644 --- a/components/js-api-client/src/core/verifySignature.ts +++ b/components/js-api-client/src/core/verifySignature.ts @@ -7,12 +7,6 @@ export type SimplifiedRequest = { webhookUrl?: string; }; -export type CreateSignatureVerifierParams = { - sha256: (data: string) => string; - jwtVerify: (token: string, secret: string, options?: any) => CrystallizeSignature; - secret: string; -}; - const newQueryParams = (webhookUrl: string, receivedUrl: string): Record => { const parseQueryString = (url: string): Record => { const urlParams = new URL(url).searchParams; @@ -34,30 +28,75 @@ const newQueryParams = (webhookUrl: string, receivedUrl: string): Record Promise; + jwtVerify: (token: string, secret: string, options?: any) => Promise; + secret: string; +}; + +const buildChallenge = (request: SimplifiedRequest) => { + return { + url: request.url, + method: request.method, + body: request.body ? JSON.parse(request.body) : null, + }; +}; +const buildGETSituationChallenge = (request: SimplifiedRequest) => { + if (request.url && request.webhookUrl && request.method && request.method.toLowerCase() === 'get') { + const body = newQueryParams(request.webhookUrl, request.url); + if (Object.keys(body).length > 0) { + return { + url: request.webhookUrl, + method: request.method, + body, + }; + } + } + return null; +}; + +export const createAsyncSignatureVerifier = ({ sha256, jwtVerify, secret }: CreateAsyncSignatureVerifierParams) => { + return async (signature: string, request: SimplifiedRequest): Promise => { + try { + const payload = await jwtVerify(signature, secret); + const isValid = async (challenge: any) => payload.hmac === (await sha256(JSON.stringify(challenge))); + const challenge = buildChallenge(request); + if (!(await isValid(challenge))) { + const newChallenge = buildGETSituationChallenge(request); + if (newChallenge && (await isValid(newChallenge))) { + return payload; + } + throw new Error('Invalid signature. HMAC does not match.'); + } + return payload; + } catch (exception: any) { + throw new Error('Invalid signature. ' + exception.message); + } + }; +}; + +/** + * @deprecated you should use the `CreateAsyncSignatureVerifierParams` type instead + */ +export type CreateSignatureVerifierParams = { + sha256: (data: string) => string; + jwtVerify: (token: string, secret: string, options?: any) => CrystallizeSignature; + secret: string; +}; + +/** + * @deprecated you should use the `createAsyncSignatureVerifier` function instead + */ export const createSignatureVerifier = ({ sha256, jwtVerify, secret }: CreateSignatureVerifierParams) => { - return (signature: string, request: SimplifiedRequest): any => { + return (signature: string, request: SimplifiedRequest): CrystallizeSignature => { try { const payload = jwtVerify(signature, secret); const isValid = (challenge: any) => payload.hmac === sha256(JSON.stringify(challenge)); - const challenge = { - url: request.url, - method: request.method, - body: request.body ? JSON.parse(request.body) : null, - }; + const challenge = buildChallenge(request); if (!isValid(challenge)) { - // we are going to do another check here for the webhook payload situation - if (request.url && request.webhookUrl && request.method && request.method.toLowerCase() === 'get') { - const body = newQueryParams(request.webhookUrl, request.url); - if (Object.keys(body).length > 0) { - const newChallenge = { - url: request.webhookUrl, - method: request.method, - body, - }; - if (isValid(newChallenge)) { - return payload; - } - } + const newChallenge = buildGETSituationChallenge(request); + if (newChallenge && isValid(newChallenge)) { + return payload; } throw new Error('Invalid signature. HMAC does not match.'); } diff --git a/components/js-api-client/tests/signature.test.js b/components/js-api-client/tests/signature.test.js index ce468149..f36efe29 100644 --- a/components/js-api-client/tests/signature.test.js +++ b/components/js-api-client/tests/signature.test.js @@ -1,7 +1,7 @@ -const { createSignatureVerifier } = require('../dist/index.js'); +const { createSignatureVerifier, createAsyncSignatureVerifier } = require('../dist/index.js'); var crypto = require('crypto'); -describe('Test Signature HMAC', () => { +describe('Test Signature HMAC with Deprecated Sync Function', () => { test('Test With a Body', () => { const guard = createSignatureVerifier({ secret: 'xXx', @@ -11,13 +11,12 @@ describe('Test Signature HMAC', () => { }), }); - expect( - guard('xXx.xXx.xXx', { - url: 'https://a17e-2601-645-4500-330-b07d-351d-ece7-41c1.ngrok.io/test/signature', - method: 'POST', - body: '{"item":{"get":{"id":"63f2d3b2a94533f79fc6397b","createdAt":"2023-02-20T01:58:10.000Z","updatedAt":"2023-02-23T07:58:34.685Z","name":"test"}}}', - }), - ); + const payload = guard('xXx.xXx.xXx', { + url: 'https://a17e-2601-645-4500-330-b07d-351d-ece7-41c1.ngrok.io/test/signature', + method: 'POST', + body: '{"item":{"get":{"id":"63f2d3b2a94533f79fc6397b","createdAt":"2023-02-20T01:58:10.000Z","updatedAt":"2023-02-23T07:58:34.685Z","name":"test"}}}', + }); + expect(payload.hmac).toBe('1101b34dac8c55e5590a37271f1c41c3d745463854613494a1624a15be24f1f8'); }); test('Test Without a Body App', () => { @@ -28,14 +27,12 @@ describe('Test Signature HMAC', () => { hmac: '157bee342a4856e14e964356fef54fd84b3a3508c1071ed674172d3f9b68892f', }), }); - - expect( - guard('xXx.xXx.xXx', { - url: 'https://helloworld.crystallize.app.local', - method: 'GET', - body: null, - }), - ); + const payload = guard('xXx.xXx.xXx', { + url: 'https://helloworld.crystallize.app.local', + method: 'GET', + body: null, + }); + expect(payload.hmac).toBe('157bee342a4856e14e964356fef54fd84b3a3508c1071ed674172d3f9b68892f'); }); test('Test Without a Body Webhook', () => { @@ -46,12 +43,66 @@ describe('Test Signature HMAC', () => { hmac: '61ce7a2e5072900a13369ac7f69b9e056e91c38c42f1bfe94389c80411d94b78', }), }); - expect( - guard('xXx.xXx.xXx', { - url: 'https://webhook.site/b56870a7-9600-41a6-86a0-98be0c7532fd?id=65d8fc4ce2ba75beec481ec1&userId=61f9933ec63b0a44d5004c2d&tenantId=61f9937c3b63c8386ea9e153&type=document&language=en', - webhookUrl: 'https://webhook.site/b56870a7-9600-41a6-86a0-98be0c7532fd', - method: 'GET', + + const payload = guard('xXx.xXx.xXx', { + url: 'https://webhook.site/b56870a7-9600-41a6-86a0-98be0c7532fd?id=65d8fc4ce2ba75beec481ec1&userId=61f9933ec63b0a44d5004c2d&tenantId=61f9937c3b63c8386ea9e153&type=document&language=en', + webhookUrl: 'https://webhook.site/b56870a7-9600-41a6-86a0-98be0c7532fd', + method: 'GET', + }); + + expect(payload.hmac).toBe('61ce7a2e5072900a13369ac7f69b9e056e91c38c42f1bfe94389c80411d94b78'); + }); +}); + +describe('Test Signature HMAC with Async verifier and ASync Functions', () => { + test('Test With a Body', async () => { + const guard = createAsyncSignatureVerifier({ + secret: 'xXx', + sha256: async (data) => crypto.createHash('sha256').update(data).digest('hex'), + jwtVerify: async (token, secret) => ({ + hmac: '1101b34dac8c55e5590a37271f1c41c3d745463854613494a1624a15be24f1f8', + }), + }); + + const payload = await guard('xXx.xXx.xXx', { + url: 'https://a17e-2601-645-4500-330-b07d-351d-ece7-41c1.ngrok.io/test/signature', + method: 'POST', + body: '{"item":{"get":{"id":"63f2d3b2a94533f79fc6397b","createdAt":"2023-02-20T01:58:10.000Z","updatedAt":"2023-02-23T07:58:34.685Z","name":"test"}}}', + }); + expect(payload.hmac).toBe('1101b34dac8c55e5590a37271f1c41c3d745463854613494a1624a15be24f1f8'); + }); + + test('Test Without a Body App', async () => { + const guard = createAsyncSignatureVerifier({ + secret: 'xXx', + sha256: async (data) => crypto.createHash('sha256').update(data).digest('hex'), + jwtVerify: async (token, secret) => ({ + hmac: '157bee342a4856e14e964356fef54fd84b3a3508c1071ed674172d3f9b68892f', + }), + }); + const payload = await guard('xXx.xXx.xXx', { + url: 'https://helloworld.crystallize.app.local', + method: 'GET', + body: null, + }); + expect(payload.hmac).toBe('157bee342a4856e14e964356fef54fd84b3a3508c1071ed674172d3f9b68892f'); + }); + + test('Test Without a Body Webhook', async () => { + const guard = createAsyncSignatureVerifier({ + secret: 'xXx', + sha256: async (data) => crypto.createHash('sha256').update(data).digest('hex'), + jwtVerify: async (token, secret) => ({ + hmac: '61ce7a2e5072900a13369ac7f69b9e056e91c38c42f1bfe94389c80411d94b78', }), - ); + }); + + const payload = await guard('xXx.xXx.xXx', { + url: 'https://webhook.site/b56870a7-9600-41a6-86a0-98be0c7532fd?id=65d8fc4ce2ba75beec481ec1&userId=61f9933ec63b0a44d5004c2d&tenantId=61f9937c3b63c8386ea9e153&type=document&language=en', + webhookUrl: 'https://webhook.site/b56870a7-9600-41a6-86a0-98be0c7532fd', + method: 'GET', + }); + + expect(payload.hmac).toBe('61ce7a2e5072900a13369ac7f69b9e056e91c38c42f1bfe94389c80411d94b78'); }); });