@lenne.tech/nest-server 11.7.0 → 11.7.2

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.
Files changed (120) hide show
  1. package/dist/config.env.js +17 -1
  2. package/dist/config.env.js.map +1 -1
  3. package/dist/core/common/interfaces/server-options.interface.d.ts +35 -15
  4. package/dist/core/modules/auth/core-auth.controller.d.ts +1 -0
  5. package/dist/core/modules/auth/core-auth.controller.js +29 -3
  6. package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
  7. package/dist/core/modules/auth/core-auth.module.js +14 -1
  8. package/dist/core/modules/auth/core-auth.module.js.map +1 -1
  9. package/dist/core/modules/auth/core-auth.resolver.d.ts +1 -0
  10. package/dist/core/modules/auth/core-auth.resolver.js +21 -3
  11. package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
  12. package/dist/core/modules/auth/exceptions/legacy-auth-disabled.exception.d.ts +4 -0
  13. package/dist/core/modules/auth/exceptions/legacy-auth-disabled.exception.js +17 -0
  14. package/dist/core/modules/auth/exceptions/legacy-auth-disabled.exception.js.map +1 -0
  15. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +9 -0
  16. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +74 -0
  17. package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -0
  18. package/dist/core/modules/auth/interfaces/auth-provider.interface.d.ts +7 -0
  19. package/dist/core/modules/auth/interfaces/auth-provider.interface.js +5 -0
  20. package/dist/core/modules/auth/interfaces/auth-provider.interface.js.map +1 -0
  21. package/dist/core/modules/auth/interfaces/core-auth-user.interface.d.ts +1 -0
  22. package/dist/core/modules/auth/services/core-auth.service.d.ts +10 -1
  23. package/dist/core/modules/auth/services/core-auth.service.js +141 -9
  24. package/dist/core/modules/auth/services/core-auth.service.js.map +1 -1
  25. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +31 -0
  26. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +153 -0
  27. package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -0
  28. package/dist/core/modules/better-auth/better-auth-migration-status.model.d.ts +10 -0
  29. package/dist/core/modules/better-auth/better-auth-migration-status.model.js +57 -0
  30. package/dist/core/modules/better-auth/better-auth-migration-status.model.js.map +1 -0
  31. package/dist/core/modules/better-auth/better-auth-rate-limiter.service.js +1 -1
  32. package/dist/core/modules/better-auth/better-auth-rate-limiter.service.js.map +1 -1
  33. package/dist/core/modules/better-auth/better-auth-user.mapper.d.ts +33 -0
  34. package/dist/core/modules/better-auth/better-auth-user.mapper.js +395 -0
  35. package/dist/core/modules/better-auth/better-auth-user.mapper.js.map +1 -1
  36. package/dist/core/modules/better-auth/better-auth.config.js +29 -10
  37. package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
  38. package/dist/core/modules/better-auth/better-auth.middleware.d.ts +1 -0
  39. package/dist/core/modules/better-auth/better-auth.middleware.js +55 -1
  40. package/dist/core/modules/better-auth/better-auth.middleware.js.map +1 -1
  41. package/dist/core/modules/better-auth/better-auth.module.d.ts +1 -1
  42. package/dist/core/modules/better-auth/better-auth.module.js +46 -18
  43. package/dist/core/modules/better-auth/better-auth.module.js.map +1 -1
  44. package/dist/core/modules/better-auth/better-auth.resolver.js +0 -11
  45. package/dist/core/modules/better-auth/better-auth.resolver.js.map +1 -1
  46. package/dist/core/modules/better-auth/better-auth.service.d.ts +22 -1
  47. package/dist/core/modules/better-auth/better-auth.service.js +209 -8
  48. package/dist/core/modules/better-auth/better-auth.service.js.map +1 -1
  49. package/dist/core/modules/better-auth/better-auth.types.d.ts +2 -0
  50. package/dist/core/modules/better-auth/better-auth.types.js.map +1 -1
  51. package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +1 -0
  52. package/dist/core/modules/better-auth/core-better-auth.controller.js +15 -2
  53. package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
  54. package/dist/core/modules/better-auth/core-better-auth.resolver.d.ts +7 -0
  55. package/dist/core/modules/better-auth/core-better-auth.resolver.js +72 -12
  56. package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
  57. package/dist/core/modules/better-auth/index.d.ts +1 -0
  58. package/dist/core/modules/better-auth/index.js +1 -0
  59. package/dist/core/modules/better-auth/index.js.map +1 -1
  60. package/dist/core/modules/user/core-user.service.d.ts +7 -1
  61. package/dist/core/modules/user/core-user.service.js +57 -3
  62. package/dist/core/modules/user/core-user.service.js.map +1 -1
  63. package/dist/core/modules/user/interfaces/core-user-service-options.interface.d.ts +4 -0
  64. package/dist/core/modules/user/interfaces/core-user-service-options.interface.js +3 -0
  65. package/dist/core/modules/user/interfaces/core-user-service-options.interface.js.map +1 -0
  66. package/dist/core.module.d.ts +3 -0
  67. package/dist/core.module.js +136 -55
  68. package/dist/core.module.js.map +1 -1
  69. package/dist/index.d.ts +5 -0
  70. package/dist/index.js +5 -0
  71. package/dist/index.js.map +1 -1
  72. package/dist/server/modules/auth/auth.resolver.js +2 -0
  73. package/dist/server/modules/auth/auth.resolver.js.map +1 -1
  74. package/dist/server/modules/better-auth/better-auth.module.d.ts +1 -1
  75. package/dist/server/modules/better-auth/better-auth.module.js +2 -1
  76. package/dist/server/modules/better-auth/better-auth.module.js.map +1 -1
  77. package/dist/server/modules/better-auth/better-auth.resolver.d.ts +5 -0
  78. package/dist/server/modules/better-auth/better-auth.resolver.js +27 -11
  79. package/dist/server/modules/better-auth/better-auth.resolver.js.map +1 -1
  80. package/dist/server/modules/user/user.controller.js +0 -8
  81. package/dist/server/modules/user/user.controller.js.map +1 -1
  82. package/dist/server/modules/user/user.service.d.ts +3 -1
  83. package/dist/server/modules/user/user.service.js +7 -3
  84. package/dist/server/modules/user/user.service.js.map +1 -1
  85. package/dist/tsconfig.build.tsbuildinfo +1 -1
  86. package/package.json +1 -1
  87. package/src/config.env.ts +32 -2
  88. package/src/core/common/interfaces/server-options.interface.ts +304 -58
  89. package/src/core/modules/auth/core-auth.controller.ts +94 -6
  90. package/src/core/modules/auth/core-auth.module.ts +15 -1
  91. package/src/core/modules/auth/core-auth.resolver.ts +71 -3
  92. package/src/core/modules/auth/exceptions/legacy-auth-disabled.exception.ts +35 -0
  93. package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +109 -0
  94. package/src/core/modules/auth/interfaces/auth-provider.interface.ts +86 -0
  95. package/src/core/modules/auth/interfaces/core-auth-user.interface.ts +6 -0
  96. package/src/core/modules/auth/services/core-auth.service.ts +245 -6
  97. package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +283 -0
  98. package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +255 -0
  99. package/src/core/modules/better-auth/README.md +565 -208
  100. package/src/core/modules/better-auth/better-auth-migration-status.model.ts +73 -0
  101. package/src/core/modules/better-auth/better-auth-rate-limiter.service.ts +1 -1
  102. package/src/core/modules/better-auth/better-auth-user.mapper.ts +737 -0
  103. package/src/core/modules/better-auth/better-auth.config.ts +45 -15
  104. package/src/core/modules/better-auth/better-auth.middleware.ts +85 -2
  105. package/src/core/modules/better-auth/better-auth.module.ts +83 -27
  106. package/src/core/modules/better-auth/better-auth.resolver.ts +0 -11
  107. package/src/core/modules/better-auth/better-auth.service.ts +367 -12
  108. package/src/core/modules/better-auth/better-auth.types.ts +16 -0
  109. package/src/core/modules/better-auth/core-better-auth.controller.ts +44 -3
  110. package/src/core/modules/better-auth/core-better-auth.resolver.ts +136 -16
  111. package/src/core/modules/better-auth/index.ts +1 -0
  112. package/src/core/modules/user/core-user.service.ts +131 -4
  113. package/src/core/modules/user/interfaces/core-user-service-options.interface.ts +15 -0
  114. package/src/core.module.ts +264 -76
  115. package/src/index.ts +5 -0
  116. package/src/server/modules/auth/auth.resolver.ts +8 -0
  117. package/src/server/modules/better-auth/better-auth.module.ts +9 -3
  118. package/src/server/modules/better-auth/better-auth.resolver.ts +18 -11
  119. package/src/server/modules/user/user.controller.ts +1 -9
  120. package/src/server/modules/user/user.service.ts +4 -2
@@ -1,7 +1,21 @@
1
1
  import { Injectable, Logger, Optional } from '@nestjs/common';
2
2
  import { InjectConnection } from '@nestjs/mongoose';
3
+ import * as bcrypt from 'bcrypt';
4
+ import { randomBytes, scrypt, ScryptOptions } from 'crypto';
5
+ import { sha256 } from 'js-sha256';
6
+ import { ObjectId } from 'mongodb';
3
7
  import { Connection } from 'mongoose';
4
8
 
9
+ // Promisify Node.js crypto.scrypt
10
+ const scryptPromise = (password: string, salt: string, keylen: number, options: ScryptOptions): Promise<Buffer> => {
11
+ return new Promise((resolve, reject) => {
12
+ scrypt(password, salt, keylen, options, (err, derivedKey) => {
13
+ if (err) reject(err);
14
+ else resolve(derivedKey);
15
+ });
16
+ });
17
+ };
18
+
5
19
  import { RoleEnum } from '../../common/enums/role.enum';
6
20
 
7
21
  /**
@@ -41,6 +55,20 @@ export interface MappedUser {
41
55
  verified?: boolean;
42
56
  }
43
57
 
58
+ /**
59
+ * Interface for migration status result
60
+ */
61
+ export interface MigrationStatus {
62
+ canDisableLegacyAuth: boolean;
63
+ fullyMigratedUsers: number;
64
+ migrationPercentage: number;
65
+ pendingMigrationUsers: number;
66
+ pendingUserEmails: string[];
67
+ totalUsers: number;
68
+ usersWithIamAccount: number;
69
+ usersWithIamId: number;
70
+ }
71
+
44
72
  /**
45
73
  * Interface for synced user document returned from database
46
74
  */
@@ -63,6 +91,10 @@ export interface SyncedUserDocument {
63
91
  *
64
92
  * This service bridges the gap between Better-Auth's session-based users
65
93
  * and the application's role-based security system.
94
+ *
95
+ * It also provides bidirectional password synchronization:
96
+ * - IAM → Legacy: Copies password from `accounts` to `users.password`
97
+ * - Legacy → IAM: Creates account entry in `accounts` from `users.password`
66
98
  */
67
99
  @Injectable()
68
100
  export class BetterAuthUserMapper {
@@ -185,6 +217,322 @@ export class BetterAuthUserMapper {
185
217
  };
186
218
  }
187
219
 
220
+ // ===================================================================================================================
221
+ // Password Sync
222
+ // ===================================================================================================================
223
+
224
+ /**
225
+ * Syncs password to users.password with bcrypt hash
226
+ *
227
+ * This enables: IAM Sign-Up → Legacy Sign-In
228
+ * After a user signs up through Better-Auth, we hash the plain password
229
+ * with bcrypt and store it in users.password so they can sign in via Legacy Auth.
230
+ *
231
+ * NOTE: We cannot copy the Better-Auth scrypt hash because Legacy Auth uses bcrypt.
232
+ * We need the plain password to create a bcrypt-compatible hash.
233
+ *
234
+ * @param iamUserId - The Better-Auth user ID (from sessions/accounts)
235
+ * @param userEmail - The user's email address
236
+ * @param plainPassword - The plain password to hash with bcrypt
237
+ * @returns true if sync was successful, false otherwise
238
+ */
239
+ async syncPasswordToLegacy(iamUserId: string, userEmail: string, plainPassword?: string): Promise<boolean> {
240
+ if (!this.connection) {
241
+ this.logger.warn('No database connection available - cannot sync password to legacy');
242
+ return false;
243
+ }
244
+
245
+ if (!plainPassword) {
246
+ return false;
247
+ }
248
+
249
+ try {
250
+ const usersCollection = this.connection.collection('users');
251
+
252
+ // Hash password with bcrypt for Legacy Auth compatibility
253
+ // Legacy Auth uses: bcrypt.compare(password, hash) or bcrypt.compare(sha256(password), hash)
254
+ // We ALWAYS store bcrypt(sha256(password)) to ensure both formats work:
255
+ // - Client sends plain password → sha256 → bcrypt → stored
256
+ // - Client sends SHA256 hash → already SHA256 → bcrypt → stored
257
+ // This ensures Legacy login works regardless of what format the client sends
258
+ const normalizedPassword = this.normalizePasswordForIam(plainPassword);
259
+ const saltRounds = 10;
260
+ const bcryptHash = await bcrypt.hash(normalizedPassword, saltRounds);
261
+
262
+ // Update the users collection with the bcrypt hash
263
+ const result = await usersCollection.updateOne(
264
+ { $or: [{ email: userEmail }, { iamId: iamUserId }] },
265
+ { $set: { password: bcryptHash, updatedAt: new Date() } },
266
+ );
267
+
268
+ return result.modifiedCount > 0;
269
+ } catch (error) {
270
+ this.logger.error(
271
+ `Error syncing password to legacy: ${error instanceof Error ? error.message : 'Unknown error'}`,
272
+ );
273
+ return false;
274
+ }
275
+ }
276
+
277
+ /**
278
+ * Syncs a password change from Legacy Auth to Better-Auth (IAM)
279
+ *
280
+ * This enables: Legacy Password Reset/Change → IAM Sign-In
281
+ * When a user resets or changes their password via Legacy Auth, this method
282
+ * updates the password hash in the Better-Auth `account` collection.
283
+ *
284
+ * Use cases:
285
+ * - Password reset via Legacy Auth (`CoreUserService.resetPassword`)
286
+ * - Password change via Legacy Auth (user update with password field)
287
+ * - Bulk password updates
288
+ *
289
+ * @param userEmail - The user's email address
290
+ * @param plainPassword - The new plain password to hash with scrypt for IAM
291
+ * @returns true if sync was successful, false otherwise
292
+ */
293
+ async syncPasswordChangeToIam(userEmail: string, plainPassword: string): Promise<boolean> {
294
+ if (!this.connection) {
295
+ this.logger.warn('No database connection available - cannot sync password to IAM');
296
+ return false;
297
+ }
298
+
299
+ if (!plainPassword) {
300
+ return false;
301
+ }
302
+
303
+ try {
304
+ const usersCollection = this.connection.collection('users');
305
+ const accountCollection = this.connection.collection('account');
306
+
307
+ // Find the user
308
+ const user = await usersCollection.findOne({ email: userEmail });
309
+ if (!user) {
310
+ return false;
311
+ }
312
+
313
+ // Check if user has an IAM credential account
314
+ const existingAccount = await accountCollection.findOne({
315
+ providerId: 'credential',
316
+ userId: user._id,
317
+ });
318
+
319
+ if (!existingAccount) {
320
+ return false;
321
+ }
322
+
323
+ // Hash password with scrypt for Better-Auth
324
+ const scryptHash = await this.hashPasswordForBetterAuth(plainPassword);
325
+
326
+ // Update the account password
327
+ await accountCollection.updateOne(
328
+ { _id: existingAccount._id },
329
+ {
330
+ $set: {
331
+ password: scryptHash,
332
+ updatedAt: new Date(),
333
+ },
334
+ },
335
+ );
336
+
337
+ return true;
338
+ } catch (error) {
339
+ this.logger.error(`Error syncing password to IAM: ${error instanceof Error ? error.message : 'Unknown error'}`);
340
+ return false;
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Creates a Better-Auth account entry from legacy user's password
346
+ *
347
+ * This enables: Legacy Sign-Up → IAM Sign-In
348
+ * When a legacy user wants to use Better-Auth, this creates the necessary
349
+ * account entry. Since Legacy Auth uses sha256+bcrypt and Better-Auth uses
350
+ * only bcrypt, we need the plain password to create a compatible hash.
351
+ *
352
+ * @param userEmail - The user's email address
353
+ * @param plainPassword - Optional plain password to create Better-Auth compatible hash
354
+ * @returns true if account was created, false otherwise
355
+ */
356
+ async migrateAccountToIam(userEmail: string, plainPassword?: string): Promise<boolean> {
357
+ if (!this.connection) {
358
+ return false;
359
+ }
360
+
361
+ try {
362
+ const usersCollection = this.connection.collection('users');
363
+ const accountsCollection = this.connection.collection('account');
364
+
365
+ // Find the legacy user with password
366
+ const legacyUser = await usersCollection.findOne({ email: userEmail });
367
+
368
+ if (!legacyUser?.password) {
369
+ return false;
370
+ }
371
+
372
+ // IMPORTANT: Verify the provided password matches the legacy hash
373
+ // This prevents migration with a wrong password
374
+ // Legacy Auth uses two formats for backwards compatibility:
375
+ // 1. bcrypt(password) - direct hash
376
+ // 2. bcrypt(sha256(password)) - sha256 then bcrypt
377
+ if (plainPassword) {
378
+ const directMatch = await bcrypt.compare(plainPassword, legacyUser.password);
379
+ const sha256Match = await bcrypt.compare(sha256(plainPassword), legacyUser.password);
380
+ if (!directMatch && !sha256Match) {
381
+ // Security: Wrong password provided for migration - reject
382
+ this.logger.warn(`Migration password verification failed for ${userEmail}`);
383
+ return false;
384
+ }
385
+ } else {
386
+ // No password provided - cannot verify, cannot migrate
387
+ return false;
388
+ }
389
+
390
+ // Better-Auth stores account.userId as ObjectId that references users._id
391
+ // The id field is a secondary string identifier used in API responses
392
+ const userMongoId = legacyUser._id as ObjectId;
393
+ const userIdHex = userMongoId.toHexString();
394
+
395
+ // Update user with Better-Auth fields if not already present
396
+ if (!legacyUser.iamId) {
397
+ const now = new Date();
398
+ // Generate a nanoid-style string id for the 'id' field (for API responses)
399
+ const stringId = this.generateId();
400
+
401
+ await usersCollection.updateOne(
402
+ { _id: legacyUser._id },
403
+ {
404
+ $set: {
405
+ emailVerified: legacyUser.verified === true,
406
+ iamId: stringId,
407
+ id: stringId,
408
+ name: [legacyUser.firstName, legacyUser.lastName].filter(Boolean).join(' ') || undefined,
409
+ updatedAt: now,
410
+ },
411
+ },
412
+ );
413
+ }
414
+
415
+ // Check if credential account already exists
416
+ // Better-Auth stores userId as ObjectId referencing users._id
417
+ const existingAccount = await accountsCollection.findOne({
418
+ providerId: 'credential',
419
+ userId: userMongoId,
420
+ });
421
+
422
+ if (existingAccount) {
423
+ return true;
424
+ }
425
+
426
+ // Create the credential account with Better-Auth compatible scrypt hash
427
+ const passwordHash = await this.hashPasswordForBetterAuth(plainPassword);
428
+
429
+ const now = new Date();
430
+ // Store account matching Better-Auth's format:
431
+ // - userId: ObjectId referencing users._id
432
+ // - accountId: string version of users._id
433
+ await accountsCollection.insertOne({
434
+ accountId: userIdHex,
435
+ createdAt: now,
436
+ id: this.generateId(),
437
+ password: passwordHash,
438
+ providerId: 'credential',
439
+ updatedAt: now,
440
+ userId: userMongoId,
441
+ });
442
+
443
+ return true;
444
+ } catch (error) {
445
+ this.logger.error(`Error migrating account to IAM: ${error instanceof Error ? error.message : 'Unknown error'}`);
446
+ return false;
447
+ }
448
+ }
449
+
450
+ /**
451
+ * Generates a unique ID for Better-Auth entities
452
+ * Uses the same format as Better-Auth (nanoid-style)
453
+ */
454
+ private generateId(): string {
455
+ const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789';
456
+ let result = '';
457
+ for (let i = 0; i < 21; i++) {
458
+ result += chars.charAt(Math.floor(Math.random() * chars.length));
459
+ }
460
+ return result;
461
+ }
462
+
463
+ /**
464
+ * Normalizes a password for IAM operations
465
+ *
466
+ * This ensures consistency with Legacy Auth's SHA256 handling.
467
+ * Legacy Auth accepts both plain passwords and SHA256 hashes.
468
+ * IAM always uses SHA256(password) internally for consistency.
469
+ *
470
+ * - If password is already SHA256 (64 hex chars) → use as-is
471
+ * - If password is plain text → convert to SHA256
472
+ *
473
+ * This allows clients to send either format and get consistent behavior.
474
+ *
475
+ * @param password - Plain password or SHA256 hash
476
+ * @returns Normalized password (always SHA256 format)
477
+ */
478
+ normalizePasswordForIam(password: string): string {
479
+ // Check if already SHA256 hash (64 hex characters)
480
+ if (/^[a-f0-9]{64}$/i.test(password)) {
481
+ return password;
482
+ }
483
+ // Convert plain password to SHA256
484
+ return sha256(password);
485
+ }
486
+
487
+ /**
488
+ * Hashes a password using Better-Auth's scrypt format
489
+ *
490
+ * Better-Auth uses scrypt with:
491
+ * - N: 16384, r: 16, p: 1, dkLen: 64
492
+ * - 16-byte salt (32 hex chars)
493
+ * - Format: "salt:hash" (both hex encoded)
494
+ *
495
+ * NOTE: This method normalizes the password to SHA256 format first
496
+ * to ensure consistency with Legacy Auth.
497
+ *
498
+ * @param password - Plain password or SHA256 hash to hash
499
+ * @returns Password hash in Better-Auth format (salt:hash)
500
+ */
501
+ private async hashPasswordForBetterAuth(password: string): Promise<string> {
502
+ // Normalize password to SHA256 format for consistency with Legacy Auth
503
+ const normalizedPassword = this.normalizePasswordForIam(password);
504
+
505
+ // Generate 16-byte random salt (same as Better-Auth)
506
+ const saltBytes = randomBytes(16);
507
+ const salt = saltBytes.toString('hex');
508
+
509
+ // Scrypt parameters matching Better-Auth:
510
+ // N (cost): 16384, r (blockSize): 16, p (parallelization): 1
511
+ // maxmem: 128 * N * r * 2 = 67108864 bytes
512
+ const keyLength = 64;
513
+ const scryptOptions = {
514
+ maxmem: 128 * 16384 * 16 * 2,
515
+ N: 16384,
516
+ p: 1,
517
+ r: 16,
518
+ };
519
+
520
+ // Hash normalized password with scrypt using Node.js crypto
521
+ const key = await scryptPromise(normalizedPassword.normalize('NFKC'), salt, keyLength, scryptOptions);
522
+
523
+ // Return in Better-Auth format: salt:hash
524
+ return `${salt}:${key.toString('hex')}`;
525
+ }
526
+
527
+ /**
528
+ * Converts bytes to hex string
529
+ */
530
+ private bytesToHex(bytes: Uint8Array): string {
531
+ return Array.from(bytes)
532
+ .map((b) => b.toString(16).padStart(2, '0'))
533
+ .join('');
534
+ }
535
+
188
536
  /**
189
537
  * Links an existing user or creates a new user from Better-Auth session data
190
538
  *
@@ -266,4 +614,393 @@ export class BetterAuthUserMapper {
266
614
  return null;
267
615
  }
268
616
  }
617
+
618
+ // ===================================================================================================================
619
+ // Email Sync
620
+ // ===================================================================================================================
621
+
622
+ /**
623
+ * Syncs an email change from Legacy Auth to Better-Auth (IAM)
624
+ *
625
+ * When a user changes their email in Legacy Auth, this method updates:
626
+ * 1. The email field in the shared users collection (already done by Legacy)
627
+ * 2. The Better-Auth session data (if any active sessions exist)
628
+ *
629
+ * Note: Since we share the 'users' collection, the email is already updated.
630
+ * This method handles any additional Better-Auth specific updates.
631
+ *
632
+ * @param oldEmail - The user's previous email address
633
+ * @param newEmail - The user's new email address
634
+ * @returns true if sync was successful, false otherwise
635
+ */
636
+ async syncEmailChangeFromLegacy(oldEmail: string, newEmail: string): Promise<boolean> {
637
+ if (!this.connection) {
638
+ this.logger.warn('No database connection available - cannot sync email change');
639
+ return false;
640
+ }
641
+
642
+ try {
643
+ const sessionCollection = this.connection.collection('session');
644
+
645
+ // Find user by new email (already updated by Legacy Auth)
646
+ const usersCollection = this.connection.collection('users');
647
+ const user = await usersCollection.findOne({ email: newEmail });
648
+
649
+ if (!user) {
650
+ return false;
651
+ }
652
+
653
+ // Invalidate all existing sessions for this user
654
+ // This forces re-authentication with the new email
655
+ if (user._id) {
656
+ await sessionCollection.deleteMany({ userId: user._id });
657
+ }
658
+
659
+ return true;
660
+ } catch (error) {
661
+ this.logger.error(
662
+ `Error syncing email change from Legacy: ${error instanceof Error ? error.message : 'Unknown error'}`,
663
+ );
664
+ return false;
665
+ }
666
+ }
667
+
668
+ /**
669
+ * Syncs an email change from Better-Auth (IAM) to Legacy Auth
670
+ *
671
+ * When a user changes their email in Better-Auth, this method:
672
+ * 1. Updates the email in the shared users collection
673
+ * 2. Invalidates any legacy refresh tokens (forces re-authentication)
674
+ *
675
+ * @param userId - The Better-Auth user ID (iamId)
676
+ * @param newEmail - The user's new email address
677
+ * @returns true if sync was successful, false otherwise
678
+ */
679
+ async syncEmailChangeFromIam(userId: string, newEmail: string): Promise<boolean> {
680
+ if (!this.connection) {
681
+ this.logger.warn('No database connection available - cannot sync email change');
682
+ return false;
683
+ }
684
+
685
+ try {
686
+ const usersCollection = this.connection.collection('users');
687
+
688
+ // Find user by iamId and update email
689
+ const result = await usersCollection.findOneAndUpdate(
690
+ { $or: [{ iamId: userId }, { id: userId }] },
691
+ {
692
+ $set: {
693
+ email: newEmail,
694
+ // Clear refresh tokens to force re-authentication
695
+ refreshTokens: {},
696
+ updatedAt: new Date(),
697
+ },
698
+ },
699
+ { returnDocument: 'after' },
700
+ );
701
+
702
+ return !!result;
703
+ } catch (error) {
704
+ this.logger.error(
705
+ `Error syncing email change from IAM: ${error instanceof Error ? error.message : 'Unknown error'}`,
706
+ );
707
+ return false;
708
+ }
709
+ }
710
+
711
+ // ===================================================================================================================
712
+ // User Deletion
713
+ // ===================================================================================================================
714
+
715
+ /**
716
+ * Deletes a user from both Legacy Auth and Better-Auth (IAM) systems
717
+ *
718
+ * This method performs cascading deletion:
719
+ * 1. Deletes the user from the shared users collection
720
+ * 2. Deletes all Better-Auth accounts for this user
721
+ * 3. Deletes all Better-Auth sessions for this user
722
+ *
723
+ * @param userIdentifier - Email, MongoDB _id, or iamId of the user
724
+ * @returns Object with deletion results
725
+ */
726
+ async deleteUserFromBothSystems(userIdentifier: string): Promise<{
727
+ accountsDeleted: number;
728
+ sessionsDeleted: number;
729
+ success: boolean;
730
+ userDeleted: boolean;
731
+ }> {
732
+ if (!this.connection) {
733
+ this.logger.warn('No database connection available - cannot delete user');
734
+ return { accountsDeleted: 0, sessionsDeleted: 0, success: false, userDeleted: false };
735
+ }
736
+
737
+ try {
738
+ const usersCollection = this.connection.collection('users');
739
+ const accountCollection = this.connection.collection('account');
740
+ const sessionCollection = this.connection.collection('session');
741
+
742
+ // Find the user first to get all identifiers
743
+ let user: any = null;
744
+
745
+ // Try to find by email first
746
+ user = await usersCollection.findOne({ email: userIdentifier });
747
+
748
+ // Try by iamId
749
+ if (!user) {
750
+ user = await usersCollection.findOne({ iamId: userIdentifier });
751
+ }
752
+
753
+ // Try by MongoDB _id
754
+ if (!user && ObjectId.isValid(userIdentifier)) {
755
+ user = await usersCollection.findOne({ _id: new ObjectId(userIdentifier) });
756
+ }
757
+
758
+ if (!user) {
759
+ return { accountsDeleted: 0, sessionsDeleted: 0, success: false, userDeleted: false };
760
+ }
761
+
762
+ const userId = user._id as ObjectId;
763
+
764
+ // Delete Better-Auth sessions
765
+ const sessionsResult = await sessionCollection.deleteMany({ userId });
766
+ const sessionsDeleted = sessionsResult.deletedCount;
767
+
768
+ // Delete Better-Auth accounts
769
+ const accountsResult = await accountCollection.deleteMany({ userId });
770
+ const accountsDeleted = accountsResult.deletedCount;
771
+
772
+ // Delete the user document
773
+ const userResult = await usersCollection.deleteOne({ _id: userId });
774
+ const userDeleted = userResult.deletedCount > 0;
775
+
776
+ this.logger.log(
777
+ `Deleted user ${user.email}: user=${userDeleted}, accounts=${accountsDeleted}, sessions=${sessionsDeleted}`,
778
+ );
779
+
780
+ return {
781
+ accountsDeleted,
782
+ sessionsDeleted,
783
+ success: userDeleted,
784
+ userDeleted,
785
+ };
786
+ } catch (error) {
787
+ this.logger.error(`Error deleting user: ${error instanceof Error ? error.message : 'Unknown error'}`);
788
+ return { accountsDeleted: 0, sessionsDeleted: 0, success: false, userDeleted: false };
789
+ }
790
+ }
791
+
792
+ /**
793
+ * Cleans up Better-Auth data when a user is deleted from Legacy Auth
794
+ *
795
+ * Call this method when a user is deleted through Legacy Auth to ensure
796
+ * all Better-Auth related data is also removed.
797
+ *
798
+ * @param userId - MongoDB _id of the deleted user
799
+ * @returns Object with deletion results
800
+ */
801
+ async cleanupIamDataForDeletedUser(userId: ObjectId | string): Promise<{
802
+ accountsDeleted: number;
803
+ sessionsDeleted: number;
804
+ success: boolean;
805
+ }> {
806
+ if (!this.connection) {
807
+ this.logger.warn('No database connection available - cannot cleanup IAM data');
808
+ return { accountsDeleted: 0, sessionsDeleted: 0, success: false };
809
+ }
810
+
811
+ try {
812
+ const accountCollection = this.connection.collection('account');
813
+ const sessionCollection = this.connection.collection('session');
814
+
815
+ const userObjectId = typeof userId === 'string' ? new ObjectId(userId) : userId;
816
+
817
+ // Delete Better-Auth sessions
818
+ const sessionsResult = await sessionCollection.deleteMany({ userId: userObjectId });
819
+ const sessionsDeleted = sessionsResult.deletedCount;
820
+
821
+ // Delete Better-Auth accounts
822
+ const accountsResult = await accountCollection.deleteMany({ userId: userObjectId });
823
+ const accountsDeleted = accountsResult.deletedCount;
824
+
825
+ return {
826
+ accountsDeleted,
827
+ sessionsDeleted,
828
+ success: true,
829
+ };
830
+ } catch (error) {
831
+ this.logger.error(`Error cleaning up IAM data: ${error instanceof Error ? error.message : 'Unknown error'}`);
832
+ return { accountsDeleted: 0, sessionsDeleted: 0, success: false };
833
+ }
834
+ }
835
+
836
+ /**
837
+ * Cleans up Legacy Auth data when a user is deleted from Better-Auth (IAM)
838
+ *
839
+ * Call this method when a user is deleted through Better-Auth to ensure
840
+ * the Legacy Auth user is also removed.
841
+ *
842
+ * @param iamUserId - Better-Auth user ID (iamId)
843
+ * @returns true if cleanup was successful, false otherwise
844
+ */
845
+ async cleanupLegacyDataForDeletedIamUser(iamUserId: string): Promise<boolean> {
846
+ if (!this.connection) {
847
+ this.logger.warn('No database connection available - cannot cleanup Legacy data');
848
+ return false;
849
+ }
850
+
851
+ try {
852
+ const usersCollection = this.connection.collection('users');
853
+
854
+ // Delete the user from Legacy Auth
855
+ const result = await usersCollection.deleteOne({
856
+ $or: [{ iamId: iamUserId }, { id: iamUserId }],
857
+ });
858
+
859
+ return result.deletedCount > 0;
860
+ } catch (error) {
861
+ this.logger.error(`Error cleaning up Legacy data: ${error instanceof Error ? error.message : 'Unknown error'}`);
862
+ return false;
863
+ }
864
+ }
865
+
866
+ // ===================================================================================================================
867
+ // Migration Status
868
+ // ===================================================================================================================
869
+
870
+ /**
871
+ * Gets the migration status from Legacy Auth to Better-Auth (IAM)
872
+ *
873
+ * This method provides administrators with information about how many users
874
+ * have been migrated to the IAM system, helping them determine when it's
875
+ * safe to consider disabling Legacy Auth.
876
+ *
877
+ * A user is considered fully migrated when:
878
+ * 1. They have an `iamId` set (linked to Better-Auth user table)
879
+ * 2. They have a credential account in the `account` collection
880
+ *
881
+ * @returns Migration status object with counts and percentage
882
+ */
883
+ async getMigrationStatus(): Promise<MigrationStatus> {
884
+ if (!this.connection) {
885
+ this.logger.warn('No database connection available - cannot get migration status');
886
+ return {
887
+ canDisableLegacyAuth: false,
888
+ fullyMigratedUsers: 0,
889
+ migrationPercentage: 0,
890
+ pendingMigrationUsers: 0,
891
+ pendingUserEmails: [],
892
+ totalUsers: 0,
893
+ usersWithIamAccount: 0,
894
+ usersWithIamId: 0,
895
+ };
896
+ }
897
+
898
+ try {
899
+ const usersCollection = this.connection.collection('users');
900
+ const accountCollection = this.connection.collection('account');
901
+
902
+ // Get total user count
903
+ const totalUsers = await usersCollection.countDocuments({});
904
+
905
+ // Get users with iamId set
906
+ const usersWithIamId = await usersCollection.countDocuments({
907
+ iamId: { $exists: true, $ne: null },
908
+ });
909
+
910
+ // Get unique userIds that have credential accounts
911
+ const credentialAccounts = await accountCollection
912
+ .aggregate([{ $match: { providerId: 'credential' } }, { $group: { _id: '$userId' } }])
913
+ .toArray();
914
+ const usersWithIamAccount = credentialAccounts.length;
915
+
916
+ // Get users that are fully migrated (have both iamId AND credential account)
917
+ // We need to find users where iamId exists AND there's a matching account
918
+ const usersWithBoth = await usersCollection
919
+ .aggregate([
920
+ {
921
+ $match: {
922
+ iamId: { $exists: true, $ne: null },
923
+ },
924
+ },
925
+ {
926
+ $lookup: {
927
+ as: 'accounts',
928
+ foreignField: 'userId',
929
+ from: 'account',
930
+ localField: '_id',
931
+ },
932
+ },
933
+ {
934
+ $match: {
935
+ 'accounts.providerId': 'credential',
936
+ },
937
+ },
938
+ {
939
+ $count: 'count',
940
+ },
941
+ ])
942
+ .toArray();
943
+ const fullyMigratedUsers = usersWithBoth[0]?.count || 0;
944
+
945
+ // Calculate pending users
946
+ const pendingMigrationUsers = totalUsers - fullyMigratedUsers;
947
+
948
+ // Calculate percentage
949
+ const migrationPercentage = totalUsers > 0 ? Math.round((fullyMigratedUsers / totalUsers) * 100 * 100) / 100 : 0;
950
+
951
+ // Get emails of pending users (limit to 100)
952
+ const pendingUsers = await usersCollection
953
+ .aggregate([
954
+ {
955
+ $lookup: {
956
+ as: 'accounts',
957
+ foreignField: 'userId',
958
+ from: 'account',
959
+ localField: '_id',
960
+ },
961
+ },
962
+ {
963
+ $match: {
964
+ $or: [
965
+ { iamId: { $exists: false } },
966
+ { iamId: null },
967
+ {
968
+ $and: [{ iamId: { $exists: true, $ne: null } }, { 'accounts.providerId': { $ne: 'credential' } }],
969
+ },
970
+ ],
971
+ },
972
+ },
973
+ { $limit: 100 },
974
+ { $project: { email: 1 } },
975
+ ])
976
+ .toArray();
977
+ const pendingUserEmails = pendingUsers.map((u) => u.email).filter(Boolean);
978
+
979
+ // Can disable legacy auth only if ALL users are fully migrated
980
+ const canDisableLegacyAuth = totalUsers > 0 && fullyMigratedUsers === totalUsers;
981
+
982
+ return {
983
+ canDisableLegacyAuth,
984
+ fullyMigratedUsers,
985
+ migrationPercentage,
986
+ pendingMigrationUsers,
987
+ pendingUserEmails,
988
+ totalUsers,
989
+ usersWithIamAccount,
990
+ usersWithIamId,
991
+ };
992
+ } catch (error) {
993
+ this.logger.error(`Error getting migration status: ${error instanceof Error ? error.message : 'Unknown error'}`);
994
+ return {
995
+ canDisableLegacyAuth: false,
996
+ fullyMigratedUsers: 0,
997
+ migrationPercentage: 0,
998
+ pendingMigrationUsers: 0,
999
+ pendingUserEmails: [],
1000
+ totalUsers: 0,
1001
+ usersWithIamAccount: 0,
1002
+ usersWithIamId: 0,
1003
+ };
1004
+ }
1005
+ }
269
1006
  }