From 5ffa9c99e01c56e6479e24419babba8ed0587098 Mon Sep 17 00:00:00 2001 From: Xiao Yijun Date: Thu, 18 May 2023 10:50:32 +0800 Subject: [PATCH] feat(core): sign hook payload data --- packages/core/package.json | 1 + packages/core/src/libraries/hook.test.ts | 30 ++++++++----- packages/core/src/libraries/hook.ts | 30 +++++++++---- packages/core/src/utils/signature.test.ts | 51 +++++++++++++++++++++++ packages/core/src/utils/signature.ts | 10 +++++ pnpm-lock.yaml | 7 ++++ 6 files changed, 111 insertions(+), 18 deletions(-) create mode 100644 packages/core/src/utils/signature.test.ts create mode 100644 packages/core/src/utils/signature.ts diff --git a/packages/core/package.json b/packages/core/package.json index 02f0002f6b8c..56b702ab5048 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -101,6 +101,7 @@ "eslint": "^8.34.0", "jest": "^29.5.0", "jest-matcher-specific-error": "^1.0.0", + "json-canonicalize": "^1.0.5", "lint-staged": "^13.0.0", "node-mocks-http": "^1.12.1", "nodemon": "^2.0.19", diff --git a/packages/core/src/libraries/hook.test.ts b/packages/core/src/libraries/hook.test.ts index 5657e87a7d89..c8c05f4c3294 100644 --- a/packages/core/src/libraries/hook.test.ts +++ b/packages/core/src/libraries/hook.test.ts @@ -3,6 +3,8 @@ import { HookEvent, InteractionEvent, LogResult } from '@logto/schemas'; import { createMockUtils } from '@logto/shared/esm'; import { got } from 'got'; +import { generateSignature } from '#src/utils/signature.js'; + import type { Interaction } from './hook.js'; const { jest } = import.meta; @@ -76,19 +78,27 @@ describe('triggerInteractionHooksIfNeeded()', () => { } as Interaction ); + const expectedPayload = { + hookId: 'foo', + event: 'PostSignIn', + interactionEvent: 'SignIn', + sessionId: 'some_jti', + userId: '123', + user: { id: 'user_id', username: 'user' }, + application: { id: 'app_id' }, + createdAt: new Date(100_000).toISOString(), + }; + + const expectedSignature = generateSignature(hook.signingKey, expectedPayload); + expect(findAllHooks).toHaveBeenCalled(); expect(post).toHaveBeenCalledWith(url, { - headers: { 'user-agent': 'Logto (https://logto.io)', bar: 'baz' }, - json: { - hookId: 'foo', - event: 'PostSignIn', - interactionEvent: 'SignIn', - sessionId: 'some_jti', - userId: '123', - user: { id: 'user_id', username: 'user' }, - application: { id: 'app_id' }, - createdAt: new Date(100_000).toISOString(), + headers: { + 'user-agent': 'Logto (https://logto.io)', + bar: 'baz', + 'x-logto-signature-256': expectedSignature, }, + json: expectedPayload, retry: { limit: 3 }, timeout: { request: 10_000 }, }); diff --git a/packages/core/src/libraries/hook.ts b/packages/core/src/libraries/hook.ts index cacbf4683d5f..16454ae1d759 100644 --- a/packages/core/src/libraries/hook.ts +++ b/packages/core/src/libraries/hook.ts @@ -7,13 +7,14 @@ import { } from '@logto/schemas'; import { generateStandardId } from '@logto/shared'; import { conditional, pick, trySafe } from '@silverhand/essentials'; -import type { Response } from 'got'; +import type { OptionsOfTextResponseBody, Response } from 'got'; import { got, HTTPError } from 'got'; import type Provider from 'oidc-provider'; import { LogEntry } from '#src/middleware/koa-audit-log.js'; import type Queries from '#src/tenants/Queries.js'; import { consoleLog } from '#src/utils/console.js'; +import { generateSignature } from '#src/utils/signature.js'; const parseResponse = ({ statusCode, body }: Response) => ({ statusCode, @@ -21,6 +22,24 @@ const parseResponse = ({ statusCode, body }: Response) => ({ body: trySafe(() => JSON.parse(String(body)) as unknown) ?? String(body), }); +const createHookRequestOptions = ( + signingKey: string, + payload: HookEventPayload, + customHeaders?: Record, + retries?: number +): OptionsOfTextResponseBody => ({ + headers: { + 'user-agent': 'Logto (https://logto.io)', + ...customHeaders, + ...conditional( + signingKey && { 'x-logto-signature-256': generateSignature(signingKey, payload) } + ), + }, + json: payload, + retry: { limit: retries ?? 3 }, + timeout: { request: 10_000 }, +}); + const eventToHook: Record = { [InteractionEvent.Register]: HookEvent.PostRegister, [InteractionEvent.SignIn]: HookEvent.PostSignIn, @@ -81,7 +100,7 @@ export const createHookLibrary = (queries: Queries) => { } satisfies Omit; await Promise.all( - rows.map(async ({ config: { url, headers, retries }, id }) => { + rows.map(async ({ config: { url, headers, retries }, id, signingKey }) => { consoleLog.info(`\tTriggering hook ${id} due to ${hookEvent} event`); const json: HookEventPayload = { hookId: id, ...payload }; const logEntry = new LogEntry(`TriggerHook.${hookEvent}`); @@ -90,12 +109,7 @@ export const createHookLibrary = (queries: Queries) => { // Trigger web hook and log response await got - .post(url, { - headers: { 'user-agent': 'Logto (https://logto.io)', ...headers }, - json, - retry: { limit: retries ?? 3 }, - timeout: { request: 10_000 }, - }) + .post(url, createHookRequestOptions(signingKey, json, headers, retries)) .then(async (response) => { logEntry.append({ response: parseResponse(response), diff --git a/packages/core/src/utils/signature.test.ts b/packages/core/src/utils/signature.test.ts new file mode 100644 index 000000000000..8ceb16934fa7 --- /dev/null +++ b/packages/core/src/utils/signature.test.ts @@ -0,0 +1,51 @@ +import { generateSignature } from './signature.js'; + +describe('generateSignature()', () => { + it('should generate correct signature', () => { + const signingKey = 'foo'; + const payload = { + foo: 'foo', + bar: 'bar', + }; + + const signature = generateSignature(signingKey, payload); + + expect(signature).toBe( + 'sha256=436958f1dbfefab37712fb3927760490fbf7757da8c0b2306ee7b485f0360eee' + ); + }); + + it('should generate correct signature if payload is empty', () => { + const signingKey = 'foo'; + const payload = {}; + const signature = generateSignature(signingKey, payload); + + expect(signature).toBe( + 'sha256=c76356efa19d219d1d7e08ccb20b1d26db53b143156f406c99dcb8e0876d6c55' + ); + }); + + it('should generate the same signature if payload contents are the same but payload JSON strings are not the same', () => { + const signingKey = 'foo'; + + const payload = { + foo: 'foo', + bar: { + baz: 'baz', + }, + }; + + const disorderedPayload = { + bar: { + baz: 'baz', + }, + foo: 'foo', + }; + + const signature = generateSignature(signingKey, payload); + const signatureByDisorderedPayload = generateSignature(signingKey, disorderedPayload); + + expect(JSON.stringify(payload)).not.toEqual(JSON.stringify(disorderedPayload)); + expect(signature).toEqual(signatureByDisorderedPayload); + }); +}); diff --git a/packages/core/src/utils/signature.ts b/packages/core/src/utils/signature.ts new file mode 100644 index 000000000000..84cdac91c0be --- /dev/null +++ b/packages/core/src/utils/signature.ts @@ -0,0 +1,10 @@ +import { createHmac } from 'node:crypto'; + +import { canonicalize } from 'json-canonicalize'; + +export const generateSignature = (signingKey: string, payload: Record) => { + const hmac = createHmac('sha256', signingKey); + const payloadString = canonicalize(payload); + hmac.update(payloadString); + return `sha256=${hmac.digest('hex')}`; +}; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1f0076f6a05b..e102538dd01e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3267,6 +3267,9 @@ importers: jest-matcher-specific-error: specifier: ^1.0.0 version: 1.0.0 + json-canonicalize: + specifier: ^1.0.5 + version: 1.0.5 lint-staged: specifier: ^13.0.0 version: 13.0.0 @@ -14503,6 +14506,10 @@ packages: /json-buffer@3.0.1: resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + /json-canonicalize@1.0.5: + resolution: {integrity: sha512-3fyWChA4FSgw6kVZW38PFIOJtPoFUR6MGENN4LUXsWmZOBpU1NaSWTlBhE1/ZTX67gEUzihCfw1wUCWsHDKNyA==} + dev: true + /json-parse-even-better-errors@2.3.1: resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==}