Skip to content

Commit

Permalink
🐛 fix: support webhooks for logto (#3774)
Browse files Browse the repository at this point in the history
* ✨ 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]>
  • Loading branch information
cy948 and arvinxx authored Sep 12, 2024
1 parent bdbc647 commit 0cfee6b
Show file tree
Hide file tree
Showing 5 changed files with 226 additions and 0 deletions.
92 changes: 92 additions & 0 deletions src/app/api/webhooks/logto/__tests__/route.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>;
identities: Record<string, unknown>;
lastSignInAt: number;
createdAt: number;
updatedAt: number;
profile: Record<string, unknown>;
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: '[email protected]',
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); // 检查响应状态
});
});
40 changes: 40 additions & 0 deletions src/app/api/webhooks/logto/route.ts
Original file line number Diff line number Diff line change
@@ -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<NextResponse> => {
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 });
}
}
};
50 changes: 50 additions & 0 deletions src/app/api/webhooks/logto/validateRequest.ts
Original file line number Diff line number Diff line change
@@ -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;
}
};
2 changes: 2 additions & 0 deletions src/config/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down Expand Up @@ -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,
},
});
};
Expand Down
42 changes: 42 additions & 0 deletions src/server/services/nextAuthUser/index.ts
Original file line number Diff line number Diff line change
@@ -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<UserItem>) => {
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 });
};
}

0 comments on commit 0cfee6b

Please sign in to comment.