Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

🐛 fix: support webhooks for logto #3774

Merged
merged 6 commits into from
Sep 12, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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: '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); // 检查响应状态
});
});
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;
}
};
6 changes: 4 additions & 2 deletions src/config/auth.ts
Original file line number Diff line number Diff line change
@@ -95,7 +95,7 @@ export const getAuthConfig = () => {
GENERIC_OIDC_CLIENT_ID: z.string().optional(),
GENERIC_OIDC_CLIENT_SECRET: z.string().optional(),
GENERIC_OIDC_ISSUER: z.string().optional(),

// ZITADEL
ZITADEL_CLIENT_ID: z.string().optional(),
ZITADEL_CLIENT_SECRET: z.string().optional(),
@@ -105,6 +105,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: {
@@ -152,7 +153,7 @@ export const getAuthConfig = () => {
GENERIC_OIDC_CLIENT_ID: process.env.GENERIC_OIDC_CLIENT_ID,
GENERIC_OIDC_CLIENT_SECRET: process.env.GENERIC_OIDC_CLIENT_SECRET,
GENERIC_OIDC_ISSUER: process.env.GENERIC_OIDC_ISSUER,

// ZITADEL
ZITADEL_CLIENT_ID: process.env.ZITADEL_CLIENT_ID,
ZITADEL_CLIENT_SECRET: process.env.ZITADEL_CLIENT_SECRET,
@@ -162,6 +163,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,
},
});
};
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 });
};
}