@lenne.tech/nest-server 11.7.0 → 11.7.1

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