Skip to content

Commit 19f7732

Browse files
cy948arvinxx
andcommitted
🐛 fix: support webhooks for logto (lobehub#3774)
* ✨ feat: support webhooks for logto * :sparles: feat: update more info * ♻️ refactor: allow edit more info by webhook * ♻️ refactor: rename `nextauthUser` to `nextAuthUser` * 🧪 test: + webhooks trigger --------- Co-authored-by: Arvin Xu <[email protected]>
1 parent 3440af3 commit 19f7732

File tree

5 files changed

+226
-0
lines changed

5 files changed

+226
-0
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
import { createHmac } from 'node:crypto';
2+
import { describe, expect, it } from 'vitest';
3+
4+
interface UserDataUpdatedEvent {
5+
event: string;
6+
createdAt: string;
7+
userAgent: string;
8+
ip: string;
9+
path: string;
10+
method: string;
11+
status: number;
12+
params: {
13+
userId: string;
14+
};
15+
matchedRoute: string;
16+
data: {
17+
id: string;
18+
username: string;
19+
primaryEmail: string;
20+
primaryPhone: string | null;
21+
name: string;
22+
avatar: string | null;
23+
customData: Record<string, unknown>;
24+
identities: Record<string, unknown>;
25+
lastSignInAt: number;
26+
createdAt: number;
27+
updatedAt: number;
28+
profile: Record<string, unknown>;
29+
applicationId: string;
30+
isSuspended: boolean;
31+
};
32+
hookId: string;
33+
}
34+
35+
const userDataUpdatedEvent: UserDataUpdatedEvent = {
36+
event: 'User.Data.Updated',
37+
createdAt: '2024-09-07T08:29:09.381Z',
38+
userAgent:
39+
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36 Edg/128.0.0.0',
40+
ip: '223.104.76.217',
41+
path: '/users/rra41h9vmpnd',
42+
method: 'PATCH',
43+
status: 200,
44+
params: {
45+
userId: 'rra41h9vmpnd',
46+
},
47+
matchedRoute: '/users/:userId',
48+
data: {
49+
id: 'uid',
50+
username: 'test',
51+
primaryEmail: '[email protected]',
52+
primaryPhone: null,
53+
name: 'test',
54+
avatar: null,
55+
customData: {},
56+
identities: {},
57+
lastSignInAt: 1725446291545,
58+
createdAt: 1725440405556,
59+
updatedAt: 1725697749337,
60+
profile: {},
61+
applicationId: 'appid',
62+
isSuspended: false,
63+
},
64+
hookId: 'hookId',
65+
};
66+
67+
const LOGTO_WEBHOOK_SIGNING_KEY = 'logto-signing-key';
68+
69+
// Test Logto Webhooks in Local dev, here is some tips:
70+
// - Replace the var `LOGTO_WEBHOOK_SIGNING_KEY` with the actual value in your `.env` file
71+
// - Start web request: If you want to run the test, replace `describe.skip` with `describe` below
72+
73+
describe.skip('Test Logto Webhooks in Local dev', () => {
74+
// describe('Test Logto Webhooks in Local dev', () => {
75+
it('should send a POST request with logto headers', async () => {
76+
const url = 'http://localhost:3010/api/webhooks/logto'; // 替换为目标URL
77+
const data = userDataUpdatedEvent;
78+
// Generate data signature
79+
const hmac = createHmac('sha256', LOGTO_WEBHOOK_SIGNING_KEY!);
80+
hmac.update(JSON.stringify(data));
81+
const signature = hmac.digest('hex');
82+
const response = await fetch(url, {
83+
method: 'POST',
84+
headers: {
85+
'Content-Type': 'application/json',
86+
'logto-signature-sha-256': signature,
87+
},
88+
body: JSON.stringify(data),
89+
});
90+
expect(response.status).toBe(200); // 检查响应状态
91+
});
92+
});

src/app/api/webhooks/logto/route.ts

+40
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { NextResponse } from 'next/server';
2+
3+
import { authEnv } from '@/config/auth';
4+
import { pino } from '@/libs/logger';
5+
import { NextAuthUserService } from '@/server/services/nextAuthUser';
6+
7+
import { validateRequest } from './validateRequest';
8+
9+
export const POST = async (req: Request): Promise<NextResponse> => {
10+
const payload = await validateRequest(req, authEnv.LOGTO_WEBHOOK_SIGNING_KEY!);
11+
12+
if (!payload) {
13+
return NextResponse.json(
14+
{ error: 'webhook verification failed or payload was malformed' },
15+
{ status: 400 },
16+
);
17+
}
18+
19+
const { event, data } = payload;
20+
21+
pino.trace(`logto webhook payload: ${{ data, event }}`);
22+
23+
const nextAuthUserService = new NextAuthUserService();
24+
switch (event) {
25+
case 'User.Data.Updated': {
26+
return nextAuthUserService.safeUpdateUser(data.id, {
27+
avatar: data?.avatar,
28+
email: data?.primaryEmail,
29+
fullName: data?.name,
30+
});
31+
}
32+
33+
default: {
34+
pino.warn(
35+
`${req.url} received event type "${event}", but no handler is defined for this type`,
36+
);
37+
return NextResponse.json({ error: `unrecognised payload type: ${event}` }, { status: 400 });
38+
}
39+
}
40+
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { headers } from 'next/headers';
2+
import { createHmac } from 'node:crypto';
3+
4+
import { authEnv } from '@/config/auth';
5+
6+
export type LogtToUserEntity = {
7+
applicationId?: string;
8+
avatar?: string;
9+
createdAt?: string;
10+
customData?: object;
11+
id: string;
12+
identities?: object;
13+
isSuspended?: boolean;
14+
lastSignInAt?: string;
15+
name?: string;
16+
primaryEmail?: string;
17+
primaryPhone?: string;
18+
username?: string;
19+
};
20+
21+
interface LogtoWebhookPayload {
22+
// Only support user event currently
23+
data: LogtToUserEntity;
24+
event: string;
25+
}
26+
27+
export const validateRequest = async (request: Request, signingKey: string) => {
28+
const payloadString = await request.text();
29+
const headerPayload = headers();
30+
const logtoHeaderSignature = headerPayload.get('logto-signature-sha-256')!;
31+
try {
32+
const hmac = createHmac('sha256', signingKey);
33+
hmac.update(payloadString);
34+
const signature = hmac.digest('hex');
35+
if (signature === logtoHeaderSignature) {
36+
return JSON.parse(payloadString) as LogtoWebhookPayload;
37+
} else {
38+
console.warn(
39+
'[logto]: signature verify failed, please check your logto signature in `LOGTO_WEBHOOK_SIGNING_KEY`',
40+
);
41+
return;
42+
}
43+
} catch (e) {
44+
if (!authEnv.LOGTO_WEBHOOK_SIGNING_KEY) {
45+
throw new Error('`LOGTO_WEBHOOK_SIGNING_KEY` environment variable is missing.');
46+
}
47+
console.error('[logto]: incoming webhook failed in verification.\n', e);
48+
return;
49+
}
50+
};

src/config/auth.ts

+2
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ export const getAuthConfig = () => {
200200
LOGTO_CLIENT_ID: z.string().optional(),
201201
LOGTO_CLIENT_SECRET: z.string().optional(),
202202
LOGTO_ISSUER: z.string().optional(),
203+
LOGTO_WEBHOOK_SIGNING_KEY: z.string().optional(),
203204
},
204205

205206
runtimeEnv: {
@@ -257,6 +258,7 @@ export const getAuthConfig = () => {
257258
LOGTO_CLIENT_ID: process.env.LOGTO_CLIENT_ID,
258259
LOGTO_CLIENT_SECRET: process.env.LOGTO_CLIENT_SECRET,
259260
LOGTO_ISSUER: process.env.LOGTO_ISSUER,
261+
LOGTO_WEBHOOK_SIGNING_KEY: process.env.LOGTO_WEBHOOK_SIGNING_KEY,
260262
},
261263
});
262264
};
+42
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { NextResponse } from 'next/server';
2+
3+
import { serverDB } from '@/database/server';
4+
import { UserModel } from '@/database/server/models/user';
5+
import { UserItem } from '@/database/server/schemas/lobechat';
6+
import { pino } from '@/libs/logger';
7+
import { LobeNextAuthDbAdapter } from '@/libs/next-auth/adapter';
8+
9+
export class NextAuthUserService {
10+
userModel;
11+
adapter;
12+
13+
constructor() {
14+
this.userModel = new UserModel();
15+
this.adapter = LobeNextAuthDbAdapter(serverDB);
16+
}
17+
18+
safeUpdateUser = async (providerAccountId: string, data: Partial<UserItem>) => {
19+
pino.info('updating user due to webhook');
20+
// 1. Find User by account
21+
// @ts-expect-error: Already impl in `LobeNextauthDbAdapter`
22+
const user = await this.adapter.getUserByAccount({
23+
provider: 'logto',
24+
providerAccountId,
25+
});
26+
27+
// 2. If found, Update user data from provider
28+
if (user?.id) {
29+
// Perform update
30+
await this.userModel.updateUser(user.id, {
31+
avatar: data?.avatar,
32+
email: data?.email,
33+
fullName: data?.fullName,
34+
});
35+
} else {
36+
pino.warn(
37+
`[logto]: Webhooks handler user update for "${JSON.stringify(data)}", but no user was found by the providerAccountId.`,
38+
);
39+
}
40+
return NextResponse.json({ message: 'user updated', success: true }, { status: 200 });
41+
};
42+
}

0 commit comments

Comments
 (0)