Skip to content

Commit 5ffa9c9

Browse files
committed
feat(core): sign hook payload data
1 parent c28ff47 commit 5ffa9c9

File tree

6 files changed

+111
-18
lines changed

6 files changed

+111
-18
lines changed

packages/core/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@
101101
"eslint": "^8.34.0",
102102
"jest": "^29.5.0",
103103
"jest-matcher-specific-error": "^1.0.0",
104+
"json-canonicalize": "^1.0.5",
104105
"lint-staged": "^13.0.0",
105106
"node-mocks-http": "^1.12.1",
106107
"nodemon": "^2.0.19",

packages/core/src/libraries/hook.test.ts

+20-10
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { HookEvent, InteractionEvent, LogResult } from '@logto/schemas';
33
import { createMockUtils } from '@logto/shared/esm';
44
import { got } from 'got';
55

6+
import { generateSignature } from '#src/utils/signature.js';
7+
68
import type { Interaction } from './hook.js';
79

810
const { jest } = import.meta;
@@ -76,19 +78,27 @@ describe('triggerInteractionHooksIfNeeded()', () => {
7678
} as Interaction
7779
);
7880

81+
const expectedPayload = {
82+
hookId: 'foo',
83+
event: 'PostSignIn',
84+
interactionEvent: 'SignIn',
85+
sessionId: 'some_jti',
86+
userId: '123',
87+
user: { id: 'user_id', username: 'user' },
88+
application: { id: 'app_id' },
89+
createdAt: new Date(100_000).toISOString(),
90+
};
91+
92+
const expectedSignature = generateSignature(hook.signingKey, expectedPayload);
93+
7994
expect(findAllHooks).toHaveBeenCalled();
8095
expect(post).toHaveBeenCalledWith(url, {
81-
headers: { 'user-agent': 'Logto (https://logto.io)', bar: 'baz' },
82-
json: {
83-
hookId: 'foo',
84-
event: 'PostSignIn',
85-
interactionEvent: 'SignIn',
86-
sessionId: 'some_jti',
87-
userId: '123',
88-
user: { id: 'user_id', username: 'user' },
89-
application: { id: 'app_id' },
90-
createdAt: new Date(100_000).toISOString(),
96+
headers: {
97+
'user-agent': 'Logto (https://logto.io)',
98+
bar: 'baz',
99+
'x-logto-signature-256': expectedSignature,
91100
},
101+
json: expectedPayload,
92102
retry: { limit: 3 },
93103
timeout: { request: 10_000 },
94104
});

packages/core/src/libraries/hook.ts

+22-8
Original file line numberDiff line numberDiff line change
@@ -7,20 +7,39 @@ import {
77
} from '@logto/schemas';
88
import { generateStandardId } from '@logto/shared';
99
import { conditional, pick, trySafe } from '@silverhand/essentials';
10-
import type { Response } from 'got';
10+
import type { OptionsOfTextResponseBody, Response } from 'got';
1111
import { got, HTTPError } from 'got';
1212
import type Provider from 'oidc-provider';
1313

1414
import { LogEntry } from '#src/middleware/koa-audit-log.js';
1515
import type Queries from '#src/tenants/Queries.js';
1616
import { consoleLog } from '#src/utils/console.js';
17+
import { generateSignature } from '#src/utils/signature.js';
1718

1819
const parseResponse = ({ statusCode, body }: Response) => ({
1920
statusCode,
2021
// eslint-disable-next-line no-restricted-syntax
2122
body: trySafe(() => JSON.parse(String(body)) as unknown) ?? String(body),
2223
});
2324

25+
const createHookRequestOptions = (
26+
signingKey: string,
27+
payload: HookEventPayload,
28+
customHeaders?: Record<string, string>,
29+
retries?: number
30+
): OptionsOfTextResponseBody => ({
31+
headers: {
32+
'user-agent': 'Logto (https://logto.io)',
33+
...customHeaders,
34+
...conditional(
35+
signingKey && { 'x-logto-signature-256': generateSignature(signingKey, payload) }
36+
),
37+
},
38+
json: payload,
39+
retry: { limit: retries ?? 3 },
40+
timeout: { request: 10_000 },
41+
});
42+
2443
const eventToHook: Record<InteractionEvent, HookEvent> = {
2544
[InteractionEvent.Register]: HookEvent.PostRegister,
2645
[InteractionEvent.SignIn]: HookEvent.PostSignIn,
@@ -81,7 +100,7 @@ export const createHookLibrary = (queries: Queries) => {
81100
} satisfies Omit<HookEventPayload, 'hookId'>;
82101

83102
await Promise.all(
84-
rows.map(async ({ config: { url, headers, retries }, id }) => {
103+
rows.map(async ({ config: { url, headers, retries }, id, signingKey }) => {
85104
consoleLog.info(`\tTriggering hook ${id} due to ${hookEvent} event`);
86105
const json: HookEventPayload = { hookId: id, ...payload };
87106
const logEntry = new LogEntry(`TriggerHook.${hookEvent}`);
@@ -90,12 +109,7 @@ export const createHookLibrary = (queries: Queries) => {
90109

91110
// Trigger web hook and log response
92111
await got
93-
.post(url, {
94-
headers: { 'user-agent': 'Logto (https://logto.io)', ...headers },
95-
json,
96-
retry: { limit: retries ?? 3 },
97-
timeout: { request: 10_000 },
98-
})
112+
.post(url, createHookRequestOptions(signingKey, json, headers, retries))
99113
.then(async (response) => {
100114
logEntry.append({
101115
response: parseResponse(response),
+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { generateSignature } from './signature.js';
2+
3+
describe('generateSignature()', () => {
4+
it('should generate correct signature', () => {
5+
const signingKey = 'foo';
6+
const payload = {
7+
foo: 'foo',
8+
bar: 'bar',
9+
};
10+
11+
const signature = generateSignature(signingKey, payload);
12+
13+
expect(signature).toBe(
14+
'sha256=436958f1dbfefab37712fb3927760490fbf7757da8c0b2306ee7b485f0360eee'
15+
);
16+
});
17+
18+
it('should generate correct signature if payload is empty', () => {
19+
const signingKey = 'foo';
20+
const payload = {};
21+
const signature = generateSignature(signingKey, payload);
22+
23+
expect(signature).toBe(
24+
'sha256=c76356efa19d219d1d7e08ccb20b1d26db53b143156f406c99dcb8e0876d6c55'
25+
);
26+
});
27+
28+
it('should generate the same signature if payload contents are the same but payload JSON strings are not the same', () => {
29+
const signingKey = 'foo';
30+
31+
const payload = {
32+
foo: 'foo',
33+
bar: {
34+
baz: 'baz',
35+
},
36+
};
37+
38+
const disorderedPayload = {
39+
bar: {
40+
baz: 'baz',
41+
},
42+
foo: 'foo',
43+
};
44+
45+
const signature = generateSignature(signingKey, payload);
46+
const signatureByDisorderedPayload = generateSignature(signingKey, disorderedPayload);
47+
48+
expect(JSON.stringify(payload)).not.toEqual(JSON.stringify(disorderedPayload));
49+
expect(signature).toEqual(signatureByDisorderedPayload);
50+
});
51+
});

packages/core/src/utils/signature.ts

+10
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
import { createHmac } from 'node:crypto';
2+
3+
import { canonicalize } from 'json-canonicalize';
4+
5+
export const generateSignature = (signingKey: string, payload: Record<string, unknown>) => {
6+
const hmac = createHmac('sha256', signingKey);
7+
const payloadString = canonicalize(payload);
8+
hmac.update(payloadString);
9+
return `sha256=${hmac.digest('hex')}`;
10+
};

pnpm-lock.yaml

+7
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)