Skip to content

Commit

Permalink
feat(core): sign hook payload data
Browse files Browse the repository at this point in the history
  • Loading branch information
xiaoyijun committed May 18, 2023
1 parent c28ff47 commit 5ffa9c9
Show file tree
Hide file tree
Showing 6 changed files with 111 additions and 18 deletions.
1 change: 1 addition & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
30 changes: 20 additions & 10 deletions packages/core/src/libraries/hook.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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 },
});
Expand Down
30 changes: 22 additions & 8 deletions packages/core/src/libraries/hook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,20 +7,39 @@ 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,
// eslint-disable-next-line no-restricted-syntax
body: trySafe(() => JSON.parse(String(body)) as unknown) ?? String(body),
});

const createHookRequestOptions = (
signingKey: string,
payload: HookEventPayload,
customHeaders?: Record<string, string>,
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, HookEvent> = {
[InteractionEvent.Register]: HookEvent.PostRegister,
[InteractionEvent.SignIn]: HookEvent.PostSignIn,
Expand Down Expand Up @@ -81,7 +100,7 @@ export const createHookLibrary = (queries: Queries) => {
} satisfies Omit<HookEventPayload, 'hookId'>;

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}`);
Expand All @@ -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),
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/utils/signature.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
10 changes: 10 additions & 0 deletions packages/core/src/utils/signature.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { createHmac } from 'node:crypto';

import { canonicalize } from 'json-canonicalize';

export const generateSignature = (signingKey: string, payload: Record<string, unknown>) => {
const hmac = createHmac('sha256', signingKey);
const payloadString = canonicalize(payload);
hmac.update(payloadString);
return `sha256=${hmac.digest('hex')}`;
};
7 changes: 7 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 5ffa9c9

Please sign in to comment.