@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.
- package/dist/config.env.js +17 -1
- package/dist/config.env.js.map +1 -1
- package/dist/core/common/interfaces/server-options.interface.d.ts +35 -15
- package/dist/core/modules/auth/core-auth.controller.d.ts +1 -0
- package/dist/core/modules/auth/core-auth.controller.js +29 -3
- package/dist/core/modules/auth/core-auth.controller.js.map +1 -1
- package/dist/core/modules/auth/core-auth.module.js +14 -1
- package/dist/core/modules/auth/core-auth.module.js.map +1 -1
- package/dist/core/modules/auth/core-auth.resolver.d.ts +1 -0
- package/dist/core/modules/auth/core-auth.resolver.js +21 -3
- package/dist/core/modules/auth/core-auth.resolver.js.map +1 -1
- package/dist/core/modules/auth/exceptions/legacy-auth-disabled.exception.d.ts +4 -0
- package/dist/core/modules/auth/exceptions/legacy-auth-disabled.exception.js +17 -0
- package/dist/core/modules/auth/exceptions/legacy-auth-disabled.exception.js.map +1 -0
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.d.ts +9 -0
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js +74 -0
- package/dist/core/modules/auth/guards/legacy-auth-rate-limit.guard.js.map +1 -0
- package/dist/core/modules/auth/interfaces/auth-provider.interface.d.ts +7 -0
- package/dist/core/modules/auth/interfaces/auth-provider.interface.js +5 -0
- package/dist/core/modules/auth/interfaces/auth-provider.interface.js.map +1 -0
- package/dist/core/modules/auth/interfaces/core-auth-user.interface.d.ts +1 -0
- package/dist/core/modules/auth/services/core-auth.service.d.ts +10 -1
- package/dist/core/modules/auth/services/core-auth.service.js +141 -9
- package/dist/core/modules/auth/services/core-auth.service.js.map +1 -1
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.d.ts +31 -0
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js +153 -0
- package/dist/core/modules/auth/services/legacy-auth-rate-limiter.service.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth-migration-status.model.d.ts +10 -0
- package/dist/core/modules/better-auth/better-auth-migration-status.model.js +57 -0
- package/dist/core/modules/better-auth/better-auth-migration-status.model.js.map +1 -0
- package/dist/core/modules/better-auth/better-auth-rate-limiter.service.js +1 -1
- package/dist/core/modules/better-auth/better-auth-rate-limiter.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth-user.mapper.d.ts +33 -0
- package/dist/core/modules/better-auth/better-auth-user.mapper.js +395 -0
- package/dist/core/modules/better-auth/better-auth-user.mapper.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.config.js +29 -10
- package/dist/core/modules/better-auth/better-auth.config.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.middleware.d.ts +1 -0
- package/dist/core/modules/better-auth/better-auth.middleware.js +55 -1
- package/dist/core/modules/better-auth/better-auth.middleware.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.module.d.ts +1 -1
- package/dist/core/modules/better-auth/better-auth.module.js +46 -18
- package/dist/core/modules/better-auth/better-auth.module.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.resolver.js +0 -11
- package/dist/core/modules/better-auth/better-auth.resolver.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.service.d.ts +22 -1
- package/dist/core/modules/better-auth/better-auth.service.js +209 -8
- package/dist/core/modules/better-auth/better-auth.service.js.map +1 -1
- package/dist/core/modules/better-auth/better-auth.types.d.ts +2 -0
- package/dist/core/modules/better-auth/better-auth.types.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.controller.d.ts +1 -0
- package/dist/core/modules/better-auth/core-better-auth.controller.js +15 -2
- package/dist/core/modules/better-auth/core-better-auth.controller.js.map +1 -1
- package/dist/core/modules/better-auth/core-better-auth.resolver.d.ts +7 -0
- package/dist/core/modules/better-auth/core-better-auth.resolver.js +72 -12
- package/dist/core/modules/better-auth/core-better-auth.resolver.js.map +1 -1
- package/dist/core/modules/better-auth/index.d.ts +1 -0
- package/dist/core/modules/better-auth/index.js +1 -0
- package/dist/core/modules/better-auth/index.js.map +1 -1
- package/dist/core/modules/user/core-user.service.d.ts +7 -1
- package/dist/core/modules/user/core-user.service.js +57 -3
- package/dist/core/modules/user/core-user.service.js.map +1 -1
- package/dist/core/modules/user/interfaces/core-user-service-options.interface.d.ts +4 -0
- package/dist/core/modules/user/interfaces/core-user-service-options.interface.js +3 -0
- package/dist/core/modules/user/interfaces/core-user-service-options.interface.js.map +1 -0
- package/dist/core.module.d.ts +3 -0
- package/dist/core.module.js +136 -55
- package/dist/core.module.js.map +1 -1
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/index.js.map +1 -1
- package/dist/server/modules/auth/auth.resolver.js +2 -0
- package/dist/server/modules/auth/auth.resolver.js.map +1 -1
- package/dist/server/modules/better-auth/better-auth.module.d.ts +1 -1
- package/dist/server/modules/better-auth/better-auth.module.js +2 -1
- package/dist/server/modules/better-auth/better-auth.module.js.map +1 -1
- package/dist/server/modules/better-auth/better-auth.resolver.d.ts +5 -0
- package/dist/server/modules/better-auth/better-auth.resolver.js +27 -11
- package/dist/server/modules/better-auth/better-auth.resolver.js.map +1 -1
- package/dist/server/modules/user/user.controller.js +0 -8
- package/dist/server/modules/user/user.controller.js.map +1 -1
- package/dist/server/modules/user/user.service.d.ts +3 -1
- package/dist/server/modules/user/user.service.js +7 -3
- package/dist/server/modules/user/user.service.js.map +1 -1
- package/dist/tsconfig.build.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/config.env.ts +32 -2
- package/src/core/common/interfaces/server-options.interface.ts +304 -58
- package/src/core/modules/auth/core-auth.controller.ts +94 -6
- package/src/core/modules/auth/core-auth.module.ts +15 -1
- package/src/core/modules/auth/core-auth.resolver.ts +71 -3
- package/src/core/modules/auth/exceptions/legacy-auth-disabled.exception.ts +35 -0
- package/src/core/modules/auth/guards/legacy-auth-rate-limit.guard.ts +109 -0
- package/src/core/modules/auth/interfaces/auth-provider.interface.ts +86 -0
- package/src/core/modules/auth/interfaces/core-auth-user.interface.ts +6 -0
- package/src/core/modules/auth/services/core-auth.service.ts +245 -6
- package/src/core/modules/auth/services/legacy-auth-rate-limiter.service.ts +283 -0
- package/src/core/modules/better-auth/INTEGRATION-CHECKLIST.md +255 -0
- package/src/core/modules/better-auth/README.md +565 -208
- package/src/core/modules/better-auth/better-auth-migration-status.model.ts +73 -0
- package/src/core/modules/better-auth/better-auth-rate-limiter.service.ts +1 -1
- package/src/core/modules/better-auth/better-auth-user.mapper.ts +737 -0
- package/src/core/modules/better-auth/better-auth.config.ts +45 -15
- package/src/core/modules/better-auth/better-auth.middleware.ts +85 -2
- package/src/core/modules/better-auth/better-auth.module.ts +83 -27
- package/src/core/modules/better-auth/better-auth.resolver.ts +0 -11
- package/src/core/modules/better-auth/better-auth.service.ts +367 -12
- package/src/core/modules/better-auth/better-auth.types.ts +16 -0
- package/src/core/modules/better-auth/core-better-auth.controller.ts +44 -3
- package/src/core/modules/better-auth/core-better-auth.resolver.ts +136 -16
- package/src/core/modules/better-auth/index.ts +1 -0
- package/src/core/modules/user/core-user.service.ts +131 -4
- package/src/core/modules/user/interfaces/core-user-service-options.interface.ts +15 -0
- package/src/core.module.ts +264 -76
- package/src/index.ts +5 -0
- package/src/server/modules/auth/auth.resolver.ts +8 -0
- package/src/server/modules/better-auth/better-auth.module.ts +9 -3
- package/src/server/modules/better-auth/better-auth.resolver.ts +18 -11
- package/src/server/modules/user/user.controller.ts +1 -9
- 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
|
}
|