@nauth-toolkit/core 0.1.18 → 0.1.22

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 (103) hide show
  1. package/dist/adapters/storage.factory.d.ts.map +1 -1
  2. package/dist/adapters/storage.factory.js +250 -18
  3. package/dist/adapters/storage.factory.js.map +1 -1
  4. package/dist/bootstrap.d.ts.map +1 -1
  5. package/dist/bootstrap.js +3 -2
  6. package/dist/bootstrap.js.map +1 -1
  7. package/dist/dto/admin-signup.dto.d.ts +196 -0
  8. package/dist/dto/admin-signup.dto.d.ts.map +1 -0
  9. package/dist/dto/admin-signup.dto.js +317 -0
  10. package/dist/dto/admin-signup.dto.js.map +1 -0
  11. package/dist/dto/auth-response.dto.d.ts +14 -0
  12. package/dist/dto/auth-response.dto.d.ts.map +1 -1
  13. package/dist/dto/auth-response.dto.js +14 -0
  14. package/dist/dto/auth-response.dto.js.map +1 -1
  15. package/dist/dto/index.d.ts +1 -0
  16. package/dist/dto/index.d.ts.map +1 -1
  17. package/dist/dto/index.js +1 -0
  18. package/dist/dto/index.js.map +1 -1
  19. package/dist/dto/social-auth.dto.d.ts +24 -0
  20. package/dist/dto/social-auth.dto.d.ts.map +1 -1
  21. package/dist/dto/social-auth.dto.js +37 -1
  22. package/dist/dto/social-auth.dto.js.map +1 -1
  23. package/dist/entities/user.entity.d.ts +8 -0
  24. package/dist/entities/user.entity.d.ts.map +1 -1
  25. package/dist/entities/user.entity.js +8 -0
  26. package/dist/entities/user.entity.js.map +1 -1
  27. package/dist/handlers/auth.handler.d.ts +3 -8
  28. package/dist/handlers/auth.handler.d.ts.map +1 -1
  29. package/dist/handlers/auth.handler.js +10 -55
  30. package/dist/handlers/auth.handler.js.map +1 -1
  31. package/dist/handlers/csrf.handler.d.ts.map +1 -1
  32. package/dist/handlers/csrf.handler.js +7 -2
  33. package/dist/handlers/csrf.handler.js.map +1 -1
  34. package/dist/handlers/social-redirect.handler.d.ts +136 -0
  35. package/dist/handlers/social-redirect.handler.d.ts.map +1 -0
  36. package/dist/handlers/social-redirect.handler.js +364 -0
  37. package/dist/handlers/social-redirect.handler.js.map +1 -0
  38. package/dist/index.d.ts +1 -0
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +4 -0
  41. package/dist/index.js.map +1 -1
  42. package/dist/interfaces/config.interface.d.ts +43 -0
  43. package/dist/interfaces/config.interface.d.ts.map +1 -1
  44. package/dist/interfaces/entities.interface.d.ts +6 -0
  45. package/dist/interfaces/entities.interface.d.ts.map +1 -1
  46. package/dist/interfaces/index.d.ts +1 -0
  47. package/dist/interfaces/index.d.ts.map +1 -1
  48. package/dist/interfaces/index.js +1 -0
  49. package/dist/interfaces/index.js.map +1 -1
  50. package/dist/interfaces/social-auth-state-store.interface.d.ts +100 -0
  51. package/dist/interfaces/social-auth-state-store.interface.d.ts.map +1 -0
  52. package/dist/interfaces/social-auth-state-store.interface.js +3 -0
  53. package/dist/interfaces/social-auth-state-store.interface.js.map +1 -0
  54. package/dist/interfaces/storage-adapter.interface.d.ts +2 -2
  55. package/dist/interfaces/storage-adapter.interface.d.ts.map +1 -1
  56. package/dist/internal.d.ts +5 -0
  57. package/dist/internal.d.ts.map +1 -1
  58. package/dist/internal.js +7 -1
  59. package/dist/internal.js.map +1 -1
  60. package/dist/schemas/auth-config.schema.d.ts +107 -28
  61. package/dist/schemas/auth-config.schema.d.ts.map +1 -1
  62. package/dist/schemas/auth-config.schema.js +20 -1
  63. package/dist/schemas/auth-config.schema.js.map +1 -1
  64. package/dist/services/auth-challenge-helper.service.d.ts +1 -1
  65. package/dist/services/auth-challenge-helper.service.d.ts.map +1 -1
  66. package/dist/services/auth-challenge-helper.service.js +9 -4
  67. package/dist/services/auth-challenge-helper.service.js.map +1 -1
  68. package/dist/services/auth.service.d.ts +59 -3
  69. package/dist/services/auth.service.d.ts.map +1 -1
  70. package/dist/services/auth.service.js +276 -50
  71. package/dist/services/auth.service.js.map +1 -1
  72. package/dist/services/geo-location.service.js +2 -2
  73. package/dist/services/geo-location.service.js.map +1 -1
  74. package/dist/services/password-reset.service.d.ts.map +1 -1
  75. package/dist/services/password-reset.service.js.map +1 -1
  76. package/dist/services/phone-verification.service.js.map +1 -1
  77. package/dist/services/social-auth-base.service.d.ts +5 -10
  78. package/dist/services/social-auth-base.service.d.ts.map +1 -1
  79. package/dist/services/social-auth-base.service.js +30 -61
  80. package/dist/services/social-auth-base.service.js.map +1 -1
  81. package/dist/services/social-auth-state-store.service.d.ts +58 -0
  82. package/dist/services/social-auth-state-store.service.d.ts.map +1 -0
  83. package/dist/services/social-auth-state-store.service.js +261 -0
  84. package/dist/services/social-auth-state-store.service.js.map +1 -0
  85. package/dist/storage/account-lockout-storage.service.d.ts +2 -2
  86. package/dist/storage/account-lockout-storage.service.d.ts.map +1 -1
  87. package/dist/storage/account-lockout-storage.service.js +2 -2
  88. package/dist/storage/account-lockout-storage.service.js.map +1 -1
  89. package/dist/templates/sms-template.engine.d.ts.map +1 -1
  90. package/dist/templates/sms-template.engine.js +1 -2
  91. package/dist/templates/sms-template.engine.js.map +1 -1
  92. package/dist/utils/index.d.ts +1 -0
  93. package/dist/utils/index.d.ts.map +1 -1
  94. package/dist/utils/index.js +1 -0
  95. package/dist/utils/index.js.map +1 -1
  96. package/dist/utils/password-generator.d.ts +29 -0
  97. package/dist/utils/password-generator.d.ts.map +1 -0
  98. package/dist/utils/password-generator.js +98 -0
  99. package/dist/utils/password-generator.js.map +1 -0
  100. package/dist/utils/setup/init-social.d.ts +2 -5
  101. package/dist/utils/setup/init-social.d.ts.map +1 -1
  102. package/dist/utils/setup/init-social.js.map +1 -1
  103. package/package.json +1 -1
@@ -47,6 +47,7 @@ const error_codes_enum_1 = require("../enums/error-codes.enum");
47
47
  const mfa_method_enum_1 = require("../enums/mfa-method.enum");
48
48
  const class_validator_1 = require("class-validator");
49
49
  const crypto = __importStar(require("crypto"));
50
+ const password_generator_1 = require("../utils/password-generator");
50
51
  /**
51
52
  * Dummy Argon2 hash for constant-time response
52
53
  *
@@ -289,6 +290,199 @@ class AuthService {
289
290
  return response;
290
291
  }
291
292
  // ============================================================================
293
+ // Admin Signup
294
+ // ============================================================================
295
+ /**
296
+ * Administrative user creation with override capabilities
297
+ *
298
+ * Allows administrators to create user accounts with:
299
+ * - Bypass email/phone verification requirements
300
+ * - Force password change on first login
301
+ * - Auto-generate secure passwords
302
+ *
303
+ * Security:
304
+ * - No built-in authentication - endpoint must be protected by framework adapter
305
+ * - All duplicate checks still enforced
306
+ * - Password policy still enforced (unless auto-generated)
307
+ * - Audit trail records admin-created accounts
308
+ *
309
+ * @param dto - Admin signup DTO with override flags
310
+ * @returns User object and optionally generated password
311
+ * @throws {NAuthException} EMAIL_EXISTS | USERNAME_EXISTS | PHONE_EXISTS | WEAK_PASSWORD
312
+ *
313
+ * @example
314
+ * ```typescript
315
+ * // Create user with pre-verified email
316
+ * const result = await authService.adminSignup({
317
+ * email: 'user@example.com',
318
+ * password: 'SecurePass123!',
319
+ * isEmailVerified: true,
320
+ * });
321
+ *
322
+ * // Create user with auto-generated password
323
+ * const result = await authService.adminSignup({
324
+ * email: 'user@example.com',
325
+ * generatePassword: true,
326
+ * isEmailVerified: true,
327
+ * mustChangePassword: true,
328
+ * });
329
+ * // result.generatedPassword contains the temporary password
330
+ * ```
331
+ */
332
+ async adminSignup(dto) {
333
+ // Get client info from request context (transparent!)
334
+ const clientInfo = this.clientInfoService.get();
335
+ this.logger?.log?.(`Admin signup attempt for email: ${dto.email}`);
336
+ this.logger?.debug?.(`Admin signup details: { email: ${dto.email}, username: ${dto.username || 'none'}, ip: ${clientInfo.ipAddress} }`);
337
+ // Skip signup.enabled check (admin bypass)
338
+ // Check if user already exists (email and username)
339
+ this.logger?.debug?.(`Checking if user exists: ${dto.email}`);
340
+ const existingUserByEmail = await this.userRepository.findOne({
341
+ where: { email: dto.email },
342
+ });
343
+ if (existingUserByEmail) {
344
+ this.logger?.warn?.(`Admin signup failed - user already exists: ${dto.email}`);
345
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.EMAIL_EXISTS, 'User with this email already exists');
346
+ }
347
+ // Check for duplicate username if provided
348
+ if (dto.username) {
349
+ this.logger?.debug?.(`Checking if username exists: ${dto.username}`);
350
+ const existingUserByUsername = await this.userRepository.findOne({
351
+ where: { username: dto.username },
352
+ });
353
+ if (existingUserByUsername) {
354
+ this.logger?.warn?.(`Admin signup failed - username already exists: ${dto.username}`);
355
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USERNAME_EXISTS, 'Username is already taken');
356
+ }
357
+ }
358
+ // Check for duplicate phone if provided and duplicates not allowed
359
+ if (dto.phone && !this.config.signup?.allowDuplicatePhones) {
360
+ this.logger?.debug?.(`Checking if phone exists: ${dto.phone}`);
361
+ const existingUserByPhone = await this.userRepository.findOne({
362
+ where: { phone: dto.phone },
363
+ });
364
+ if (existingUserByPhone) {
365
+ this.logger?.warn?.(`Admin signup failed - phone already exists: ${dto.phone}`);
366
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PHONE_EXISTS, 'Phone number is already registered');
367
+ }
368
+ }
369
+ // Handle password
370
+ let passwordHash;
371
+ let generatedPassword;
372
+ if (dto.generatePassword) {
373
+ // Generate secure random password
374
+ generatedPassword = (0, password_generator_1.generateSecurePassword)(16);
375
+ this.logger?.debug?.(`Generated password for admin-created user: ${dto.email}`);
376
+ passwordHash = await this.passwordService.hashPassword(generatedPassword);
377
+ }
378
+ else {
379
+ // Validate password policy
380
+ if (!dto.password) {
381
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.WEAK_PASSWORD, 'Password is required when generatePassword is false');
382
+ }
383
+ this.logger?.debug?.('Validating password against policy');
384
+ const passwordValidation = await this.passwordService.validatePassword(dto.password, {
385
+ email: dto.email,
386
+ username: dto.username,
387
+ });
388
+ if (!passwordValidation.valid) {
389
+ this.logger?.warn?.(`Password validation failed for ${dto.email}: ${passwordValidation.errors.join(', ')}`);
390
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.WEAK_PASSWORD, passwordValidation.errors.join(', '), {
391
+ errors: passwordValidation.errors,
392
+ });
393
+ }
394
+ // Hash password
395
+ passwordHash = await this.passwordService.hashPassword(dto.password);
396
+ }
397
+ // Create user with override flags
398
+ this.logger?.debug?.(`Creating admin user record for: ${dto.email} || ${dto.username} || ${dto.phone} (isEmailVerified: ${dto.isEmailVerified || false}, isPhoneVerified: ${dto.isPhoneVerified || false})`);
399
+ const user = this.userRepository.create({
400
+ email: dto.email,
401
+ username: dto.username,
402
+ firstName: dto.firstName,
403
+ lastName: dto.lastName,
404
+ phone: dto.phone,
405
+ passwordHash,
406
+ passwordChangedAt: new Date(),
407
+ isEmailVerified: dto.isEmailVerified ?? false, // Use DTO value or default to false
408
+ isPhoneVerified: dto.isPhoneVerified ?? false, // Use DTO value or default to false
409
+ mustChangePassword: dto.mustChangePassword ?? false, // Use DTO value or default to false
410
+ isActive: true, // Always active
411
+ metadata: dto.metadata,
412
+ });
413
+ let savedUser;
414
+ try {
415
+ savedUser = (await this.userRepository.save(user));
416
+ this.logger?.log?.(`Admin user created successfully: ${dto.email} (sub: ${savedUser.sub})`);
417
+ // ============================================================================
418
+ // Audit: Record account creation by admin
419
+ // ============================================================================
420
+ try {
421
+ await this.auditService?.recordEvent({
422
+ userId: savedUser.id,
423
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.ACCOUNT_CREATED,
424
+ eventStatus: 'INFO',
425
+ authMethod: 'admin',
426
+ // Client info automatically included from context
427
+ metadata: {
428
+ email: savedUser.email,
429
+ username: savedUser.username || null,
430
+ createdByAdmin: true,
431
+ adminIdentifier: clientInfo.ipAddress || 'unknown',
432
+ isEmailVerified: savedUser.isEmailVerified,
433
+ isPhoneVerified: savedUser.isPhoneVerified,
434
+ mustChangePassword: savedUser.mustChangePassword,
435
+ passwordGenerated: !!generatedPassword,
436
+ },
437
+ });
438
+ }
439
+ catch (auditError) {
440
+ // Non-blocking: Log but continue
441
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
442
+ this.logger?.error?.(`Failed to record ACCOUNT_CREATED audit event: ${errorMessage}`, {
443
+ error: auditError,
444
+ userId: savedUser.id,
445
+ });
446
+ }
447
+ }
448
+ catch (error) {
449
+ // Handle database constraint violations gracefully
450
+ if (error && typeof error === 'object' && 'code' in error && error.code === '23505') {
451
+ // PostgreSQL unique constraint violation
452
+ const dbError = error;
453
+ if (dbError.detail?.includes('email')) {
454
+ this.logger?.warn?.(`Admin signup failed - email constraint violation: ${dto.email}`);
455
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.EMAIL_EXISTS, 'User with this email already exists');
456
+ }
457
+ else if (dbError.detail?.includes('username')) {
458
+ this.logger?.warn?.(`Admin signup failed - username constraint violation: ${dto.username}`);
459
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USERNAME_EXISTS, 'Username is already taken');
460
+ }
461
+ else if (dbError.detail?.includes('phone')) {
462
+ this.logger?.warn?.(`Admin signup failed - phone constraint violation: ${dto.phone}`);
463
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PHONE_EXISTS, 'Phone number is already registered');
464
+ }
465
+ else {
466
+ this.logger?.error?.(`Admin signup failed - database constraint violation: ${dbError.message}`);
467
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.EMAIL_EXISTS, 'User with this information already exists', {
468
+ conflictType: 'unknown',
469
+ });
470
+ }
471
+ }
472
+ // Re-throw other database errors
473
+ const errorMessage = error instanceof Error ? error.message : 'Unknown database error';
474
+ this.logger?.error?.(`Admin signup failed - database error: ${errorMessage}`);
475
+ throw error;
476
+ }
477
+ // No tokens, no challenge system, no verification emails - pure user creation
478
+ // Return sanitized user object (excludes passwordHash and other sensitive fields)
479
+ const userDto = user_response_dto_1.UserResponseDto.fromEntity(savedUser);
480
+ return {
481
+ user: userDto,
482
+ generatedPassword,
483
+ };
484
+ }
485
+ // ============================================================================
292
486
  // User Login
293
487
  // ============================================================================
294
488
  /**
@@ -794,10 +988,19 @@ class AuthService {
794
988
  });
795
989
  }
796
990
  }
797
- // // Execute afterLogin hook
798
- // if (this.config.hooks?.afterLogin) {
799
- // await this.config.hooks.afterLogin(user, session);
800
- // }
991
+ // ============================================================================
992
+ // Lifecycle Hook: afterLogin
993
+ // ============================================================================
994
+ if (this.config.hooks?.afterLogin) {
995
+ try {
996
+ await this.config.hooks.afterLogin(user, session);
997
+ }
998
+ catch (hookError) {
999
+ const errorMessage = hookError instanceof Error ? hookError.message : 'Unknown error';
1000
+ // Non-blocking: auth succeeded; hook errors should not break login
1001
+ this.logger?.error?.(`afterLogin hook failed (continuing): ${errorMessage}`, { error: hookError });
1002
+ }
1003
+ }
801
1004
  // ============================================================================
802
1005
  // Trusted Device Token Management (Remember Device Feature)
803
1006
  // ============================================================================
@@ -847,11 +1050,13 @@ class AuthService {
847
1050
  isEmailVerified: userDto.isEmailVerified,
848
1051
  isPhoneVerified: userDto.isPhoneVerified ?? undefined,
849
1052
  socialProviders: userDto.socialProviders && userDto.socialProviders.length > 0 ? userDto.socialProviders : undefined,
1053
+ hasPasswordHash: userDto.hasPasswordHash,
850
1054
  },
851
1055
  accessToken: tokens.accessToken,
852
1056
  refreshToken: tokens.refreshToken,
853
1057
  accessTokenExpiresAt: accessTokenValidation.payload?.exp || 0,
854
1058
  refreshTokenExpiresAt: refreshTokenValidation.payload?.exp || 0,
1059
+ authMethod: 'password',
855
1060
  trusted: isTrusted, // Include trusted flag so frontend knows if device is already trusted
856
1061
  // Include deviceToken - CookieTokenInterceptor will handle cookie/stripping based on @TokenDelivery decorator
857
1062
  deviceToken,
@@ -3008,7 +3213,7 @@ class AuthService {
3008
3213
  const attempts = await this.accountLockoutStorage.recordFailedAttempt(ipAddress);
3009
3214
  // Lock IP if max attempts reached
3010
3215
  if (attempts >= (this.config.lockout.maxAttempts || 5)) {
3011
- await this.accountLockoutStorage.blockIpAdresss(ipAddress, this.config.lockout.duration || 900, // 15 minutes default
3216
+ await this.accountLockoutStorage.lockIpAddress(ipAddress, this.config.lockout.duration || 900, // 15 minutes default
3012
3217
  'Too many failed login attempts from this IP');
3013
3218
  // // Execute hook with IP address
3014
3219
  // if (this.config.hooks?.afterAccountLock) {
@@ -3016,10 +3221,19 @@ class AuthService {
3016
3221
  // }
3017
3222
  }
3018
3223
  }
3019
- // // Execute hook
3020
- // if (this.config.hooks?.afterLoginFailed) {
3021
- // await this.config.hooks.afterLoginFailed(identifier, reason || 'unknown');
3022
- // }
3224
+ // ============================================================================
3225
+ // Lifecycle Hook: afterLoginFailed
3226
+ // ============================================================================
3227
+ if (this.config.hooks?.afterLoginFailed) {
3228
+ try {
3229
+ await this.config.hooks.afterLoginFailed(identifier, reason || 'unknown');
3230
+ }
3231
+ catch (hookError) {
3232
+ const errorMessage = hookError instanceof Error ? hookError.message : 'Unknown error';
3233
+ // Non-blocking: login already failed; do not throw
3234
+ this.logger?.error?.(`afterLoginFailed hook failed (continuing): ${errorMessage}`, { error: hookError });
3235
+ }
3236
+ }
3023
3237
  }
3024
3238
  /**
3025
3239
  * Records a login attempt with client context.
@@ -3044,10 +3258,50 @@ class AuthService {
3044
3258
  await this.loginAttemptRepository.save(attempt);
3045
3259
  }
3046
3260
  /**
3047
- * Get user by ID (sub)
3048
- * @param sub - User sub (external identifier)
3049
- * @returns User entity or null
3261
+ * Get user for authentication context
3262
+ *
3263
+ * Loads user by sub (external identifier) with all fields needed for auth context.
3264
+ * Computes hasPasswordHash from passwordHash, then removes passwordHash and other sensitive fields.
3265
+ *
3266
+ * This method is used by AuthHandler and AuthGuard to load authenticated users.
3267
+ * It ensures consistent user object shape across platforms (core + NestJS).
3268
+ *
3269
+ * @param sub - External user identifier (UUID)
3270
+ * @returns User object with hasPasswordHash flag, without sensitive fields
3271
+ * @throws {NAuthException} If user not found or account is inactive
3272
+ *
3273
+ * @example
3274
+ * ```typescript
3275
+ * const user = await authService.getUserForAuthContext('user-uuid-123');
3276
+ * // user.hasPasswordHash === true/false
3277
+ * // user.passwordHash === undefined (removed)
3278
+ * ```
3050
3279
  */
3280
+ async getUserForAuthContext(sub) {
3281
+ // Load user with all fields including passwordHash (needed to compute hasPasswordHash)
3282
+ const user = await this.userRepository.findOne({
3283
+ where: { sub },
3284
+ });
3285
+ if (!user) {
3286
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
3287
+ }
3288
+ if (!user.isActive) {
3289
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.ACCOUNT_INACTIVE, 'Account is not active');
3290
+ }
3291
+ // Compute hasPasswordHash from passwordHash
3292
+ const hasPasswordHash = Boolean(user.passwordHash);
3293
+ // Create safe user object without sensitive fields
3294
+ const safeUser = {
3295
+ ...user,
3296
+ hasPasswordHash,
3297
+ };
3298
+ // Remove sensitive fields
3299
+ delete safeUser.passwordHash;
3300
+ delete safeUser.totpSecret;
3301
+ delete safeUser.backupCodes;
3302
+ delete safeUser.passwordHistory;
3303
+ return safeUser;
3304
+ }
3051
3305
  async getUserById(dto) {
3052
3306
  const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
3053
3307
  return user ? user_response_dto_1.UserResponseDto.fromEntity(user) : null;
@@ -3154,17 +3408,9 @@ class AuthService {
3154
3408
  this.logger?.warn?.(`Password reset failed - user not found: ${dto.identifier}`);
3155
3409
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
3156
3410
  }
3157
- // ============================================================================
3158
- // Validate User Can Have Password Reset
3159
- // ============================================================================
3160
- // CRITICAL PROTECTION: Only allow for users with password authentication
3161
- // Pure social users cannot have password reset (they don't have passwords)
3162
- if (!user.passwordHash) {
3163
- this.logger?.warn?.(`Password reset failed - user doesn't have a password (pure social signup): ${user.sub}`);
3164
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_CHANGE_NOT_ALLOWED, 'Password reset not available. This account uses social authentication only and has no password.');
3165
- }
3166
3411
  const mustChangePassword = dto.mustChangePassword ?? true; // Default to true for security
3167
3412
  const revokeSessions = dto.revokeSessions !== false;
3413
+ const wasSocialOnly = !user.passwordHash;
3168
3414
  const { sessionsRevoked } = await this.updateUserPassword({
3169
3415
  user,
3170
3416
  newPassword: dto.newPassword,
@@ -3179,6 +3425,9 @@ class AuthService {
3179
3425
  metadata: {
3180
3426
  identifier: dto.identifier,
3181
3427
  mustChangePassword,
3428
+ // WHY: Admins can set the first password for social-only accounts so users can login via either route later.
3429
+ // This flag helps downstream observability without exposing anything to clients.
3430
+ wasSocialOnly,
3182
3431
  },
3183
3432
  },
3184
3433
  });
@@ -3227,34 +3476,11 @@ class AuthService {
3227
3476
  if (!user) {
3228
3477
  return response; // Non-enumerating
3229
3478
  }
3230
- // Only password-capable accounts can use forgot-password.
3231
- // Hybrid (password + social) is allowed (passwordHash exists).
3232
- if (!user.passwordHash) {
3233
- // ============================================================================
3234
- // Security: record attempt for social-only accounts (no password set)
3235
- // ============================================================================
3236
- // WHY: A malicious actor may spam forgot-password to learn about accounts or to harass users.
3237
- // We keep the API response non-enumerating, but still record an audit event for observability.
3238
- this.logger?.warn?.(`Password reset requested for social-only account; ignoring for user: ${user.sub}`);
3239
- try {
3240
- await this.auditService?.recordEvent({
3241
- userId: user.id,
3242
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PASSWORD_RESET_REQUESTED,
3243
- eventStatus: 'SUSPICIOUS',
3244
- authMethod: 'social',
3245
- reason: 'forgot_password_social_only',
3246
- description: 'Password reset requested for social-only account (ignored)',
3247
- });
3248
- }
3249
- catch (auditError) {
3250
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
3251
- this.logger?.error?.(`Failed to record PASSWORD_RESET_REQUESTED audit event: ${errorMessage}`, {
3252
- error: auditError,
3253
- userId: user.id,
3254
- });
3255
- }
3256
- return response;
3257
- }
3479
+ // ============================================================================
3480
+ // Allow social-only accounts to set their first password via forgot-password
3481
+ // ============================================================================
3482
+ // WHY: Social-first users commonly want to add a password later. The reset code proves possession
3483
+ // of the delivery channel (email/sms) and avoids weakening account security.
3258
3484
  const verificationMethod = this.config.signup?.verificationMethod ?? 'email';
3259
3485
  // ============================================================================
3260
3486
  // Determine delivery channel
@@ -3320,7 +3546,7 @@ class AuthService {
3320
3546
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.SERVICE_UNAVAILABLE, 'Password reset is not available');
3321
3547
  }
3322
3548
  const user = await this.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
3323
- if (!user || !user.passwordHash) {
3549
+ if (!user) {
3324
3550
  // Non-enumerating: treat as invalid code
3325
3551
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_RESET_CODE_INVALID, 'Invalid password reset code');
3326
3552
  }