@lobehub/chat 1.122.5 → 1.122.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,108 @@
1
+ // @vitest-environment node
2
+ import { NextResponse } from 'next/server';
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
4
+
5
+ import { UserModel } from '@/database/models/user';
6
+ import { UserItem } from '@/database/schemas';
7
+ import { serverDB } from '@/database/server';
8
+ import { pino } from '@/libs/logger';
9
+
10
+ import { NextAuthUserService } from './index';
11
+
12
+ vi.mock('@/libs/logger', () => ({
13
+ pino: {
14
+ info: vi.fn(),
15
+ warn: vi.fn(),
16
+ },
17
+ }));
18
+
19
+ vi.mock('@/database/models/user');
20
+ vi.mock('@/database/server');
21
+
22
+ describe('NextAuthUserService', () => {
23
+ let service: NextAuthUserService;
24
+
25
+ beforeEach(async () => {
26
+ vi.clearAllMocks();
27
+ service = new NextAuthUserService(serverDB);
28
+ });
29
+
30
+ describe('safeUpdateUser', () => {
31
+ const mockUser = {
32
+ id: 'user-123',
33
+ email: 'test@example.com',
34
+ };
35
+
36
+ const mockAccount = {
37
+ provider: 'github',
38
+ providerAccountId: '12345',
39
+ };
40
+
41
+ const mockUpdateData: Partial<UserItem> = {
42
+ avatar: 'https://example.com/avatar.jpg',
43
+ email: 'new@example.com',
44
+ fullName: 'Test User',
45
+ };
46
+
47
+ it('should update user when user is found', async () => {
48
+ const mockUserModel = {
49
+ updateUser: vi.fn().mockResolvedValue({}),
50
+ };
51
+
52
+ vi.mocked(UserModel).mockImplementation(() => mockUserModel as any);
53
+
54
+ // Mock the adapter directly on the service instance
55
+ service.adapter = {
56
+ getUserByAccount: vi.fn().mockResolvedValue(mockUser),
57
+ };
58
+
59
+ const response = await service.safeUpdateUser(mockAccount, mockUpdateData);
60
+
61
+ expect(pino.info).toHaveBeenCalledWith(
62
+ `updating user "${JSON.stringify(mockAccount)}" due to webhook`,
63
+ );
64
+
65
+ expect(service.adapter.getUserByAccount).toHaveBeenCalledWith(mockAccount);
66
+ expect(UserModel).toHaveBeenCalledWith(serverDB, mockUser.id);
67
+ expect(mockUserModel.updateUser).toHaveBeenCalledWith(mockUpdateData);
68
+
69
+ expect(response).toBeInstanceOf(NextResponse);
70
+ expect(response.status).toBe(200);
71
+ const data = await response.json();
72
+ expect(data).toEqual({ message: 'user updated', success: true });
73
+ });
74
+
75
+ it('should handle case when user is not found', async () => {
76
+ // Mock the adapter directly on the service instance
77
+ service.adapter = {
78
+ getUserByAccount: vi.fn().mockResolvedValue(null),
79
+ };
80
+
81
+ const response = await service.safeUpdateUser(mockAccount, mockUpdateData);
82
+
83
+ expect(pino.warn).toHaveBeenCalledWith(
84
+ `[${mockAccount.provider}]: Webhooks handler user "${JSON.stringify(mockAccount)}" update for "${JSON.stringify(mockUpdateData)}", but no user was found by the providerAccountId.`,
85
+ );
86
+
87
+ expect(UserModel).not.toHaveBeenCalled();
88
+
89
+ expect(response).toBeInstanceOf(NextResponse);
90
+ expect(response.status).toBe(200);
91
+ const data = await response.json();
92
+ expect(data).toEqual({ message: 'user updated', success: true });
93
+ });
94
+
95
+ it('should handle errors during user update', async () => {
96
+ const mockError = new Error('Database error');
97
+
98
+ // Mock the adapter directly on the service instance
99
+ service.adapter = {
100
+ getUserByAccount: vi.fn().mockRejectedValue(mockError),
101
+ };
102
+
103
+ await expect(service.safeUpdateUser(mockAccount, mockUpdateData)).rejects.toThrow(mockError);
104
+
105
+ expect(UserModel).not.toHaveBeenCalled();
106
+ });
107
+ });
108
+ });
@@ -1,33 +1,18 @@
1
- import { and, eq } from 'drizzle-orm';
2
- import { Adapter, AdapterAccount } from 'next-auth/adapters';
3
1
  import { NextResponse } from 'next/server';
4
2
 
5
3
  import { UserModel } from '@/database/models/user';
6
- import {
7
- UserItem,
8
- nextauthAccounts,
9
- nextauthAuthenticators,
10
- nextauthSessions,
11
- nextauthVerificationTokens,
12
- users,
13
- } from '@/database/schemas';
4
+ import { UserItem } from '@/database/schemas';
14
5
  import { LobeChatDatabase } from '@/database/type';
15
6
  import { pino } from '@/libs/logger';
16
- import { merge } from '@/utils/merge';
17
-
18
- import { AgentService } from '../agent';
19
- import {
20
- mapAdapterUserToLobeUser,
21
- mapAuthenticatorQueryResutlToAdapterAuthenticator,
22
- mapLobeUserToAdapterUser,
23
- partialMapAdapterUserToLobeUser,
24
- } from './utils';
7
+ import { LobeNextAuthDbAdapter } from '@/libs/next-auth/adapter';
25
8
 
26
9
  export class NextAuthUserService {
10
+ adapter;
27
11
  private db: LobeChatDatabase;
28
12
 
29
13
  constructor(db: LobeChatDatabase) {
30
14
  this.db = db;
15
+ this.adapter = LobeNextAuthDbAdapter(db);
31
16
  }
32
17
 
33
18
  safeUpdateUser = async (
@@ -36,7 +21,8 @@ export class NextAuthUserService {
36
21
  ) => {
37
22
  pino.info(`updating user "${JSON.stringify({ provider, providerAccountId })}" due to webhook`);
38
23
  // 1. Find User by account
39
- const user = await this.getUserByAccount({
24
+ // @ts-expect-error: Already impl in `LobeNextauthDbAdapter`
25
+ const user = await this.adapter.getUserByAccount({
40
26
  provider,
41
27
  providerAccountId,
42
28
  });
@@ -58,266 +44,4 @@ export class NextAuthUserService {
58
44
  }
59
45
  return NextResponse.json({ message: 'user updated', success: true }, { status: 200 });
60
46
  };
61
-
62
- safeSignOutUser = async ({
63
- providerAccountId,
64
- provider,
65
- }: {
66
- provider: string;
67
- providerAccountId: string;
68
- }) => {
69
- pino.info(`Signing out user "${JSON.stringify({ provider, providerAccountId })}"`);
70
- const user = await this.getUserByAccount({
71
- provider,
72
- providerAccountId,
73
- });
74
-
75
- // 2. If found, Update user data from provider
76
- if (user?.id) {
77
- // Perform update
78
- await this.db.delete(nextauthSessions).where(eq(nextauthSessions.userId, user.id));
79
- } else {
80
- pino.warn(
81
- `[${provider}]: Webhooks handler user "${JSON.stringify({ provider, providerAccountId })}" to signout", but no user was found by the providerAccountId.`,
82
- );
83
- }
84
- return NextResponse.json({ message: 'user signed out', success: true }, { status: 200 });
85
- };
86
-
87
- createAuthenticator: NonNullable<Adapter['createAuthenticator']> = async (authenticator) => {
88
- return await this.db
89
- .insert(nextauthAuthenticators)
90
- .values(authenticator)
91
- .returning()
92
- .then((res: any) => res[0] ?? undefined);
93
- };
94
-
95
- createSession: NonNullable<Adapter['createSession']> = async (data) => {
96
- return await this.db
97
- .insert(nextauthSessions)
98
- .values(data)
99
- .returning()
100
- .then((res: any) => res[0]);
101
- };
102
-
103
- createUser: NonNullable<Adapter['createUser']> = async (user) => {
104
- const { id, name, email, emailVerified, image, providerAccountId } = user;
105
- // return the user if it already exists
106
- let existingUser =
107
- email && typeof email === 'string' && email.trim()
108
- ? await UserModel.findByEmail(this.db, email)
109
- : undefined;
110
- // If the user is not found by email, try to find by providerAccountId
111
- if (!existingUser && providerAccountId) {
112
- existingUser = await UserModel.findById(this.db, providerAccountId);
113
- }
114
- if (existingUser) {
115
- const adapterUser = mapLobeUserToAdapterUser(existingUser);
116
- return adapterUser;
117
- }
118
-
119
- // create a new user if it does not exist
120
- // Use id from provider if it exists, otherwise use id assigned by next-auth
121
- // ref: https://github.com/lobehub/lobe-chat/pull/2935
122
- const uid = providerAccountId ?? id;
123
- await UserModel.createUser(
124
- this.db,
125
- mapAdapterUserToLobeUser({
126
- email,
127
- emailVerified,
128
- // Use providerAccountId as userid to identify if the user exists in a SSO provider
129
- id: uid,
130
- image,
131
- name,
132
- }),
133
- );
134
-
135
- // 3. Create an inbox session for the user
136
- const agentService = new AgentService(this.db, uid);
137
- await agentService.createInbox();
138
-
139
- return { ...user, id: uid };
140
- };
141
-
142
- createVerificationToken: NonNullable<Adapter['createVerificationToken']> = async (data) => {
143
- return await this.db
144
- .insert(nextauthVerificationTokens)
145
- .values(data)
146
- .returning()
147
- .then((res: any) => res[0]);
148
- };
149
-
150
- deleteSession: NonNullable<Adapter['deleteSession']> = async (sessionToken) => {
151
- await this.db.delete(nextauthSessions).where(eq(nextauthSessions.sessionToken, sessionToken));
152
- };
153
-
154
- deleteUser: NonNullable<Adapter['deleteUser']> = async (id) => {
155
- const user = await UserModel.findById(this.db, id);
156
- if (!user) throw new Error('NextAuth: Delete User not found');
157
- await UserModel.deleteUser(this.db, id);
158
- };
159
-
160
- getAccount: NonNullable<Adapter['getAccount']> = async (providerAccountId, provider) => {
161
- return (await this.db
162
- .select()
163
- .from(nextauthAccounts)
164
- .where(
165
- and(
166
- eq(nextauthAccounts.provider, provider),
167
- eq(nextauthAccounts.providerAccountId, providerAccountId),
168
- ),
169
- )
170
- .then((res: any) => res[0] ?? null)) as Promise<AdapterAccount | null>;
171
- };
172
-
173
- getAuthenticator: NonNullable<Adapter['getAuthenticator']> = async (credentialID) => {
174
- const result = await this.db
175
- .select()
176
- .from(nextauthAuthenticators)
177
- .where(eq(nextauthAuthenticators.credentialID, credentialID))
178
- .then((res) => res[0] ?? null);
179
- if (!result) throw new Error('NextAuthUserService: Failed to get authenticator');
180
- return mapAuthenticatorQueryResutlToAdapterAuthenticator(result);
181
- };
182
-
183
- getSessionAndUser: NonNullable<Adapter['getSessionAndUser']> = async (sessionToken) => {
184
- const result = await this.db
185
- .select({
186
- session: nextauthSessions,
187
- user: users,
188
- })
189
- .from(nextauthSessions)
190
- .where(eq(nextauthSessions.sessionToken, sessionToken))
191
- .innerJoin(users, eq(users.id, nextauthSessions.userId))
192
- .then((res: any) => (res.length > 0 ? res[0] : null));
193
-
194
- if (!result) return null;
195
- const adapterUser = mapLobeUserToAdapterUser(result.user);
196
- if (!adapterUser) return null;
197
- return {
198
- session: result.session,
199
- user: adapterUser,
200
- };
201
- };
202
-
203
- getUser: NonNullable<Adapter['getUser']> = async (id) => {
204
- const lobeUser = await UserModel.findById(this.db, id);
205
- if (!lobeUser) return null;
206
- return mapLobeUserToAdapterUser(lobeUser);
207
- };
208
-
209
- getUserByAccount: NonNullable<Adapter['getUserByAccount']> = async (account) => {
210
- const result = await this.db
211
- .select({
212
- account: nextauthAccounts,
213
- users,
214
- })
215
- .from(nextauthAccounts)
216
- .innerJoin(users, eq(nextauthAccounts.userId, users.id))
217
- .where(
218
- and(
219
- eq(nextauthAccounts.provider, account.provider),
220
- eq(nextauthAccounts.providerAccountId, account.providerAccountId),
221
- ),
222
- )
223
- .then((res: any) => res[0]);
224
-
225
- return result?.users ? mapLobeUserToAdapterUser(result.users) : null;
226
- };
227
-
228
- getUserByEmail: NonNullable<Adapter['getUserByEmail']> = async (email) => {
229
- const lobeUser =
230
- email && typeof email === 'string' && email.trim()
231
- ? await UserModel.findByEmail(this.db, email)
232
- : undefined;
233
- return lobeUser ? mapLobeUserToAdapterUser(lobeUser) : null;
234
- };
235
-
236
- linkAccount: NonNullable<Adapter['linkAccount']> = async (data) => {
237
- const [account] = await this.db
238
- .insert(nextauthAccounts)
239
- .values(data as any)
240
- .returning();
241
- if (!account) throw new Error('NextAuthAccountModel: Failed to create account');
242
- // TODO Update type annotation
243
- return account as any;
244
- };
245
-
246
- listAuthenticatorsByUserId: NonNullable<Adapter['listAuthenticatorsByUserId']> = async (
247
- userId,
248
- ) => {
249
- const result = await this.db
250
- .select()
251
- .from(nextauthAuthenticators)
252
- .where(eq(nextauthAuthenticators.userId, userId))
253
- .then((res: any) => res);
254
- if (result.length === 0)
255
- throw new Error('NextAuthUserService: Failed to get authenticator list');
256
- return result.map((r: any) => mapAuthenticatorQueryResutlToAdapterAuthenticator(r));
257
- };
258
-
259
- unlinkAccount: NonNullable<Adapter['unlinkAccount']> = async (account) => {
260
- await this.db
261
- .delete(nextauthAccounts)
262
- .where(
263
- and(
264
- eq(nextauthAccounts.provider, account.provider),
265
- eq(nextauthAccounts.providerAccountId, account.providerAccountId),
266
- ),
267
- );
268
- };
269
-
270
- updateAuthenticatorCounter: NonNullable<Adapter['updateAuthenticatorCounter']> = async (
271
- credentialID,
272
- counter,
273
- ) => {
274
- const result = await this.db
275
- .update(nextauthAuthenticators)
276
- .set({ counter })
277
- .where(eq(nextauthAuthenticators.credentialID, credentialID))
278
- .returning()
279
- .then((res: any) => res[0]);
280
- if (!result) throw new Error('NextAuthUserService: Failed to update authenticator counter');
281
- return mapAuthenticatorQueryResutlToAdapterAuthenticator(result);
282
- };
283
-
284
- updateSession: NonNullable<Adapter['updateSession']> = async (data) => {
285
- const res = await this.db
286
- .update(nextauthSessions)
287
- .set(data)
288
- .where(eq(nextauthSessions.sessionToken, data.sessionToken))
289
- .returning();
290
- return res[0];
291
- };
292
-
293
- updateUser: NonNullable<Adapter['updateUser']> = async (user) => {
294
- const lobeUser = await UserModel.findById(this.db, user?.id);
295
- if (!lobeUser) throw new Error('NextAuth: User not found');
296
- const userModel = new UserModel(this.db, user.id);
297
-
298
- const updatedUser = await userModel.updateUser({
299
- ...partialMapAdapterUserToLobeUser(user),
300
- });
301
- if (!updatedUser) throw new Error('NextAuth: Failed to update user');
302
-
303
- // merge new user data with old user data
304
- const newAdapterUser = mapLobeUserToAdapterUser(lobeUser);
305
- if (!newAdapterUser) {
306
- throw new Error('NextAuth: Failed to map user data to adapter user');
307
- }
308
- return merge(newAdapterUser, user);
309
- };
310
-
311
- useVerificationToken: NonNullable<Adapter['useVerificationToken']> = async (identifier_token) => {
312
- return await this.db
313
- .delete(nextauthVerificationTokens)
314
- .where(
315
- and(
316
- eq(nextauthVerificationTokens.identifier, identifier_token.identifier),
317
- eq(nextauthVerificationTokens.token, identifier_token.token),
318
- ),
319
- )
320
- .returning()
321
- .then((res: any) => (res.length > 0 ? res[0] : null));
322
- };
323
47
  }
@@ -1,137 +0,0 @@
1
- import debug from 'debug';
2
- import { NextRequest, NextResponse } from 'next/server';
3
-
4
- import { serverDBEnv } from '@/config/db';
5
- import { serverDB } from '@/database/server';
6
- import { dateKeys } from '@/libs/next-auth/adapter';
7
- import { NextAuthUserService } from '@/server/services/nextAuthUser';
8
-
9
- const log = debug('lobe-next-auth:api:auth:adapter');
10
-
11
- /**
12
- * @description Process the db query for the NextAuth adapter.
13
- * Returns the db query result directly and let NextAuth handle the raw results.
14
- * @returns {
15
- * success: boolean; // Only return false if the database query fails or the action is invalid.
16
- * data?: any;
17
- * error?: string;
18
- * }
19
- */
20
- export async function POST(req: NextRequest) {
21
- try {
22
- // try validate the request
23
- if (
24
- !req.headers.get('Authorization') ||
25
- req.headers.get('Authorization')?.trim() !== `Bearer ${serverDBEnv.KEY_VAULTS_SECRET}`
26
- ) {
27
- log('Unauthorized request, missing or invalid Authorization header');
28
- return NextResponse.json({ error: 'Unauthorized', success: false }, { status: 401 });
29
- }
30
-
31
- // Parse the request body
32
- const data = await req.json();
33
- log('Received request data:', data);
34
- // Preprocess
35
- if (data?.data) {
36
- for (const key of dateKeys) {
37
- if (data?.data && data.data[key]) {
38
- data.data[key] = new Date(data.data[key]);
39
- continue;
40
- }
41
- }
42
- }
43
- const service = new NextAuthUserService(serverDB);
44
- let result;
45
- switch (data.action) {
46
- case 'createAuthenticator': {
47
- result = await service.createAuthenticator(data.data);
48
- break;
49
- }
50
- case 'createSession': {
51
- result = await service.createSession(data.data);
52
- break;
53
- }
54
- case 'createUser': {
55
- result = await service.createUser(data.data);
56
- break;
57
- }
58
- case 'createVerificationToken': {
59
- result = await service.createVerificationToken(data.data);
60
- break;
61
- }
62
- case 'deleteSession': {
63
- result = await service.deleteSession(data.data);
64
- break;
65
- }
66
- case 'deleteUser': {
67
- result = await service.deleteUser(data.data);
68
- break;
69
- }
70
- case 'getAccount': {
71
- result = await service.getAccount(data.data.providerAccountId, data.data.provider);
72
- break;
73
- }
74
- case 'getAuthenticator': {
75
- result = await service.getAuthenticator(data.data);
76
- break;
77
- }
78
- case 'getSessionAndUser': {
79
- result = await service.getSessionAndUser(data.data);
80
- break;
81
- }
82
- case 'getUser': {
83
- result = await service.getUser(data.data);
84
- break;
85
- }
86
- case 'getUserByAccount': {
87
- result = await service.getUserByAccount(data.data);
88
- break;
89
- }
90
- case 'getUserByEmail': {
91
- result = await service.getUserByEmail(data.data);
92
- break;
93
- }
94
- case 'linkAccount': {
95
- result = await service.linkAccount(data.data);
96
- break;
97
- }
98
- case 'listAuthenticatorsByUserId': {
99
- result = await service.listAuthenticatorsByUserId(data.data);
100
- break;
101
- }
102
- case 'unlinkAccount': {
103
- result = await service.unlinkAccount(data.data);
104
- break;
105
- }
106
- case 'updateAuthenticatorCounter': {
107
- result = await service.updateAuthenticatorCounter(
108
- data.data.credentialID,
109
- data.data.counter,
110
- );
111
- break;
112
- }
113
- case 'updateSession': {
114
- result = await service.updateSession(data.data);
115
- break;
116
- }
117
- case 'updateUser': {
118
- result = await service.updateUser(data.data);
119
- break;
120
- }
121
- case 'useVerificationToken': {
122
- result = await service.useVerificationToken(data.data);
123
- break;
124
- }
125
- default: {
126
- return NextResponse.json({ error: 'Invalid action', success: false }, { status: 400 });
127
- }
128
- }
129
- return NextResponse.json({ data: result, success: true });
130
- } catch (error) {
131
- log('Error processing request:');
132
- log(error);
133
- return NextResponse.json({ error, success: false }, { status: 400 });
134
- }
135
- }
136
-
137
- export const runtime = 'nodejs';