diff --git a/src/app/api/webhooks/logto/__tests__/route.test.ts b/src/app/api/webhooks/logto/__tests__/route.test.ts new file mode 100644 index 000000000000..8c1fe7c52537 --- /dev/null +++ b/src/app/api/webhooks/logto/__tests__/route.test.ts @@ -0,0 +1,92 @@ +import { createHmac } from 'node:crypto'; +import { describe, expect, it } from 'vitest'; + +interface UserDataUpdatedEvent { + event: string; + createdAt: string; + userAgent: string; + ip: string; + path: string; + method: string; + status: number; + params: { + userId: string; + }; + matchedRoute: string; + data: { + id: string; + username: string; + primaryEmail: string; + primaryPhone: string | null; + name: string; + avatar: string | null; + customData: Record; + identities: Record; + lastSignInAt: number; + createdAt: number; + updatedAt: number; + profile: Record; + applicationId: string; + isSuspended: boolean; + }; + hookId: string; +} + +const userDataUpdatedEvent: UserDataUpdatedEvent = { + event: 'User.Data.Updated', + createdAt: '2024-09-07T08:29:09.381Z', + userAgent: + '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', + ip: '223.104.76.217', + path: '/users/rra41h9vmpnd', + method: 'PATCH', + status: 200, + params: { + userId: 'rra41h9vmpnd', + }, + matchedRoute: '/users/:userId', + data: { + id: 'uid', + username: 'test', + primaryEmail: 'user@example.com', + primaryPhone: null, + name: 'test', + avatar: null, + customData: {}, + identities: {}, + lastSignInAt: 1725446291545, + createdAt: 1725440405556, + updatedAt: 1725697749337, + profile: {}, + applicationId: 'appid', + isSuspended: false, + }, + hookId: 'hookId', +}; + +const LOGTO_WEBHOOK_SIGNING_KEY = 'logto-signing-key'; + +// Test Logto Webhooks in Local dev, here is some tips: +// - Replace the var `LOGTO_WEBHOOK_SIGNING_KEY` with the actual value in your `.env` file +// - Start web request: If you want to run the test, replace `describe.skip` with `describe` below + +describe.skip('Test Logto Webhooks in Local dev', () => { + // describe('Test Logto Webhooks in Local dev', () => { + it('should send a POST request with logto headers', async () => { + const url = 'http://localhost:3010/api/webhooks/logto'; // 替换为目标URL + const data = userDataUpdatedEvent; + // Generate data signature + const hmac = createHmac('sha256', LOGTO_WEBHOOK_SIGNING_KEY!); + hmac.update(JSON.stringify(data)); + const signature = hmac.digest('hex'); + const response = await fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'logto-signature-sha-256': signature, + }, + body: JSON.stringify(data), + }); + expect(response.status).toBe(200); // 检查响应状态 + }); +}); diff --git a/src/app/api/webhooks/logto/route.ts b/src/app/api/webhooks/logto/route.ts new file mode 100644 index 000000000000..d1b67b0ec78f --- /dev/null +++ b/src/app/api/webhooks/logto/route.ts @@ -0,0 +1,40 @@ +import { NextResponse } from 'next/server'; + +import { authEnv } from '@/config/auth'; +import { pino } from '@/libs/logger'; +import { NextAuthUserService } from '@/server/services/nextAuthUser'; + +import { validateRequest } from './validateRequest'; + +export const POST = async (req: Request): Promise => { + const payload = await validateRequest(req, authEnv.LOGTO_WEBHOOK_SIGNING_KEY!); + + if (!payload) { + return NextResponse.json( + { error: 'webhook verification failed or payload was malformed' }, + { status: 400 }, + ); + } + + const { event, data } = payload; + + pino.trace(`logto webhook payload: ${{ data, event }}`); + + const nextAuthUserService = new NextAuthUserService(); + switch (event) { + case 'User.Data.Updated': { + return nextAuthUserService.safeUpdateUser(data.id, { + avatar: data?.avatar, + email: data?.primaryEmail, + fullName: data?.name, + }); + } + + default: { + pino.warn( + `${req.url} received event type "${event}", but no handler is defined for this type`, + ); + return NextResponse.json({ error: `unrecognised payload type: ${event}` }, { status: 400 }); + } + } +}; diff --git a/src/app/api/webhooks/logto/validateRequest.ts b/src/app/api/webhooks/logto/validateRequest.ts new file mode 100644 index 000000000000..0a1fa48a063f --- /dev/null +++ b/src/app/api/webhooks/logto/validateRequest.ts @@ -0,0 +1,50 @@ +import { headers } from 'next/headers'; +import { createHmac } from 'node:crypto'; + +import { authEnv } from '@/config/auth'; + +export type LogtToUserEntity = { + applicationId?: string; + avatar?: string; + createdAt?: string; + customData?: object; + id: string; + identities?: object; + isSuspended?: boolean; + lastSignInAt?: string; + name?: string; + primaryEmail?: string; + primaryPhone?: string; + username?: string; +}; + +interface LogtoWebhookPayload { + // Only support user event currently + data: LogtToUserEntity; + event: string; +} + +export const validateRequest = async (request: Request, signingKey: string) => { + const payloadString = await request.text(); + const headerPayload = headers(); + const logtoHeaderSignature = headerPayload.get('logto-signature-sha-256')!; + try { + const hmac = createHmac('sha256', signingKey); + hmac.update(payloadString); + const signature = hmac.digest('hex'); + if (signature === logtoHeaderSignature) { + return JSON.parse(payloadString) as LogtoWebhookPayload; + } else { + console.warn( + '[logto]: signature verify failed, please check your logto signature in `LOGTO_WEBHOOK_SIGNING_KEY`', + ); + return; + } + } catch (e) { + if (!authEnv.LOGTO_WEBHOOK_SIGNING_KEY) { + throw new Error('`LOGTO_WEBHOOK_SIGNING_KEY` environment variable is missing.'); + } + console.error('[logto]: incoming webhook failed in verification.\n', e); + return; + } +}; diff --git a/src/config/auth.ts b/src/config/auth.ts index d15cddd8724d..3859b8f88045 100644 --- a/src/config/auth.ts +++ b/src/config/auth.ts @@ -200,6 +200,7 @@ export const getAuthConfig = () => { LOGTO_CLIENT_ID: z.string().optional(), LOGTO_CLIENT_SECRET: z.string().optional(), LOGTO_ISSUER: z.string().optional(), + LOGTO_WEBHOOK_SIGNING_KEY: z.string().optional(), }, runtimeEnv: { @@ -257,6 +258,7 @@ export const getAuthConfig = () => { LOGTO_CLIENT_ID: process.env.LOGTO_CLIENT_ID, LOGTO_CLIENT_SECRET: process.env.LOGTO_CLIENT_SECRET, LOGTO_ISSUER: process.env.LOGTO_ISSUER, + LOGTO_WEBHOOK_SIGNING_KEY: process.env.LOGTO_WEBHOOK_SIGNING_KEY, }, }); }; diff --git a/src/server/services/nextAuthUser/index.ts b/src/server/services/nextAuthUser/index.ts new file mode 100644 index 000000000000..73889f2b9929 --- /dev/null +++ b/src/server/services/nextAuthUser/index.ts @@ -0,0 +1,42 @@ +import { NextResponse } from 'next/server'; + +import { serverDB } from '@/database/server'; +import { UserModel } from '@/database/server/models/user'; +import { UserItem } from '@/database/server/schemas/lobechat'; +import { pino } from '@/libs/logger'; +import { LobeNextAuthDbAdapter } from '@/libs/next-auth/adapter'; + +export class NextAuthUserService { + userModel; + adapter; + + constructor() { + this.userModel = new UserModel(); + this.adapter = LobeNextAuthDbAdapter(serverDB); + } + + safeUpdateUser = async (providerAccountId: string, data: Partial) => { + pino.info('updating user due to webhook'); + // 1. Find User by account + // @ts-expect-error: Already impl in `LobeNextauthDbAdapter` + const user = await this.adapter.getUserByAccount({ + provider: 'logto', + providerAccountId, + }); + + // 2. If found, Update user data from provider + if (user?.id) { + // Perform update + await this.userModel.updateUser(user.id, { + avatar: data?.avatar, + email: data?.email, + fullName: data?.fullName, + }); + } else { + pino.warn( + `[logto]: Webhooks handler user update for "${JSON.stringify(data)}", but no user was found by the providerAccountId.`, + ); + } + return NextResponse.json({ message: 'user updated', success: true }, { status: 200 }); + }; +}