@nauth-toolkit/core 0.1.37 → 0.1.40

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 (58) hide show
  1. package/dist/dto/get-user-sessions-response.dto.d.ts +88 -0
  2. package/dist/dto/get-user-sessions-response.dto.d.ts.map +1 -0
  3. package/dist/dto/get-user-sessions-response.dto.js +181 -0
  4. package/dist/dto/get-user-sessions-response.dto.js.map +1 -0
  5. package/dist/dto/get-user-sessions.dto.d.ts +17 -0
  6. package/dist/dto/get-user-sessions.dto.d.ts.map +1 -0
  7. package/dist/dto/get-user-sessions.dto.js +38 -0
  8. package/dist/dto/get-user-sessions.dto.js.map +1 -0
  9. package/dist/dto/index.d.ts +5 -0
  10. package/dist/dto/index.d.ts.map +1 -1
  11. package/dist/dto/index.js +5 -0
  12. package/dist/dto/index.js.map +1 -1
  13. package/dist/dto/logout-session-response.dto.d.ts +20 -0
  14. package/dist/dto/logout-session-response.dto.d.ts.map +1 -0
  15. package/dist/dto/logout-session-response.dto.js +42 -0
  16. package/dist/dto/logout-session-response.dto.js.map +1 -0
  17. package/dist/dto/logout-session.dto.d.ts +22 -0
  18. package/dist/dto/logout-session.dto.d.ts.map +1 -0
  19. package/dist/dto/logout-session.dto.js +48 -0
  20. package/dist/dto/logout-session.dto.js.map +1 -0
  21. package/dist/dto/update-verified-status-request.dto.d.ts +70 -0
  22. package/dist/dto/update-verified-status-request.dto.d.ts.map +1 -0
  23. package/dist/dto/update-verified-status-request.dto.js +107 -0
  24. package/dist/dto/update-verified-status-request.dto.js.map +1 -0
  25. package/dist/interfaces/hooks.interface.d.ts +129 -0
  26. package/dist/interfaces/hooks.interface.d.ts.map +1 -1
  27. package/dist/services/auth-service-internal-helpers.d.ts +229 -0
  28. package/dist/services/auth-service-internal-helpers.d.ts.map +1 -0
  29. package/dist/services/auth-service-internal-helpers.js +1004 -0
  30. package/dist/services/auth-service-internal-helpers.js.map +1 -0
  31. package/dist/services/auth.service.d.ts +204 -145
  32. package/dist/services/auth.service.d.ts.map +1 -1
  33. package/dist/services/auth.service.js +485 -2086
  34. package/dist/services/auth.service.js.map +1 -1
  35. package/dist/services/email-verification.service.d.ts +3 -1
  36. package/dist/services/email-verification.service.d.ts.map +1 -1
  37. package/dist/services/email-verification.service.js +77 -1
  38. package/dist/services/email-verification.service.js.map +1 -1
  39. package/dist/services/hook-registry.service.d.ts +23 -1
  40. package/dist/services/hook-registry.service.d.ts.map +1 -1
  41. package/dist/services/hook-registry.service.js +39 -0
  42. package/dist/services/hook-registry.service.js.map +1 -1
  43. package/dist/services/phone-verification.service.d.ts +3 -1
  44. package/dist/services/phone-verification.service.d.ts.map +1 -1
  45. package/dist/services/phone-verification.service.js +80 -1
  46. package/dist/services/phone-verification.service.js.map +1 -1
  47. package/dist/services/social-auth-base.service.d.ts +2 -1
  48. package/dist/services/social-auth-base.service.d.ts.map +1 -1
  49. package/dist/services/social-auth-base.service.js +5 -2
  50. package/dist/services/social-auth-base.service.js.map +1 -1
  51. package/dist/services/user.service.d.ts +274 -0
  52. package/dist/services/user.service.d.ts.map +1 -0
  53. package/dist/services/user.service.js +1327 -0
  54. package/dist/services/user.service.js.map +1 -0
  55. package/dist/utils/setup/init-services.d.ts.map +1 -1
  56. package/dist/utils/setup/init-services.js +2 -2
  57. package/dist/utils/setup/init-services.js.map +1 -1
  58. package/package.json +1 -1
@@ -40,33 +40,27 @@ const context_storage_1 = require("../utils/context-storage");
40
40
  const signup_dto_1 = require("../dto/signup.dto");
41
41
  const admin_signup_dto_1 = require("../dto/admin-signup.dto");
42
42
  const admin_signup_social_dto_1 = require("../dto/admin-signup-social.dto");
43
- const delete_user_dto_1 = require("../dto/delete-user.dto");
44
- const get_users_dto_1 = require("../dto/get-users.dto");
45
- const disable_user_dto_1 = require("../dto/disable-user.dto");
46
- const enable_user_dto_1 = require("../dto/enable-user.dto");
47
43
  const login_dto_1 = require("../dto/login.dto");
48
44
  const change_password_request_dto_1 = require("../dto/change-password-request.dto");
49
- const update_user_attributes_request_dto_1 = require("../dto/update-user-attributes-request.dto");
50
45
  const user_response_dto_1 = require("../dto/user-response.dto");
51
46
  const auth_response_dto_1 = require("../dto/auth-response.dto");
52
47
  const auth_challenge_dto_1 = require("../dto/auth-challenge.dto");
53
48
  const respond_challenge_dto_1 = require("../dto/respond-challenge.dto");
54
- const get_user_by_email_dto_1 = require("../dto/get-user-by-email.dto");
55
- const get_user_by_id_dto_1 = require("../dto/get-user-by-id.dto");
56
49
  const logout_dto_1 = require("../dto/logout.dto");
57
50
  const logout_all_dto_1 = require("../dto/logout-all.dto");
51
+ const get_user_sessions_dto_1 = require("../dto/get-user-sessions.dto");
52
+ const logout_session_dto_1 = require("../dto/logout-session.dto");
58
53
  const refresh_token_dto_1 = require("../dto/refresh-token.dto");
59
54
  const resend_code_dto_1 = require("../dto/resend-code.dto");
60
- const set_must_change_password_dto_1 = require("../dto/set-must-change-password.dto");
61
55
  const admin_set_password_dto_1 = require("../dto/admin-set-password.dto");
62
56
  const forgot_password_dto_1 = require("../dto/forgot-password.dto");
63
57
  const confirm_forgot_password_dto_1 = require("../dto/confirm-forgot-password.dto");
64
58
  const verify_email_dto_1 = require("../dto/verify-email.dto");
65
59
  const verify_phone_dto_1 = require("../dto/verify-phone.dto");
66
- const verify_phone_by_sub_dto_1 = require("../dto/verify-phone-by-sub.dto");
60
+ const auth_service_internal_helpers_1 = require("./auth-service-internal-helpers");
61
+ const user_service_1 = require("./user.service");
67
62
  const nauth_exception_1 = require("../exceptions/nauth.exception");
68
63
  const error_codes_enum_1 = require("../enums/error-codes.enum");
69
- const mfa_method_enum_1 = require("../enums/mfa-method.enum");
70
64
  const class_validator_1 = require("class-validator");
71
65
  const crypto = __importStar(require("crypto"));
72
66
  const password_generator_1 = require("../utils/password-generator");
@@ -107,6 +101,8 @@ class AuthService {
107
101
  challengeSessionRepository;
108
102
  authAuditRepository;
109
103
  trustedDeviceRepository;
104
+ helpers;
105
+ userService;
110
106
  constructor(userRepository, loginAttemptRepository, passwordService, jwtService, sessionService, challengeService, challengeHelper, emailVerificationService, clientInfoService, accountLockoutStorage, config, logger, hookRegistry, auditService, // Optional - audit trail service (enabled via config.auditLogs.enabled)
111
107
  phoneVerificationService, // Optional - only available when SMS provider is configured
112
108
  mfaService, // Optional - available when MFA modules are imported
@@ -146,6 +142,10 @@ class AuthService {
146
142
  this.challengeSessionRepository = challengeSessionRepository;
147
143
  this.authAuditRepository = authAuditRepository;
148
144
  this.trustedDeviceRepository = trustedDeviceRepository;
145
+ // Initialize internal helpers with only needed dependencies
146
+ this.helpers = new auth_service_internal_helpers_1.AuthServiceInternalHelpers(userRepository, loginAttemptRepository, emailVerificationService, phoneVerificationService, challengeService, challengeHelper, clientInfoService, sessionService, accountLockoutStorage, config, logger);
147
+ // Initialize UserService for user data management
148
+ this.userService = new user_service_1.UserService(userRepository, loginAttemptRepository, sessionService, config, logger, mfaDeviceRepository, auditService, hookRegistry, clientInfoService, sessionRepository, verificationTokenRepository, socialAccountRepository, challengeSessionRepository, authAuditRepository, trustedDeviceRepository, this.helpers);
149
149
  this.logger?.log?.('AuthService initialized');
150
150
  }
151
151
  // ============================================================================
@@ -735,10 +735,16 @@ class AuthService {
735
735
  // Lifecycle Hook: afterSignup
736
736
  // ============================================================================
737
737
  // Execute afterSignup hook immediately after account creation (non-blocking)
738
+ // Extract profile picture from social metadata if available
739
+ const profilePicture = dto.socialMetadata && typeof dto.socialMetadata === 'object' && 'picture' in dto.socialMetadata
740
+ ? dto.socialMetadata.picture
741
+ : null;
738
742
  await this.hookRegistry.executePostSignup(savedUser, {
739
743
  signupType: 'social',
740
744
  provider: dto.provider,
741
745
  adminSignup: true,
746
+ socialMetadata: dto.socialMetadata || null,
747
+ profilePicture,
742
748
  });
743
749
  // ============================================================================
744
750
  // Audit: Record account creation by admin (social import)
@@ -846,124 +852,7 @@ class AuthService {
846
852
  * ```
847
853
  */
848
854
  async deleteUser(dto) {
849
- // Ensure DTO is validated
850
- dto = await (0, dto_validator_1.ensureValidatedDto)(delete_user_dto_1.DeleteUserDTO, dto);
851
- // Get client info for audit
852
- const clientInfo = this.clientInfoService.get();
853
- this.logger?.log?.(`Admin deleteUser initiated for sub: ${dto.sub}`);
854
- // Find user by sub
855
- const user = await this.userRepository.findOne({ where: { sub: dto.sub } });
856
- if (!user) {
857
- this.logger?.warn?.(`User not found for deletion: ${dto.sub}`);
858
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found');
859
- }
860
- this.logger?.debug?.(`Deleting user ${user.email} (id: ${user.id}, sub: ${dto.sub})`);
861
- // ============================================================================
862
- // Explicit Cascade Deletion (to track counts)
863
- // ============================================================================
864
- // Even though database has CASCADE, we explicitly delete each table to track counts
865
- // 1. Delete Sessions
866
- let sessionsCount = 0;
867
- if (this.sessionRepository) {
868
- const result = await this.sessionRepository.delete({ userId: user.id });
869
- sessionsCount = result.affected || 0;
870
- this.logger?.debug?.(`Deleted ${sessionsCount} sessions for user ${dto.sub}`);
871
- }
872
- // 2. Delete Verification Tokens
873
- let verificationTokensCount = 0;
874
- if (this.verificationTokenRepository) {
875
- const result = await this.verificationTokenRepository.delete({ userId: user.id });
876
- verificationTokensCount = result.affected || 0;
877
- this.logger?.debug?.(`Deleted ${verificationTokensCount} verification tokens for user ${dto.sub}`);
878
- }
879
- // 3. Delete MFA Devices
880
- let mfaDevicesCount = 0;
881
- if (this.mfaDeviceRepository) {
882
- const result = await this.mfaDeviceRepository.delete({ userId: user.id });
883
- mfaDevicesCount = result.affected || 0;
884
- this.logger?.debug?.(`Deleted ${mfaDevicesCount} MFA devices for user ${dto.sub}`);
885
- }
886
- // 4. Delete Trusted Devices
887
- let trustedDevicesCount = 0;
888
- if (this.trustedDeviceRepository) {
889
- const result = await this.trustedDeviceRepository.delete({ userId: user.id });
890
- trustedDevicesCount = result.affected || 0;
891
- this.logger?.debug?.(`Deleted ${trustedDevicesCount} trusted devices for user ${dto.sub}`);
892
- }
893
- // 5. Delete Social Accounts
894
- let socialAccountsCount = 0;
895
- if (this.socialAccountRepository) {
896
- const result = await this.socialAccountRepository.delete({ userId: user.id });
897
- socialAccountsCount = result.affected || 0;
898
- this.logger?.debug?.(`Deleted ${socialAccountsCount} social accounts for user ${dto.sub}`);
899
- }
900
- // 6. Delete Login Attempts
901
- let loginAttemptsCount = 0;
902
- const loginAttemptResult = await this.loginAttemptRepository.delete({ userId: user.id });
903
- loginAttemptsCount = loginAttemptResult.affected || 0;
904
- this.logger?.debug?.(`Deleted ${loginAttemptsCount} login attempts for user ${dto.sub}`);
905
- // 7. Delete Challenge Sessions
906
- let challengeSessionsCount = 0;
907
- if (this.challengeSessionRepository) {
908
- const result = await this.challengeSessionRepository.delete({ userId: user.id });
909
- challengeSessionsCount = result.affected || 0;
910
- this.logger?.debug?.(`Deleted ${challengeSessionsCount} challenge sessions for user ${dto.sub}`);
911
- }
912
- // 8. Delete Audit Logs (user-specific)
913
- let auditLogsCount = 0;
914
- if (this.authAuditRepository) {
915
- const result = await this.authAuditRepository.delete({ userId: user.id });
916
- auditLogsCount = result.affected || 0;
917
- this.logger?.debug?.(`Deleted ${auditLogsCount} audit logs for user ${dto.sub}`);
918
- }
919
- // ============================================================================
920
- // Record Admin Action (BEFORE deleting user to satisfy foreign key constraint)
921
- // ============================================================================
922
- try {
923
- await this.auditService?.recordEvent({
924
- userId: user.id,
925
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.ACCOUNT_DELETED,
926
- eventStatus: 'INFO',
927
- authMethod: 'admin',
928
- metadata: {
929
- deletedEmail: user.email,
930
- deletedSub: dto.sub,
931
- adminIdentifier: clientInfo.ipAddress || 'unknown',
932
- deletedRecords: {
933
- sessions: sessionsCount,
934
- verificationTokens: verificationTokensCount,
935
- mfaDevices: mfaDevicesCount,
936
- trustedDevices: trustedDevicesCount,
937
- socialAccounts: socialAccountsCount,
938
- loginAttempts: loginAttemptsCount,
939
- challengeSessions: challengeSessionsCount,
940
- auditLogs: auditLogsCount,
941
- },
942
- },
943
- });
944
- }
945
- catch (auditError) {
946
- // Non-blocking: Log but continue
947
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
948
- this.logger?.error?.(`Failed to record ACCOUNT_DELETED audit event: ${errorMessage}`);
949
- }
950
- // 9. Delete User Record (final)
951
- await this.userRepository.delete({ id: user.id });
952
- this.logger?.log?.(`User deleted successfully: ${user.email} (sub: ${dto.sub})`);
953
- return {
954
- success: true,
955
- deletedUserId: dto.sub,
956
- deletedRecords: {
957
- sessions: sessionsCount,
958
- verificationTokens: verificationTokensCount,
959
- mfaDevices: mfaDevicesCount,
960
- trustedDevices: trustedDevicesCount,
961
- socialAccounts: socialAccountsCount,
962
- loginAttempts: loginAttemptsCount,
963
- challengeSessions: challengeSessionsCount,
964
- auditLogs: auditLogsCount,
965
- },
966
- };
855
+ return await this.userService.deleteUser(dto);
967
856
  }
968
857
  /**
969
858
  * Get paginated list of users with advanced filtering
@@ -992,100 +881,7 @@ class AuthService {
992
881
  * ```
993
882
  */
994
883
  async getUsers(dto) {
995
- // Ensure DTO is validated
996
- dto = await (0, dto_validator_1.ensureValidatedDto)(get_users_dto_1.GetUsersDTO, dto);
997
- this.logger?.debug?.(`Admin getUsers initiated with filters: ${JSON.stringify(dto)}`);
998
- // ============================================================================
999
- // Build Query with Filters
1000
- // ============================================================================
1001
- const qb = this.userRepository.createQueryBuilder('user');
1002
- // Apply partial match filters (email and phone) - case-insensitive
1003
- // Using LOWER() for cross-database compatibility (works on both MySQL and PostgreSQL)
1004
- if (dto.email) {
1005
- qb.andWhere('LOWER(user.email) LIKE LOWER(:email)', { email: `%${dto.email}%` });
1006
- }
1007
- if (dto.phone) {
1008
- qb.andWhere('LOWER(user.phone) LIKE LOWER(:phone)', { phone: `%${dto.phone}%` });
1009
- }
1010
- // Apply boolean filters
1011
- if (dto.isEmailVerified !== undefined) {
1012
- qb.andWhere('user.isEmailVerified = :isEmailVerified', { isEmailVerified: dto.isEmailVerified });
1013
- }
1014
- if (dto.isPhoneVerified !== undefined) {
1015
- qb.andWhere('user.isPhoneVerified = :isPhoneVerified', { isPhoneVerified: dto.isPhoneVerified });
1016
- }
1017
- if (dto.hasSocialAuth !== undefined) {
1018
- qb.andWhere('user.hasSocialAuth = :hasSocialAuth', { hasSocialAuth: dto.hasSocialAuth });
1019
- }
1020
- if (dto.isLocked !== undefined) {
1021
- qb.andWhere('user.isLocked = :isLocked', { isLocked: dto.isLocked });
1022
- }
1023
- if (dto.mfaEnabled !== undefined) {
1024
- qb.andWhere('user.mfaEnabled = :mfaEnabled', { mfaEnabled: dto.mfaEnabled });
1025
- }
1026
- // Apply date filters with operators
1027
- if (dto.createdAt) {
1028
- const { operator, value } = dto.createdAt;
1029
- if (operator === 'gt') {
1030
- qb.andWhere('user.createdAt > :createdAtValue', { createdAtValue: value });
1031
- }
1032
- else if (operator === 'gte') {
1033
- qb.andWhere('user.createdAt >= :createdAtValue', { createdAtValue: value });
1034
- }
1035
- else if (operator === 'lt') {
1036
- qb.andWhere('user.createdAt < :createdAtValue', { createdAtValue: value });
1037
- }
1038
- else if (operator === 'lte') {
1039
- qb.andWhere('user.createdAt <= :createdAtValue', { createdAtValue: value });
1040
- }
1041
- else if (operator === 'eq') {
1042
- qb.andWhere('user.createdAt = :createdAtValue', { createdAtValue: value });
1043
- }
1044
- }
1045
- if (dto.updatedAt) {
1046
- const { operator, value } = dto.updatedAt;
1047
- if (operator === 'gt') {
1048
- qb.andWhere('user.updatedAt > :updatedAtValue', { updatedAtValue: value });
1049
- }
1050
- else if (operator === 'gte') {
1051
- qb.andWhere('user.updatedAt >= :updatedAtValue', { updatedAtValue: value });
1052
- }
1053
- else if (operator === 'lt') {
1054
- qb.andWhere('user.updatedAt < :updatedAtValue', { updatedAtValue: value });
1055
- }
1056
- else if (operator === 'lte') {
1057
- qb.andWhere('user.updatedAt <= :updatedAtValue', { updatedAtValue: value });
1058
- }
1059
- else if (operator === 'eq') {
1060
- qb.andWhere('user.updatedAt = :updatedAtValue', { updatedAtValue: value });
1061
- }
1062
- }
1063
- // ============================================================================
1064
- // Apply Sorting
1065
- // ============================================================================
1066
- const sortBy = dto.sortBy || 'createdAt';
1067
- const sortOrder = dto.sortOrder || 'DESC';
1068
- qb.orderBy(`user.${sortBy}`, sortOrder);
1069
- // ============================================================================
1070
- // Apply Pagination
1071
- // ============================================================================
1072
- const page = dto.page || 1;
1073
- const limit = dto.limit || 10;
1074
- qb.skip((page - 1) * limit).take(limit);
1075
- // Execute query
1076
- const [users, total] = await qb.getManyAndCount();
1077
- this.logger?.debug?.(`Found ${users.length} users (total: ${total}) with filters`);
1078
- // Sanitize user data
1079
- const sanitizedUsers = users.map((user) => user_response_dto_1.UserResponseDto.fromEntity(user));
1080
- return {
1081
- users: sanitizedUsers,
1082
- pagination: {
1083
- page,
1084
- limit,
1085
- total,
1086
- totalPages: Math.ceil(total / limit),
1087
- },
1088
- };
884
+ return await this.userService.getUsers(dto);
1089
885
  }
1090
886
  /**
1091
887
  * Administrative permanent account locking
@@ -1116,112 +912,7 @@ class AuthService {
1116
912
  * ```
1117
913
  */
1118
914
  async disableUser(dto) {
1119
- // Ensure DTO is validated
1120
- dto = await (0, dto_validator_1.ensureValidatedDto)(disable_user_dto_1.DisableUserDTO, dto);
1121
- // Get client info for audit
1122
- const clientInfo = this.clientInfoService.get();
1123
- this.logger?.log?.(`Admin disableUser initiated for sub: ${dto.sub}`);
1124
- // Find user by sub
1125
- const user = await this.userRepository.findOne({ where: { sub: dto.sub } });
1126
- if (!user) {
1127
- this.logger?.warn?.(`User not found for disabling: ${dto.sub}`);
1128
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found');
1129
- }
1130
- this.logger?.debug?.(`Disabling user ${user.email} (id: ${user.id}, sub: ${dto.sub})`);
1131
- // ============================================================================
1132
- // Set Permanent Lock (lockedUntil = NULL)
1133
- // ============================================================================
1134
- // Use update() to ensure persistence and avoid entity state issues
1135
- await this.userRepository.update({ id: user.id }, {
1136
- isLocked: true,
1137
- lockReason: dto.reason || 'Account disabled',
1138
- lockedAt: new Date(),
1139
- lockedUntil: null, // NULL = permanent lock (vs rate-limit's future date)
1140
- });
1141
- // Reload user to get updated entity with lock fields
1142
- const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
1143
- if (!updatedUser) {
1144
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found after update');
1145
- }
1146
- this.logger?.log?.(`User locked permanently: ${updatedUser.email} (sub: ${dto.sub})`);
1147
- // ============================================================================
1148
- // Revoke All Sessions (force logout)
1149
- // ============================================================================
1150
- let revokedCount = 0;
1151
- try {
1152
- revokedCount = await this.sessionService.revokeAllUserSessions(updatedUser.id, 'Account disabled');
1153
- this.logger?.debug?.(`Revoked ${revokedCount} sessions for user ${dto.sub}`);
1154
- }
1155
- catch (sessionError) {
1156
- // Non-blocking: Log but continue
1157
- const errorMessage = sessionError instanceof Error ? sessionError.message : 'Unknown error';
1158
- this.logger?.warn?.(`Failed to revoke sessions for user ${dto.sub}: ${errorMessage}`);
1159
- }
1160
- // ============================================================================
1161
- // Record Admin Action (ACCOUNT_DISABLED)
1162
- // ============================================================================
1163
- if (!this.auditService) {
1164
- this.logger?.warn?.(`Audit service not available - ACCOUNT_DISABLED event not recorded for user ${dto.sub}. Enable audit logs in config.auditLogs.enabled`);
1165
- }
1166
- else {
1167
- try {
1168
- // Get admin user ID from client info (the currently logged in user performing this action)
1169
- // This is extracted from the JWT token by interceptors/handlers
1170
- const adminUserId = clientInfo?.userId;
1171
- // Set performedBy to the admin's user ID (who locked the account)
1172
- // This identifies which admin user performed the action in the audit trail
1173
- const performedBy = adminUserId ? String(adminUserId) : clientInfo.ipAddress || 'system';
1174
- if (adminUserId) {
1175
- this.logger?.debug?.(`Admin user ID ${adminUserId} (currently logged in) is disabling account for user ${dto.sub}`);
1176
- }
1177
- else {
1178
- this.logger?.warn?.(`No admin user ID in clientInfo - performedBy will be set to IP address or 'system' for user ${dto.sub}`);
1179
- }
1180
- const auditResult = await this.auditService.recordEvent({
1181
- userId: updatedUser.id, // The user whose account is being disabled
1182
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.ACCOUNT_DISABLED,
1183
- eventStatus: 'INFO',
1184
- authMethod: 'admin',
1185
- performedBy, // The admin user ID (currently logged in user) who performed this action
1186
- reason: updatedUser.lockReason || 'Account disabled',
1187
- description: `Account disabled by administrator. User: ${updatedUser.email} (sub: ${dto.sub}). ${revokedCount} session(s) revoked.`,
1188
- metadata: {
1189
- email: updatedUser.email,
1190
- userSub: dto.sub,
1191
- reason: updatedUser.lockReason,
1192
- adminIdentifier: clientInfo.ipAddress || 'unknown',
1193
- adminUserId: adminUserId || null,
1194
- revokedSessions: revokedCount,
1195
- lockedAt: updatedUser.lockedAt,
1196
- lockedUntil: updatedUser.lockedUntil,
1197
- },
1198
- });
1199
- if (auditResult) {
1200
- this.logger?.debug?.(`ACCOUNT_DISABLED audit event recorded successfully for user ${dto.sub}`);
1201
- }
1202
- else {
1203
- this.logger?.warn?.(`ACCOUNT_DISABLED audit event returned null for user ${dto.sub}`);
1204
- }
1205
- }
1206
- catch (auditError) {
1207
- // Non-blocking: Log but continue
1208
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
1209
- const errorStack = auditError instanceof Error ? auditError.stack : undefined;
1210
- this.logger?.error?.(`Failed to record ACCOUNT_DISABLED audit event: ${errorMessage}`, {
1211
- error: auditError,
1212
- errorStack,
1213
- userId: updatedUser.id,
1214
- userSub: dto.sub,
1215
- });
1216
- }
1217
- }
1218
- // Return sanitized user and revoked session count
1219
- const userDto = user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
1220
- return {
1221
- success: true,
1222
- user: userDto,
1223
- revokedSessions: revokedCount,
1224
- };
915
+ return await this.userService.disableUser(dto);
1225
916
  }
1226
917
  /**
1227
918
  * Enable (unlock) user account
@@ -1248,94 +939,7 @@ class AuthService {
1248
939
  * ```
1249
940
  */
1250
941
  async enableUser(dto) {
1251
- // Ensure DTO is validated
1252
- dto = await (0, dto_validator_1.ensureValidatedDto)(enable_user_dto_1.EnableUserDTO, dto);
1253
- // Get client info for audit
1254
- const clientInfo = this.clientInfoService.get();
1255
- this.logger?.log?.(`Admin enableUser initiated for sub: ${dto.sub}`);
1256
- // Find user by sub
1257
- const user = await this.userRepository.findOne({ where: { sub: dto.sub } });
1258
- if (!user) {
1259
- this.logger?.warn?.(`User not found for enabling: ${dto.sub}`);
1260
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found');
1261
- }
1262
- this.logger?.debug?.(`Enabling user ${user.email} (id: ${user.id}, sub: ${dto.sub})`);
1263
- // ============================================================================
1264
- // Clear Lock Fields (unlock account)
1265
- // ============================================================================
1266
- await this.userRepository.update({ id: user.id }, {
1267
- isLocked: false,
1268
- lockReason: null,
1269
- lockedAt: null,
1270
- lockedUntil: null,
1271
- failedLoginAttempts: 0, // Reset failed attempts counter
1272
- });
1273
- // Reload user to get updated entity
1274
- const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
1275
- if (!updatedUser) {
1276
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found after update');
1277
- }
1278
- this.logger?.log?.(`User unlocked: ${updatedUser.email} (sub: ${dto.sub})`);
1279
- // ============================================================================
1280
- // Record Admin Action (ACCOUNT_ENABLED)
1281
- // ============================================================================
1282
- if (!this.auditService) {
1283
- this.logger?.warn?.(`Audit service not available - ACCOUNT_ENABLED event not recorded for user ${dto.sub}. Enable audit logs in config.auditLogs.enabled`);
1284
- }
1285
- else {
1286
- try {
1287
- // Get admin user ID from client info (the currently logged in user performing this action)
1288
- const adminUserId = clientInfo?.userId;
1289
- // Set performedBy to the admin's user ID (who unlocked the account)
1290
- const performedBy = adminUserId ? String(adminUserId) : clientInfo.ipAddress || 'system';
1291
- if (adminUserId) {
1292
- this.logger?.debug?.(`Admin user ID ${adminUserId} (currently logged in) is enabling account for user ${dto.sub}`);
1293
- }
1294
- else {
1295
- this.logger?.warn?.(`No admin user ID in clientInfo - performedBy will be set to IP address or 'system' for user ${dto.sub}`);
1296
- }
1297
- const auditResult = await this.auditService.recordEvent({
1298
- userId: updatedUser.id,
1299
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.ACCOUNT_ENABLED,
1300
- eventStatus: 'INFO',
1301
- authMethod: 'admin',
1302
- performedBy,
1303
- reason: 'admin_unlock',
1304
- description: 'Account unlocked by administrator',
1305
- metadata: {
1306
- userSub: dto.sub,
1307
- adminIdentifier: clientInfo.ipAddress || 'unknown',
1308
- adminUserId: adminUserId || null,
1309
- previousLockReason: user.lockReason,
1310
- previousLockedAt: user.lockedAt,
1311
- previousLockedUntil: user.lockedUntil,
1312
- },
1313
- });
1314
- if (auditResult) {
1315
- this.logger?.debug?.(`ACCOUNT_ENABLED audit event recorded successfully for user ${dto.sub}`);
1316
- }
1317
- else {
1318
- this.logger?.warn?.(`ACCOUNT_ENABLED audit event returned null for user ${dto.sub}`);
1319
- }
1320
- }
1321
- catch (auditError) {
1322
- // Non-blocking: Log but continue
1323
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
1324
- const errorStack = auditError instanceof Error ? auditError.stack : undefined;
1325
- this.logger?.error?.(`Failed to record ACCOUNT_ENABLED audit event: ${errorMessage}`, {
1326
- error: auditError,
1327
- errorStack,
1328
- userId: updatedUser.id,
1329
- userSub: dto.sub,
1330
- });
1331
- }
1332
- }
1333
- // Return sanitized user
1334
- const userDto = user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
1335
- return {
1336
- success: true,
1337
- user: userDto,
1338
- };
942
+ return await this.userService.enableUser(dto);
1339
943
  }
1340
944
  // ============================================================================
1341
945
  // User Login
@@ -1375,7 +979,7 @@ class AuthService {
1375
979
  const isLocked = await this.accountLockoutStorage.isAccountLocked(ipAddress);
1376
980
  if (isLocked) {
1377
981
  this.logger?.warn?.(`Login blocked - IP locked: ${ipAddress}`);
1378
- await this.recordLoginAttempt(dto.identifier, false, 'ip_locked');
982
+ await this.helpers.recordLoginAttempt(dto.identifier, false, 'ip_locked');
1379
983
  // ============================================================================
1380
984
  // Audit: Record blocked login (IP locked)
1381
985
  // ============================================================================
@@ -1425,16 +1029,16 @@ class AuthService {
1425
1029
  const identifierType = this.config.login?.identifierType;
1426
1030
  if (identifierType) {
1427
1031
  this.logger?.debug?.(`Validating identifier type for: ${dto.identifier}, allowed type: ${identifierType}`);
1428
- const isValidIdentifier = this.validateIdentifierType(dto.identifier, identifierType);
1032
+ const isValidIdentifier = this.helpers.validateIdentifierType(dto.identifier, identifierType);
1429
1033
  if (!isValidIdentifier) {
1430
1034
  this.logger?.warn?.(`Login rejected - identifier type mismatch. Identifier: ${dto.identifier}, Required: ${identifierType}`);
1431
- await this.handleFailedLogin(dto.identifier, 'identifier_type_mismatch');
1035
+ await this.helpers.handleFailedLogin(dto.identifier, 'identifier_type_mismatch');
1432
1036
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INVALID_CREDENTIALS, `Login with this identifier type is not allowed. Expected: ${identifierType}`);
1433
1037
  }
1434
1038
  }
1435
1039
  // Find user by email, username, or phone (filtered by identifierType config)
1436
1040
  this.logger?.debug?.(`Finding user by identifier: ${dto.identifier}`);
1437
- const user = await this.findUserByIdentifier(dto.identifier, identifierType);
1041
+ const user = await this.helpers.findUserByIdentifier(dto.identifier, identifierType);
1438
1042
  // SECURITY CRITICAL: Always hash password even when user doesn't exist
1439
1043
  // This ensures constant-time response to prevent user enumeration via timing attacks
1440
1044
  const hashToVerify = user?.passwordHash || DUMMY_ARGON2_HASH;
@@ -1444,7 +1048,7 @@ class AuthService {
1444
1048
  // Now check all conditions AFTER password verification (constant time achieved)
1445
1049
  if (!user || !user.passwordHash || !isPasswordValid) {
1446
1050
  this.logger?.warn?.(`Login failed - invalid credentials for: ${dto.identifier}`);
1447
- await this.handleFailedLogin(dto.identifier, 'invalid_credentials');
1051
+ await this.helpers.handleFailedLogin(dto.identifier, 'invalid_credentials');
1448
1052
  // ============================================================================
1449
1053
  // Audit: Record failed login
1450
1054
  // ============================================================================
@@ -1510,7 +1114,7 @@ class AuthService {
1510
1114
  const lockReason = user.lockReason || 'Account is locked';
1511
1115
  this.logger?.warn?.(`Login blocked - account locked for user: ${user.email} (sub: ${user.sub}). Reason: ${lockReason}`);
1512
1116
  // Record blocked login attempt
1513
- await this.recordLoginAttempt(dto.identifier, false, 'account_locked');
1117
+ await this.helpers.recordLoginAttempt(dto.identifier, false, 'account_locked');
1514
1118
  // ============================================================================
1515
1119
  // Audit: Record blocked login (account locked)
1516
1120
  // ============================================================================
@@ -1653,7 +1257,7 @@ class AuthService {
1653
1257
  [auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED]: 'mfa_required',
1654
1258
  };
1655
1259
  this.logger?.warn?.(`Login blocked - pending challenge: ${response.challengeName} for ${dto.identifier} (sub: ${user.sub})`);
1656
- await this.recordLoginAttempt(dto.identifier, false, reasonMap[response.challengeName] || 'challenge_required', user.id);
1260
+ await this.helpers.recordLoginAttempt(dto.identifier, false, reasonMap[response.challengeName] || 'challenge_required', user.id);
1657
1261
  return response;
1658
1262
  }
1659
1263
  // If response already has tokens (session was created by challenge helper), return it
@@ -1661,7 +1265,7 @@ class AuthService {
1661
1265
  if (response.accessToken && response.refreshToken) {
1662
1266
  this.logger?.debug?.(`Login successful - session already created by challenge helper for ${dto.identifier} (sub: ${user.sub})`);
1663
1267
  // Record successful login attempt
1664
- await this.recordLoginAttempt(dto.identifier, true, undefined, user.id);
1268
+ await this.helpers.recordLoginAttempt(dto.identifier, true, undefined, user.id);
1665
1269
  this.logger?.log?.(`Login successful for: ${dto.identifier} (sub: ${user.sub}) from ${clientInfo.ipAddress}`);
1666
1270
  // Update user last login info
1667
1271
  await this.userRepository.update(user.id, {
@@ -1795,7 +1399,7 @@ class AuthService {
1795
1399
  // Check if user is active (should never happen with new signups, but keep for legacy accounts)
1796
1400
  if (!user.isActive) {
1797
1401
  this.logger?.warn?.(`Login failed - account inactive: ${dto.identifier} (sub: ${user.sub})`);
1798
- await this.recordLoginAttempt(dto.identifier, false, 'account_inactive', user.id);
1402
+ await this.helpers.recordLoginAttempt(dto.identifier, false, 'account_inactive', user.id);
1799
1403
  // ============================================================================
1800
1404
  // Audit: Record blocked login (account inactive)
1801
1405
  // ============================================================================
@@ -1883,7 +1487,7 @@ class AuthService {
1883
1487
  failedLoginAttempts: 0,
1884
1488
  });
1885
1489
  // Record successful login attempt - use internal id
1886
- await this.recordLoginAttempt(dto.identifier, true, undefined, user.id);
1490
+ await this.helpers.recordLoginAttempt(dto.identifier, true, undefined, user.id);
1887
1491
  this.logger?.log?.(`Login successful for: ${dto.identifier} (sub: ${user.sub}) from ${clientInfo.ipAddress}`);
1888
1492
  // ============================================================================
1889
1493
  // Audit: Record successful login with trusted device and MFA bypass metadata
@@ -2015,896 +1619,140 @@ class AuthService {
2015
1619
  // Validate session and get challenge type
2016
1620
  const challengeSession = await this.challengeService.validateSession(session);
2017
1621
  // Validate response matches expected challenge
2018
- this.validateChallengeTypeMatch(challengeSession.challengeName, type);
1622
+ this.helpers.validateChallengeTypeMatch(challengeSession.challengeName, type);
2019
1623
  // Validate parameters for this challenge type
2020
1624
  // TODO: Later check if we can use classvalidator to replicate the logic of DTO validation centrally
2021
- this.validateChallengeParams(type, responseData);
1625
+ this.helpers.validateChallengeParams(type, responseData);
2022
1626
  // Handle challenge based on type
2023
1627
  switch (type) {
2024
1628
  case 'VERIFY_EMAIL':
2025
- return await this.handleVerifyEmail(challengeSession, responseData.code);
1629
+ return await this.helpers.handleVerifyEmail(challengeSession, responseData.code);
2026
1630
  case 'VERIFY_PHONE':
2027
- return await this.handleVerifyPhone(challengeSession, responseData);
1631
+ return await this.helpers.handleVerifyPhone(challengeSession, responseData);
2028
1632
  case 'MFA_REQUIRED':
2029
- return await this.handleMFAVerification(challengeSession, responseData);
1633
+ return await this.helpers.handleMFAVerification(challengeSession, responseData, this.mfaService, this.trustedDeviceService, this.auditService);
2030
1634
  case 'FORCE_CHANGE_PASSWORD':
2031
- return await this.handleForceChangePassword(challengeSession, responseData.newPassword);
1635
+ return await this.helpers.handleForceChangePassword(challengeSession, responseData.newPassword, this.passwordService, this.auditService);
2032
1636
  case 'MFA_SETUP_REQUIRED':
2033
- return await this.handleMFASetup(challengeSession, responseData);
1637
+ return await this.helpers.handleMFASetup(challengeSession, responseData, this.mfaService, this.auditService);
2034
1638
  default:
2035
1639
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Unknown challenge type: ${type}`);
2036
1640
  }
2037
1641
  }
2038
1642
  /**
2039
- * Validate that response type matches expected challenge type
2040
- */
2041
- validateChallengeTypeMatch(expected, provided) {
2042
- if (expected !== provided) {
2043
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Challenge type mismatch: expected ${expected}, got ${provided}`);
2044
- }
2045
- }
2046
- /**
2047
- * Validate parameters for challenge type
1643
+ * Resend verification code for current challenge
1644
+ *
1645
+ * Determines the challenge type from the session and resends the appropriate code:
1646
+ * - VERIFY_EMAIL: Resends email verification code
1647
+ * - VERIFY_PHONE: Resends SMS verification code
1648
+ * - MFA_REQUIRED: Resends MFA code (for SMS MFA)
1649
+ *
1650
+ * Rate limits are enforced internally by the verification services.
1651
+ *
1652
+ * @param dto - Resend code request with challenge session token
1653
+ * @returns Destination info (masked email/phone)
1654
+ * @throws {NAuthException} INVALID_CHALLENGE_SESSION | RATE_LIMIT_* | VALIDATION_FAILED
2048
1655
  *
2049
- * Service-level validation ensures Express/other frameworks get same validation as NestJS.
2050
- * This is critical for non-DTO-based applications.
1656
+ * @example
1657
+ * ```typescript
1658
+ * const result = await authService.resendCode({ session: 'challenge-token' });
1659
+ * // Returns: { destination: 'u***r@example.com' }
1660
+ * ```
2051
1661
  */
2052
- validateChallengeParams(type, data) {
2053
- switch (type) {
2054
- case 'VERIFY_EMAIL': {
2055
- const response = data;
2056
- if (!response.code || typeof response.code !== 'string') {
2057
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Verification code is required', { field: 'code' });
2058
- }
2059
- break;
1662
+ async resendCode(dto) {
1663
+ // Ensure DTO is validated (supports direct usage without framework validation)
1664
+ dto = await (0, dto_validator_1.ensureValidatedDto)(resend_code_dto_1.ResendCodeDTO, dto);
1665
+ this.logger?.debug?.(`Resending verification code: session=${dto.session}`);
1666
+ // Validate session (session must be valid to resend)
1667
+ const challengeSession = await this.challengeService.validateSession(dto.session);
1668
+ // Get user from session
1669
+ const user = challengeSession.user;
1670
+ if (!user) {
1671
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Challenge session has no associated user');
1672
+ }
1673
+ // Handle based on challenge type
1674
+ switch (challengeSession.challengeName) {
1675
+ case auth_challenge_dto_1.AuthChallenge.VERIFY_EMAIL: {
1676
+ // Resend email verification
1677
+ // Pass challengeSessionId to ensure new token is linked to this challenge session
1678
+ const resendDto = Object.assign(new verify_email_dto_1.ResendVerificationEmailDTO(), {
1679
+ sub: user.sub,
1680
+ challengeSessionId: challengeSession.id,
1681
+ });
1682
+ await this.emailVerificationService.resendVerificationEmail(resendDto);
1683
+ const maskedEmail = this.helpers.maskEmail(user.email);
1684
+ this.logger?.debug?.(`Email verification code resent: user=${user.sub}, email=${maskedEmail}`);
1685
+ return { destination: maskedEmail };
2060
1686
  }
2061
- case 'VERIFY_PHONE': {
2062
- const response = data;
2063
- const hasCode = 'code' in response && response.code;
2064
- const hasPhone = 'phone' in response && response.phone;
2065
- if (!hasCode && !hasPhone) {
2066
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Either phone number or verification code is required', { fields: ['phone', 'code'] });
1687
+ case auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE: {
1688
+ // Check if phone already collected
1689
+ if (!user.phone) {
1690
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Phone number not yet provided. Submit phone number first.');
1691
+ }
1692
+ if (!this.phoneVerificationService) {
1693
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'Phone verification service is not available');
2067
1694
  }
2068
- break;
1695
+ // Resend SMS verification
1696
+ const resendDto = Object.assign(new verify_phone_dto_1.ResendVerificationSMSDTO(), { sub: user.sub });
1697
+ await this.phoneVerificationService.resendVerificationSMS(resendDto);
1698
+ const maskedPhone = this.helpers.maskPhone(user.phone);
1699
+ this.logger?.debug?.(`Phone verification code resent: user=${user.sub}, phone=${maskedPhone}`);
1700
+ return { destination: maskedPhone };
2069
1701
  }
2070
- case 'MFA_REQUIRED': {
2071
- const response = data;
2072
- if (!response.method) {
2073
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA method is required', { field: 'method' });
1702
+ case auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED: {
1703
+ // For MFA, we need to know which method is being used
1704
+ // Method is stored in metadata when challenge is created (see auth-challenge-helper.service.ts line 403)
1705
+ // Note: challengeParameters is never populated - only metadata is used
1706
+ const metadata = challengeSession.metadata;
1707
+ const method = metadata?.method;
1708
+ if (!method) {
1709
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Cannot resend MFA code: method not specified in session');
2074
1710
  }
2075
- if (response.method === 'passkey') {
2076
- const passkeyResponse = response;
2077
- if (!passkeyResponse.credential) {
2078
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Passkey credential is required', {
2079
- field: 'credential',
1711
+ // SMS and Email MFA support resending codes
1712
+ if (method === 'sms' || method === 'email') {
1713
+ // For SMS, use phone verification service directly to pass challengeSessionId
1714
+ if (method === 'sms' && this.phoneVerificationService) {
1715
+ const smsDto = Object.assign(new verify_phone_dto_1.SendVerificationSMSDTO(), {
1716
+ sub: user.sub,
1717
+ skipAlreadyVerifiedCheck: true,
1718
+ challengeSessionId: challengeSession.id, // Link resend code to this challenge session
2080
1719
  });
1720
+ await this.phoneVerificationService.sendVerificationSMS(smsDto);
1721
+ this.logger?.debug?.(`SMS MFA code resent: user=${user.sub}`);
1722
+ // Get masked phone from user or device
1723
+ const maskedPhone = user.phone ? this.helpers.maskPhone(user.phone) : '***-***-****';
1724
+ return { destination: maskedPhone };
2081
1725
  }
2082
- }
2083
- else {
2084
- const codeResponse = response;
2085
- if (!codeResponse.code || typeof codeResponse.code !== 'string') {
2086
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA code is required', { field: 'code' });
1726
+ // For Email, use email verification service directly to pass challengeSessionId
1727
+ if (method === 'email' && this.emailVerificationService) {
1728
+ const emailDto = Object.assign(new verify_email_dto_1.ResendVerificationEmailDTO(), {
1729
+ sub: user.sub,
1730
+ challengeSessionId: challengeSession.id, // Link resend code to this challenge session
1731
+ });
1732
+ await this.emailVerificationService.resendVerificationEmail(emailDto);
1733
+ this.logger?.debug?.(`Email MFA code resent: user=${user.sub}`);
1734
+ const maskedEmail = user.email ? this.helpers.maskEmail(user.email) : 'u***r@example.com';
1735
+ return { destination: maskedEmail };
2087
1736
  }
1737
+ // Fallback to provider if services not available (shouldn't happen)
1738
+ if (!this.mfaService) {
1739
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
1740
+ }
1741
+ const provider = this.mfaService.getProvider(method);
1742
+ if (!provider.sendChallenge) {
1743
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `${method.toUpperCase()} MFA provider does not support sending challenges`);
1744
+ }
1745
+ const result = await provider.sendChallenge(user);
1746
+ this.logger?.debug?.(`${method.toUpperCase()} MFA code resent: user=${user.sub}`);
1747
+ // Provider returns masked phone or email
1748
+ return { destination: result };
2088
1749
  }
2089
- break;
2090
- }
2091
- case 'FORCE_CHANGE_PASSWORD': {
2092
- const response = data;
2093
- if (!response.newPassword || typeof response.newPassword !== 'string') {
2094
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'New password is required', {
2095
- field: 'newPassword',
2096
- });
2097
- }
2098
- break;
2099
- }
2100
- case 'MFA_SETUP_REQUIRED': {
2101
- const response = data;
2102
- if (!response.method) {
2103
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA setup method is required', {
2104
- field: 'method',
2105
- });
2106
- }
2107
- if (!response.setupData || typeof response.setupData !== 'object') {
2108
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA setup data is required', {
2109
- field: 'setupData',
2110
- });
2111
- }
2112
- break;
1750
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Cannot resend code for MFA method '${method}'. Only SMS and Email support code resending.`);
2113
1751
  }
1752
+ default:
1753
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Cannot resend code for challenge type '${challengeSession.challengeName}'`);
2114
1754
  }
2115
1755
  }
2116
- /**
2117
- * Handle VERIFY_EMAIL challenge
2118
- */
2119
- async handleVerifyEmail(challengeSession, code) {
2120
- const user = challengeSession.user;
2121
- if (!user) {
2122
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
2123
- }
2124
- this.logger?.log?.(`Verifying email for user: ${user.sub}`);
2125
- // Verify email with code, ensuring it belongs to this specific challenge session
2126
- const verifyDto = Object.assign(new verify_email_dto_1.VerifyEmailWithCodeDTO(), {
2127
- email: user.email,
2128
- code,
2129
- challengeSessionId: challengeSession.id, // Link verification to this specific session
2130
- });
2131
- const result = await this.emailVerificationService.verifyEmailWithCode(verifyDto);
2132
- const isVerified = result.message === 'Email verified successfully. Please log in to continue.';
2133
- if (!isVerified) {
2134
- // Increment attempts but don't consume session
2135
- await this.challengeService.incrementAttempts(challengeSession);
2136
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
2137
- }
2138
- // Consume challenge session
2139
- await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.VERIFY_EMAIL);
2140
- // Reload user to get updated emailVerified flag
2141
- const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
2142
- if (!updatedUser) {
2143
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after email verification');
2144
- }
2145
- // Get client info
2146
- const clientInfo = this.clientInfoService.get();
2147
- // Read auth context from challenge session metadata
2148
- const authMethod = challengeSession.metadata?.authMethod || 'password';
2149
- const authProvider = challengeSession.metadata?.authProvider;
2150
- const isSocialLogin = authMethod === 'social';
2151
- // Check for next challenges
2152
- const response = await this.challengeHelper.determineAuthResponse({
2153
- user: updatedUser,
2154
- config: this.config,
2155
- deviceToken: clientInfo.deviceToken,
2156
- isSocialLogin,
2157
- skipMFAVerification: false,
2158
- authProvider,
2159
- });
2160
- if (response.challengeName) {
2161
- this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
2162
- }
2163
- else {
2164
- this.logger?.log?.(`Email verified, auth completed for: ${user.email}`);
2165
- }
2166
- return response;
2167
- }
2168
- /**
2169
- * Handle VERIFY_PHONE challenge
2170
- */
2171
- async handleVerifyPhone(challengeSession, data) {
2172
- const user = challengeSession.user;
2173
- if (!user) {
2174
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
2175
- }
2176
- // Check if this is phone collection (first step) or verification (second step)
2177
- if ('phone' in data && data.phone) {
2178
- // Phone collection step
2179
- const phone = data.phone;
2180
- this.logger?.log?.(`Collecting phone number for user: ${user.sub}`);
2181
- // Validate phone format (E.164 format: +[country][number])
2182
- const phoneRegex = /^\+[1-9]\d{1,14}$/;
2183
- if (!phoneRegex.test(phone)) {
2184
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INVALID_PHONE_FORMAT, 'Invalid phone number format. Use E.164 format (e.g., +1234567890)');
2185
- }
2186
- // Update user phone number
2187
- await this.userRepository.update({ sub: user.sub }, { phone });
2188
- this.logger?.log?.(`Phone number added for user ${user.sub}: ${phone}`);
2189
- // Send verification SMS to the newly added phone
2190
- let smsError;
2191
- if (this.phoneVerificationService) {
2192
- this.logger?.log?.(`Sending verification SMS to newly added phone: ${phone}`);
2193
- try {
2194
- const smsDto = Object.assign(new verify_phone_dto_1.SendVerificationSMSDTO(), {
2195
- sub: user.sub,
2196
- skipAlreadyVerifiedCheck: false, // Explicitly set to false for phone verification (not MFA)
2197
- challengeSessionId: challengeSession.id, // Link SMS code to this challenge session
2198
- });
2199
- await this.phoneVerificationService.sendVerificationSMS(smsDto);
2200
- this.logger?.log?.(`Verification SMS sent successfully to: ${phone}`);
2201
- }
2202
- catch (error) {
2203
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
2204
- this.logger?.error?.(`Failed to send verification SMS to ${phone}: ${errorMessage}`);
2205
- smsError = errorMessage;
2206
- }
2207
- }
2208
- else {
2209
- this.logger?.warn?.(`Phone verification SMS not sent - PhoneVerificationService not available. ` +
2210
- 'Phone verification requires an SMS provider to be configured.');
2211
- }
2212
- // DO NOT consume the challenge session yet - user still needs to verify the code
2213
- // Preserve auth context from original challenge session
2214
- const authMethod = challengeSession.metadata?.authMethod || 'password';
2215
- const authProvider = challengeSession.metadata?.authProvider;
2216
- // Return same challenge with updated phone in parameters
2217
- // Skip auto-send since SMS was already sent above during phone collection
2218
- const challengeResponse = await this.challengeHelper.createChallengeResponse({ ...user, phone }, auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE, this.config, authMethod, authProvider, true);
2219
- // Include SMS error in challenge parameters if SMS failed
2220
- if (smsError) {
2221
- challengeResponse.challengeParameters = challengeResponse.challengeParameters || {};
2222
- challengeResponse.challengeParameters.smsError = smsError;
2223
- }
2224
- return challengeResponse;
2225
- }
2226
- else {
2227
- // Phone verification step (code provided)
2228
- const code = data.code;
2229
- this.logger?.log?.(`Verifying phone for user: ${user.sub}`);
2230
- // Check if phone is set
2231
- if (!user.phone) {
2232
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Phone number not yet provided. Submit phone number first.');
2233
- }
2234
- // Verify phone with code, ensuring it belongs to this specific challenge session
2235
- const verifyDto = Object.assign(new verify_phone_by_sub_dto_1.VerifyPhoneWithCodeBySubDTO(), {
2236
- sub: user.sub,
2237
- code,
2238
- challengeSessionId: challengeSession.id, // Link verification to this specific session
2239
- });
2240
- const result = await this.phoneVerificationService.verifyPhoneWithCodeBySub(verifyDto);
2241
- const isVerified = result.message === 'Phone verified successfully. Please log in to continue.';
2242
- if (!isVerified) {
2243
- // Increment attempts but don't consume session
2244
- await this.challengeService.incrementAttempts(challengeSession);
2245
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
2246
- }
2247
- // Consume challenge session
2248
- await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE);
2249
- // Reload user to get updated phoneVerified flag
2250
- const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
2251
- if (!updatedUser) {
2252
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after phone verification');
2253
- }
2254
- // Get client info
2255
- const clientInfo = this.clientInfoService.get();
2256
- // Read auth context from challenge session metadata
2257
- const authMethod = challengeSession.metadata?.authMethod || 'password';
2258
- const authProvider = challengeSession.metadata?.authProvider;
2259
- const isSocialLogin = authMethod === 'social';
2260
- // Check for next challenges
2261
- const response = await this.challengeHelper.determineAuthResponse({
2262
- user: updatedUser,
2263
- config: this.config,
2264
- deviceToken: clientInfo.deviceToken,
2265
- isSocialLogin,
2266
- skipMFAVerification: false,
2267
- authProvider,
2268
- });
2269
- if (response.challengeName) {
2270
- this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
2271
- }
2272
- else {
2273
- this.logger?.log?.(`Phone verified, auth completed for: ${user.email}`);
2274
- // ============================================================================
2275
- // Audit: Record successful login after phone verification
2276
- // ============================================================================
2277
- const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
2278
- if (fireAndForget) {
2279
- this.auditService
2280
- ?.recordEvent({
2281
- userId: user.id,
2282
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
2283
- eventStatus: 'SUCCESS',
2284
- authMethod: isSocialLogin ? authProvider || 'social' : 'password',
2285
- metadata: {
2286
- completedAfterPhoneVerification: true,
2287
- },
2288
- })
2289
- .catch((err) => {
2290
- const errorMessage = err instanceof Error ? err.message : 'Unknown error';
2291
- this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after phone verification (fire-and-forget): ${errorMessage}`, {
2292
- error: err,
2293
- userId: user.id,
2294
- userSub: user.sub,
2295
- });
2296
- });
2297
- }
2298
- else {
2299
- try {
2300
- await this.auditService?.recordEvent({
2301
- userId: user.id,
2302
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
2303
- eventStatus: 'SUCCESS',
2304
- authMethod: isSocialLogin ? authProvider || 'social' : 'password',
2305
- metadata: {
2306
- completedAfterPhoneVerification: true,
2307
- },
2308
- });
2309
- }
2310
- catch (auditError) {
2311
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2312
- this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after phone verification: ${errorMessage}`, {
2313
- error: auditError,
2314
- userId: user.id,
2315
- });
2316
- }
2317
- }
2318
- }
2319
- return response;
2320
- }
2321
- }
2322
- /**
2323
- * Handle MFA_REQUIRED challenge
2324
- */
2325
- async handleMFAVerification(challengeSession, data) {
2326
- const user = challengeSession.user;
2327
- if (!user) {
2328
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
2329
- }
2330
- const method = data.method;
2331
- this.logger?.log?.(`MFA verification attempt: method=${method}, user=${user.sub}`);
2332
- // Check if MFAService is available
2333
- if (!this.mfaService) {
2334
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
2335
- }
2336
- // Get client info
2337
- const clientInfo = this.clientInfoService.get();
2338
- // Verify MFA based on method
2339
- let isValid = false;
2340
- if (method === 'passkey') {
2341
- const passkeyData = data;
2342
- const credential = passkeyData.credential;
2343
- // Get expected challenge from session metadata
2344
- const expectedChallenge = challengeSession.metadata?.passkeyChallenge;
2345
- if (!expectedChallenge) {
2346
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'No passkey challenge found in session');
2347
- }
2348
- // Verify passkey via MFAService
2349
- const wrappedCredential = { credential, expectedChallenge };
2350
- const verifyResult = await this.mfaService.verifyCode({
2351
- sub: user.sub,
2352
- methodName: mfa_method_enum_1.MFAMethod.PASSKEY,
2353
- code: wrappedCredential,
2354
- });
2355
- isValid = verifyResult.valid;
2356
- }
2357
- else {
2358
- const codeData = data;
2359
- const code = codeData.code;
2360
- // Verify code via MFAService (handles totp, sms, and backup)
2361
- const verifyResult = await this.mfaService.verifyCode({
2362
- sub: user.sub,
2363
- methodName: method,
2364
- code,
2365
- });
2366
- isValid = verifyResult.valid;
2367
- }
2368
- if (!isValid) {
2369
- this.logger?.warn?.(`MFA verification failed for user: ${user.sub}`);
2370
- // Audit: Record MFA verification failure
2371
- if (this.config.auditLogs?.fireAndForget) {
2372
- this.auditService
2373
- ?.recordEvent({
2374
- userId: user.id,
2375
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_FAILED,
2376
- eventStatus: 'FAILURE',
2377
- challengeSessionId: challengeSession.id,
2378
- authMethod: method,
2379
- metadata: { mfaMethod: method },
2380
- })
2381
- .catch((err) => {
2382
- const errorMessage = err instanceof Error ? err.message : 'Unknown error';
2383
- this.logger?.error?.(`Failed to record MFA_VERIFICATION_FAILED audit event (fire-and-forget): ${errorMessage}`, {
2384
- error: err,
2385
- userId: user.id,
2386
- userSub: user.sub,
2387
- });
2388
- });
2389
- }
2390
- else {
2391
- try {
2392
- await this.auditService?.recordEvent({
2393
- userId: user.id,
2394
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_FAILED,
2395
- eventStatus: 'FAILURE',
2396
- challengeSessionId: challengeSession.id,
2397
- authMethod: method,
2398
- metadata: { mfaMethod: method },
2399
- });
2400
- }
2401
- catch (auditError) {
2402
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2403
- this.logger?.error?.(`Failed to record MFA_VERIFICATION_FAILED audit event: ${errorMessage}`, {
2404
- error: auditError,
2405
- userId: user.id,
2406
- });
2407
- }
2408
- }
2409
- // Increment challenge attempts (session not consumed, so user can retry)
2410
- await this.challengeService.incrementAttempts(challengeSession);
2411
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid MFA code');
2412
- }
2413
- this.logger?.log?.(`MFA verified successfully for user: ${user.sub}`);
2414
- // Audit: Record MFA verification success
2415
- if (this.config.auditLogs?.fireAndForget) {
2416
- this.auditService
2417
- ?.recordEvent({
2418
- userId: user.id,
2419
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
2420
- eventStatus: 'SUCCESS',
2421
- challengeSessionId: challengeSession.id,
2422
- authMethod: method,
2423
- metadata: { mfaMethod: method },
2424
- })
2425
- .catch((err) => {
2426
- const errorMessage = err instanceof Error ? err.message : 'Unknown error';
2427
- this.logger?.error?.(`Failed to record MFA_VERIFICATION_SUCCESS audit event (fire-and-forget): ${errorMessage}`, {
2428
- error: err,
2429
- userId: user.id,
2430
- userSub: user.sub,
2431
- });
2432
- });
2433
- }
2434
- else {
2435
- try {
2436
- await this.auditService?.recordEvent({
2437
- userId: user.id,
2438
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
2439
- eventStatus: 'SUCCESS',
2440
- challengeSessionId: challengeSession.id,
2441
- authMethod: method,
2442
- metadata: { mfaMethod: method },
2443
- });
2444
- }
2445
- catch (auditError) {
2446
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2447
- this.logger?.error?.(`Failed to record MFA_VERIFICATION_SUCCESS audit event: ${errorMessage}`, {
2448
- error: auditError,
2449
- userId: user.id,
2450
- });
2451
- }
2452
- }
2453
- // Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
2454
- await this.challengeService.updateMetadata(challengeSession.sessionToken, {
2455
- mfaMethod: method,
2456
- });
2457
- // Only consume the session AFTER successful verification
2458
- await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED);
2459
- // Read auth context from challenge session metadata
2460
- const authMethod = challengeSession.metadata?.authMethod || 'password';
2461
- const authProvider = challengeSession.metadata?.authProvider;
2462
- const isSocialLogin = authMethod === 'social';
2463
- // ============================================================================
2464
- // Trusted Device Token Management (Remember Device Feature)
2465
- // ============================================================================
2466
- // NOTE:
2467
- // - We only create / update trusted device tokens AFTER MFA has been successfully
2468
- // verified to avoid trusting devices that haven't completed full auth.
2469
- // - For 'always' mode, this mirrors the behavior in the primary login flow.
2470
- let deviceToken = clientInfo.deviceToken;
2471
- let isTrustedDevice = false;
2472
- if (this.trustedDeviceService && this.config.mfa?.rememberDevices && this.config.mfa.rememberDevices !== 'never') {
2473
- const rememberMode = this.config.mfa.rememberDevices;
2474
- // If a device token is already present, check if it's trusted
2475
- if (deviceToken) {
2476
- try {
2477
- isTrustedDevice = await this.trustedDeviceService.isDeviceTrusted(deviceToken, user.id);
2478
- if (isTrustedDevice) {
2479
- this.logger?.debug?.(`MFA flow: existing trusted device token detected for user ${user.sub} (token reused)`);
2480
- }
2481
- }
2482
- catch (error) {
2483
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
2484
- this.logger?.warn?.(`MFA flow: failed to validate existing trusted device token for user ${user.sub}: ${errorMessage}`, { error });
2485
- }
2486
- }
2487
- // Auto-trust mode: create device token automatically if not already trusted
2488
- if (rememberMode === 'always' && !isTrustedDevice) {
2489
- try {
2490
- deviceToken = await this.trustedDeviceService.createTrustedDevice(user.id, clientInfo.deviceName, clientInfo.deviceType, clientInfo.ipAddress, clientInfo.userAgent, clientInfo.platform, clientInfo.browser);
2491
- isTrustedDevice = true;
2492
- this.logger?.debug?.(`MFA flow: auto-created trusted device token for user ${user.sub} (rememberDevices='always')`);
2493
- }
2494
- catch (error) {
2495
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
2496
- this.logger?.warn?.(`MFA flow: failed to create trusted device token for user ${user.sub}: ${errorMessage}`, {
2497
- error,
2498
- });
2499
- }
2500
- }
2501
- }
2502
- // Check for next challenges (MFA is usually the last challenge)
2503
- const response = await this.challengeHelper.determineAuthResponse({
2504
- user,
2505
- config: this.config,
2506
- deviceToken,
2507
- isSocialLogin,
2508
- skipMFAVerification: true, // Already verified
2509
- authProvider,
2510
- });
2511
- // Propagate trusted device metadata into response so that:
2512
- // - CookieTokenInterceptor can set the nauth_device_token cookie (cookies mode)
2513
- // - Mobile clients in JSON mode can store the device token securely
2514
- if (isTrustedDevice) {
2515
- response.trusted = response.trusted ?? true;
2516
- }
2517
- if (deviceToken && !response.deviceToken) {
2518
- response.deviceToken = deviceToken;
2519
- }
2520
- if (response.challengeName) {
2521
- this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
2522
- }
2523
- else {
2524
- this.logger?.log?.(`MFA verified, auth completed for: ${user.email}`);
2525
- // ============================================================================
2526
- // Audit: Record successful login after MFA completion
2527
- // ============================================================================
2528
- const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
2529
- if (fireAndForget) {
2530
- this.auditService
2531
- ?.recordEvent({
2532
- userId: user.id,
2533
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
2534
- eventStatus: 'SUCCESS',
2535
- authMethod: isSocialLogin ? authProvider || 'social' : 'password',
2536
- metadata: {
2537
- completedAfterMFA: true,
2538
- },
2539
- })
2540
- .catch((err) => {
2541
- const errorMessage = err instanceof Error ? err.message : 'Unknown error';
2542
- this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA (fire-and-forget): ${errorMessage}`, {
2543
- error: err,
2544
- userId: user.id,
2545
- userSub: user.sub,
2546
- });
2547
- });
2548
- }
2549
- else {
2550
- try {
2551
- await this.auditService?.recordEvent({
2552
- userId: user.id,
2553
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
2554
- eventStatus: 'SUCCESS',
2555
- authMethod: isSocialLogin ? authProvider || 'social' : 'password',
2556
- metadata: {
2557
- completedAfterMFA: true,
2558
- },
2559
- });
2560
- }
2561
- catch (auditError) {
2562
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2563
- this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA: ${errorMessage}`, {
2564
- error: auditError,
2565
- userId: user.id,
2566
- });
2567
- }
2568
- }
2569
- }
2570
- return response;
2571
- }
2572
- /**
2573
- * Handle FORCE_CHANGE_PASSWORD challenge
2574
- */
2575
- async handleForceChangePassword(challengeSession, newPassword) {
2576
- const user = challengeSession.user;
2577
- if (!user) {
2578
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
2579
- }
2580
- this.logger?.log?.(`Changing password for user: ${user.sub}`);
2581
- await this.updateUserPassword({
2582
- user,
2583
- newPassword,
2584
- mustChangePassword: false,
2585
- revokeSessions: true,
2586
- revokeReason: 'Password changed (force change password)',
2587
- audit: {
2588
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PASSWORD_CHANGED,
2589
- eventStatus: 'SUCCESS',
2590
- reason: 'force_change_password',
2591
- description: 'Password changed due to FORCE_CHANGE_PASSWORD challenge',
2592
- },
2593
- });
2594
- // Consume challenge session
2595
- await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.FORCE_CHANGE_PASSWORD);
2596
- // Reload user from database to get updated mustChangePassword flag
2597
- const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
2598
- if (!updatedUser) {
2599
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after password update');
2600
- }
2601
- // Get client info
2602
- const clientInfo = this.clientInfoService.get();
2603
- // Read auth context from challenge session metadata
2604
- const authMethod = challengeSession.metadata?.authMethod || 'password';
2605
- const authProvider = challengeSession.metadata?.authProvider;
2606
- const isSocialLogin = authMethod === 'social';
2607
- // Check for next challenges
2608
- const response = await this.challengeHelper.determineAuthResponse({
2609
- user: updatedUser,
2610
- config: this.config,
2611
- deviceToken: clientInfo.deviceToken,
2612
- isSocialLogin,
2613
- skipMFAVerification: false,
2614
- authProvider,
2615
- });
2616
- if (response.challengeName) {
2617
- this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
2618
- }
2619
- else {
2620
- this.logger?.log?.(`Password changed, auth completed for: ${user.email}`);
2621
- // ============================================================================
2622
- // Audit: Record successful login after password change
2623
- // ============================================================================
2624
- const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
2625
- if (fireAndForget) {
2626
- this.auditService
2627
- ?.recordEvent({
2628
- userId: user.id,
2629
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
2630
- eventStatus: 'SUCCESS',
2631
- authMethod: isSocialLogin ? authProvider || 'social' : 'password',
2632
- metadata: {
2633
- completedAfterPasswordChange: true,
2634
- },
2635
- })
2636
- .catch((err) => {
2637
- const errorMessage = err instanceof Error ? err.message : 'Unknown error';
2638
- this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after password change (fire-and-forget): ${errorMessage}`, {
2639
- error: err,
2640
- userId: user.id,
2641
- userSub: user.sub,
2642
- });
2643
- });
2644
- }
2645
- else {
2646
- try {
2647
- await this.auditService?.recordEvent({
2648
- userId: user.id,
2649
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
2650
- eventStatus: 'SUCCESS',
2651
- authMethod: isSocialLogin ? authProvider || 'social' : 'password',
2652
- metadata: {
2653
- completedAfterPasswordChange: true,
2654
- },
2655
- });
2656
- }
2657
- catch (auditError) {
2658
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2659
- this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after password change: ${errorMessage}`, {
2660
- error: auditError,
2661
- userId: user.id,
2662
- });
2663
- }
2664
- }
2665
- }
2666
- return response;
2667
- }
2668
- /**
2669
- * Handle MFA_SETUP_REQUIRED challenge
2670
- */
2671
- async handleMFASetup(challengeSession, data) {
2672
- const user = challengeSession.user;
2673
- if (!user) {
2674
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
2675
- }
2676
- const method = data.method;
2677
- const setupData = data.setupData;
2678
- const requestTrace = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
2679
- this.logger?.log?.(`[${requestTrace}] MFA setup attempt: method=${method}, user=${user.sub}`);
2680
- // Check if MFAService is available
2681
- if (!this.mfaService) {
2682
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
2683
- }
2684
- // Get provider
2685
- const provider = this.mfaService.getProvider(method);
2686
- // Verify setup based on method
2687
- let deviceId;
2688
- try {
2689
- deviceId = await provider.verifySetup(user, setupData);
2690
- this.logger?.log?.(`MFA device setup completed: method=${method}, deviceId=${deviceId}`);
2691
- }
2692
- catch (error) {
2693
- this.logger?.warn?.(`MFA setup verification failed: method=${method}, user=${user.sub}`);
2694
- // Increment attempts but don't consume session
2695
- await this.challengeService.incrementAttempts(challengeSession);
2696
- // Re-throw the error
2697
- throw error;
2698
- }
2699
- // Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
2700
- await this.challengeService.updateMetadata(challengeSession.sessionToken, {
2701
- mfaMethod: method,
2702
- });
2703
- // Consume challenge session
2704
- await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.MFA_SETUP_REQUIRED);
2705
- // Reload user from database to get updated mfaEnabled flag
2706
- const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
2707
- if (!updatedUser) {
2708
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after MFA setup');
2709
- }
2710
- // Get client info
2711
- const clientInfo = this.clientInfoService.get();
2712
- // Check for next challenges with updated user data
2713
- // Skip MFA verification because device was already verified during setup
2714
- const response = await this.challengeHelper.determineAuthResponse({
2715
- user: updatedUser,
2716
- config: this.config,
2717
- deviceToken: clientInfo.deviceToken,
2718
- isSocialLogin: false,
2719
- skipMFAVerification: true, // Device already verified during setup
2720
- });
2721
- if (response.challengeName) {
2722
- this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
2723
- }
2724
- else {
2725
- this.logger?.log?.(`MFA setup completed, auth completed for: ${user.email}`);
2726
- // ============================================================================
2727
- // Audit: Record successful login after MFA setup
2728
- // ============================================================================
2729
- const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
2730
- if (fireAndForget) {
2731
- this.auditService
2732
- ?.recordEvent({
2733
- userId: user.id,
2734
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
2735
- eventStatus: 'SUCCESS',
2736
- authMethod: 'password',
2737
- metadata: {
2738
- completedAfterMFASetup: true,
2739
- },
2740
- })
2741
- .catch((err) => {
2742
- const errorMessage = err instanceof Error ? err.message : 'Unknown error';
2743
- this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA setup (fire-and-forget): ${errorMessage}`, {
2744
- error: err,
2745
- userId: user.id,
2746
- userSub: user.sub,
2747
- });
2748
- });
2749
- }
2750
- else {
2751
- try {
2752
- await this.auditService?.recordEvent({
2753
- userId: user.id,
2754
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
2755
- eventStatus: 'SUCCESS',
2756
- authMethod: 'password',
2757
- metadata: {
2758
- completedAfterMFASetup: true,
2759
- },
2760
- });
2761
- }
2762
- catch (auditError) {
2763
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2764
- this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA setup: ${errorMessage}`, {
2765
- error: auditError,
2766
- userId: user.id,
2767
- });
2768
- }
2769
- }
2770
- }
2771
- return response;
2772
- }
2773
- // ============================================================================
2774
- // Challenge Helper Methods
2775
- // ============================================================================
2776
- /**
2777
- * Resend verification code for current challenge
2778
- *
2779
- * Determines the challenge type from the session and resends the appropriate code:
2780
- * - VERIFY_EMAIL: Resends email verification code
2781
- * - VERIFY_PHONE: Resends SMS verification code
2782
- * - MFA_REQUIRED: Resends MFA code (for SMS MFA)
2783
- *
2784
- * Rate limits are enforced internally by the verification services.
2785
- *
2786
- * @param session - Challenge session token
2787
- * @returns Destination info (masked email/phone)
2788
- * @throws {NAuthException} INVALID_CHALLENGE_SESSION | RATE_LIMIT_* | VALIDATION_FAILED
2789
- *
2790
- * @example
2791
- * ```typescript
2792
- * const result = await authService.resendCode(session);
2793
- * // Returns: { destination: 'u***r@example.com' }
2794
- * ```
2795
- */
2796
- async resendCode(dto) {
2797
- // Ensure DTO is validated (supports direct usage without framework validation)
2798
- dto = await (0, dto_validator_1.ensureValidatedDto)(resend_code_dto_1.ResendCodeDTO, dto);
2799
- this.logger?.debug?.(`Resending verification code: session=${dto.session}`);
2800
- // Validate session (session must be valid to resend)
2801
- const challengeSession = await this.challengeService.validateSession(dto.session);
2802
- // Get user from session
2803
- const user = challengeSession.user;
2804
- if (!user) {
2805
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Challenge session has no associated user');
2806
- }
2807
- // Handle based on challenge type
2808
- switch (challengeSession.challengeName) {
2809
- case auth_challenge_dto_1.AuthChallenge.VERIFY_EMAIL: {
2810
- // Resend email verification
2811
- // Pass challengeSessionId to ensure new token is linked to this challenge session
2812
- const resendDto = Object.assign(new verify_email_dto_1.ResendVerificationEmailDTO(), {
2813
- sub: user.sub,
2814
- challengeSessionId: challengeSession.id,
2815
- });
2816
- await this.emailVerificationService.resendVerificationEmail(resendDto);
2817
- const maskedEmail = this.maskEmail(user.email);
2818
- this.logger?.debug?.(`Email verification code resent: user=${user.sub}, email=${maskedEmail}`);
2819
- return { destination: maskedEmail };
2820
- }
2821
- case auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE: {
2822
- // Check if phone already collected
2823
- if (!user.phone) {
2824
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Phone number not yet provided. Submit phone number first.');
2825
- }
2826
- if (!this.phoneVerificationService) {
2827
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'Phone verification service is not available');
2828
- }
2829
- // Resend SMS verification
2830
- const resendDto = Object.assign(new verify_phone_dto_1.ResendVerificationSMSDTO(), { sub: user.sub });
2831
- await this.phoneVerificationService.resendVerificationSMS(resendDto);
2832
- const maskedPhone = this.maskPhone(user.phone);
2833
- this.logger?.debug?.(`Phone verification code resent: user=${user.sub}, phone=${maskedPhone}`);
2834
- return { destination: maskedPhone };
2835
- }
2836
- case auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED: {
2837
- // For MFA, we need to know which method is being used
2838
- // Method is stored in metadata when challenge is created (see auth-challenge-helper.service.ts line 403)
2839
- // Note: challengeParameters is never populated - only metadata is used
2840
- const metadata = challengeSession.metadata;
2841
- const method = metadata?.method;
2842
- if (!method) {
2843
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Cannot resend MFA code: method not specified in session');
2844
- }
2845
- // SMS and Email MFA support resending codes
2846
- if (method === 'sms' || method === 'email') {
2847
- // For SMS, use phone verification service directly to pass challengeSessionId
2848
- if (method === 'sms' && this.phoneVerificationService) {
2849
- const smsDto = Object.assign(new verify_phone_dto_1.SendVerificationSMSDTO(), {
2850
- sub: user.sub,
2851
- skipAlreadyVerifiedCheck: true,
2852
- challengeSessionId: challengeSession.id, // Link resend code to this challenge session
2853
- });
2854
- await this.phoneVerificationService.sendVerificationSMS(smsDto);
2855
- this.logger?.debug?.(`SMS MFA code resent: user=${user.sub}`);
2856
- // Get masked phone from user or device
2857
- const maskedPhone = user.phone ? this.maskPhone(user.phone) : '***-***-****';
2858
- return { destination: maskedPhone };
2859
- }
2860
- // For Email, use email verification service directly to pass challengeSessionId
2861
- if (method === 'email' && this.emailVerificationService) {
2862
- const emailDto = Object.assign(new verify_email_dto_1.ResendVerificationEmailDTO(), {
2863
- sub: user.sub,
2864
- challengeSessionId: challengeSession.id, // Link resend code to this challenge session
2865
- });
2866
- await this.emailVerificationService.resendVerificationEmail(emailDto);
2867
- this.logger?.debug?.(`Email MFA code resent: user=${user.sub}`);
2868
- const maskedEmail = user.email ? this.maskEmail(user.email) : 'u***r@example.com';
2869
- return { destination: maskedEmail };
2870
- }
2871
- // Fallback to provider if services not available (shouldn't happen)
2872
- if (!this.mfaService) {
2873
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
2874
- }
2875
- const provider = this.mfaService.getProvider(method);
2876
- if (!provider.sendChallenge) {
2877
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `${method.toUpperCase()} MFA provider does not support sending challenges`);
2878
- }
2879
- const result = await provider.sendChallenge(user);
2880
- this.logger?.debug?.(`${method.toUpperCase()} MFA code resent: user=${user.sub}`);
2881
- // Provider returns masked phone or email
2882
- return { destination: result };
2883
- }
2884
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Cannot resend code for MFA method '${method}'. Only SMS and Email support code resending.`);
2885
- }
2886
- default:
2887
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Cannot resend code for challenge type '${challengeSession.challengeName}'`);
2888
- }
2889
- }
2890
- /**
2891
- * Mask email for display (helper method)
2892
- */
2893
- maskEmail(email) {
2894
- const [localPart, domain] = email.split('@');
2895
- if (localPart.length <= 2) {
2896
- return `${localPart[0]}***@${domain}`;
2897
- }
2898
- return `${localPart[0]}***${localPart[localPart.length - 1]}@${domain}`;
2899
- }
2900
- /**
2901
- * Mask phone number for display (helper method)
2902
- */
2903
- maskPhone(phone) {
2904
- const digits = phone.replace(/\D/g, '');
2905
- const lastFour = digits.slice(-4);
2906
- return `***-***-${lastFour}`;
2907
- }
2908
1756
  /**
2909
1757
  * Registers the current device as trusted for the user (opt-in).
2910
1758
  *
@@ -3282,13 +2130,39 @@ class AuthService {
3282
2130
  // Logout
3283
2131
  // ============================================================================
3284
2132
  /**
3285
- * Logout user (revoke session)
2133
+ * Logout user from current session
3286
2134
  *
3287
- * Session ID is automatically extracted from the JWT token context (via ClientInfoService), similar to how IP address and user agent are handled.
2135
+ * Revokes the current authenticated session. Session ID is automatically extracted
2136
+ * from the JWT token context (via ClientInfoService), similar to how IP address
2137
+ * and user agent are handled.
2138
+ *
2139
+ * Usage Pattern:
2140
+ * - **User-context only**: This method operates on the current authenticated session
2141
+ * - Session ID is transparently extracted from JWT token in request context
2142
+ * - User can only logout their own current session (not other sessions)
2143
+ * - For logging out other sessions, use logoutSession() or logoutAll()
3288
2144
  *
3289
- * @param dto - Logout options (forgetMe flag)
2145
+ * Security:
2146
+ * - Requires authentication - session ID must be present in request context
2147
+ * - Endpoint MUST be protected by authentication guards
2148
+ * - User cannot specify which session to logout (always current session)
2149
+ * - Optional sub validation for additional security
2150
+ *
2151
+ * @param dto - Logout options (optional sub for validation, optional forgetMe flag)
3290
2152
  * @returns Success status
3291
- * @throws {NAuthException} If session ID is not available in request context
2153
+ * @throws {NAuthException} SESSION_NOT_FOUND if session ID not found in request context
2154
+ *
2155
+ * @example
2156
+ * ```typescript
2157
+ * @UseGuards(AuthGuard)
2158
+ * @Get('logout')
2159
+ * async logout(@CurrentUser() user: IUser, @Query('forgetMe') forgetMe?: string) {
2160
+ * const dto = new LogoutDTO();
2161
+ * dto.sub = user.sub; // Optional validation
2162
+ * dto.forgetMe = forgetMe === 'true';
2163
+ * return this.authService.logout(dto);
2164
+ * }
2165
+ * ```
3292
2166
  */
3293
2167
  async logout(dto) {
3294
2168
  // Ensure DTO is validated (supports direct usage without framework validation)
@@ -3381,44 +2255,50 @@ class AuthService {
3381
2255
  // ============================================================================
3382
2256
  const response = this.clientInfoService.getResponse();
3383
2257
  if (response && this.config.tokenDelivery?.method !== 'json') {
3384
- this.clearAuthCookies(response, dto.forgetMe ?? false);
2258
+ this.helpers.clearAuthCookies(response, dto.forgetMe ?? false);
3385
2259
  this.logger?.debug?.('Auth cookies cleared automatically on logout');
3386
2260
  }
3387
2261
  return { success: true };
3388
2262
  }
3389
- /**
3390
- * Clear authentication cookies from response
3391
- *
3392
- * @param response - HTTP response object with clearCookie method
3393
- * @param forgetDevice - Whether to also clear device token cookie
3394
- * @private
3395
- */
3396
- clearAuthCookies(response, forgetDevice) {
3397
- if (!response.clearCookie) {
3398
- return; // Response doesn't support cookie clearing (shouldn't happen)
3399
- }
3400
- const cookieOptions = this.config.tokenDelivery?.cookieOptions || {};
3401
- const prefix = this.config.tokenDelivery?.cookieNamePrefix || 'nauth';
3402
- // Clear access and refresh tokens
3403
- response.clearCookie(`${prefix}_access_token`, cookieOptions);
3404
- response.clearCookie(`${prefix}_refresh_token`, cookieOptions);
3405
- // Clear CSRF token cookie (httpOnly: false, so it can be cleared)
3406
- // Use the same cookie options but with httpOnly: false to match how it was set
3407
- const csrfCookieOptions = {
3408
- ...cookieOptions,
3409
- httpOnly: false, // CSRF token cookie is not httpOnly
3410
- };
3411
- const csrfCookieName = this.config.security?.csrf?.cookieName || `${prefix}_csrf_token`;
3412
- response.clearCookie(csrfCookieName, csrfCookieOptions);
3413
- // Clear device token if forgetting device
3414
- if (forgetDevice) {
3415
- response.clearCookie(`${prefix}_device_token`, cookieOptions);
3416
- }
3417
- }
3418
2263
  /**
3419
2264
  * Global signout (revoke all user sessions)
3420
- * @param sub - External user identifier (sub/UUID)
2265
+ *
2266
+ * Revokes all active sessions for a user across all devices.
2267
+ * Optionally revokes all trusted devices if forgetDevices flag is set.
2268
+ *
2269
+ * Usage Patterns:
2270
+ * - **User-initiated**: User logs out from all their own sessions (protected endpoint, user provides their own sub)
2271
+ * - **Admin-initiated**: Admin force-logs out any user (admin-protected endpoint, admin provides target user's sub)
2272
+ *
2273
+ * Security:
2274
+ * - Requires explicit sub parameter
2275
+ * - NO built-in authentication - endpoint MUST be protected by guards
2276
+ * - For user endpoints: Extract sub from authenticated user context (@CurrentUser)
2277
+ * - For admin endpoints: Accept sub from route parameter and protect with admin guards
2278
+ *
2279
+ * @param dto - User sub and optional forgetDevices flag
3421
2280
  * @returns Number of sessions revoked
2281
+ * @throws {NAuthException} NOT_FOUND if user not found
2282
+ *
2283
+ * @example User-initiated (user context)
2284
+ * ```typescript
2285
+ * // Controller extracts sub from authenticated user
2286
+ * @UseGuards(AuthGuard)
2287
+ * @Post('logout/all')
2288
+ * async logoutAll(@CurrentUser() user: IUser, @Body() body: { forgetDevices?: boolean }) {
2289
+ * return this.authService.logoutAll({ sub: user.sub, forgetDevices: body.forgetDevices });
2290
+ * }
2291
+ * ```
2292
+ *
2293
+ * @example Admin-initiated (admin manages any user)
2294
+ * ```typescript
2295
+ * // Admin provides target user's sub
2296
+ * @UseGuards(AuthGuard, AdminGuard)
2297
+ * @Post('admin/users/:sub/logout-all')
2298
+ * async adminLogoutAll(@Param('sub') sub: string, @Body() body: { forgetDevices?: boolean }) {
2299
+ * return this.authService.logoutAll({ sub, forgetDevices: body.forgetDevices });
2300
+ * }
2301
+ * ```
3422
2302
  */
3423
2303
  async logoutAll(dto) {
3424
2304
  // Ensure DTO is validated (supports direct usage without framework validation)
@@ -3512,25 +2392,226 @@ class AuthService {
3512
2392
  });
3513
2393
  }
3514
2394
  catch (auditError) {
3515
- // Non-blocking: Log but continue (individual SESSION_REVOKED events already recorded in SessionService)
2395
+ // Non-blocking: Log but continue (individual SESSION_REVOKED events already recorded in SessionService)
2396
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2397
+ this.logger?.error?.(`Failed to record GLOBAL_SIGNOUT audit event: ${errorMessage}`, {
2398
+ error: auditError,
2399
+ userId: user.id,
2400
+ });
2401
+ }
2402
+ }
2403
+ // ============================================================================
2404
+ // Automatically Clear Auth Cookies (if using cookie-based token delivery)
2405
+ // ============================================================================
2406
+ const response = this.clientInfoService.getResponse();
2407
+ if (response && this.config.tokenDelivery?.method !== 'json') {
2408
+ // Clear auth cookies
2409
+ // If forgetDevices is true, also clear device token cookie
2410
+ this.helpers.clearAuthCookies(response, dto.forgetDevices ?? false);
2411
+ this.logger?.debug?.('Auth cookies cleared automatically on global logout');
2412
+ }
2413
+ return { revokedCount };
2414
+ }
2415
+ /**
2416
+ * Get all active sessions for a user
2417
+ *
2418
+ * Returns session details including authentication method (password, social, admin).
2419
+ * For social logins, check session metadata for the specific OAuth provider.
2420
+ * Current session (if called from authenticated context) is marked with isCurrent=true.
2421
+ *
2422
+ * Usage Patterns:
2423
+ * - **User viewing own sessions**: User views their active sessions (protected endpoint, user provides their own sub)
2424
+ * - **Admin viewing any user's sessions**: Admin views any user's sessions (admin-protected endpoint, admin provides target user's sub)
2425
+ *
2426
+ * Security:
2427
+ * - Requires explicit sub parameter
2428
+ * - NO built-in authentication - endpoint MUST be protected by guards
2429
+ * - For user endpoints: Extract sub from authenticated user context (@CurrentUser)
2430
+ * - For admin endpoints: Accept sub from route parameter and protect with admin guards
2431
+ *
2432
+ * @param dto - Contains user sub
2433
+ * @returns Array of sessions with device info, auth method, and isCurrent flag
2434
+ * @throws {NAuthException} NOT_FOUND if user not found
2435
+ *
2436
+ * @example User viewing own sessions
2437
+ * ```typescript
2438
+ * @UseGuards(AuthGuard)
2439
+ * @Get('sessions')
2440
+ * async getSessions(@CurrentUser() user: IUser) {
2441
+ * return this.authService.getUserSessions({ sub: user.sub });
2442
+ * }
2443
+ * ```
2444
+ *
2445
+ * @example Admin viewing any user's sessions
2446
+ * ```typescript
2447
+ * @UseGuards(AuthGuard, AdminGuard)
2448
+ * @Get('admin/users/:sub/sessions')
2449
+ * async adminGetSessions(@Param('sub') sub: string) {
2450
+ * return this.authService.getUserSessions({ sub });
2451
+ * }
2452
+ * ```
2453
+ */
2454
+ async getUserSessions(dto) {
2455
+ // Ensure DTO is validated (supports direct usage without framework validation)
2456
+ dto = await (0, dto_validator_1.ensureValidatedDto)(get_user_sessions_dto_1.GetUserSessionsDTO, dto);
2457
+ // Get user by sub to get internal id
2458
+ const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
2459
+ if (!user) {
2460
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
2461
+ }
2462
+ // Get current session ID from context (if available)
2463
+ const clientInfo = this.clientInfoService.get();
2464
+ const currentSessionId = clientInfo.sessionId ? String(clientInfo.sessionId) : null;
2465
+ // Get all active sessions for user
2466
+ const sessions = await this.sessionService.findUserSessions(user.id);
2467
+ // Map sessions to response format
2468
+ const sessionInfos = sessions.map((session) => {
2469
+ // Determine auth method and provider
2470
+ let authMethod = session.authMethod || null;
2471
+ let authProvider = null;
2472
+ // If authMethod is 'social' or starts with 'admin-', extract provider from metadata
2473
+ if (authMethod === 'social' || authMethod?.startsWith('admin-')) {
2474
+ // Check metadata for provider information
2475
+ const metadata = session.metadata || {};
2476
+ authProvider = metadata.authProvider || metadata.provider || null;
2477
+ // If no provider in metadata but authMethod contains it (e.g., 'google', 'facebook')
2478
+ if (!authProvider && authMethod && authMethod !== 'social' && !authMethod.startsWith('admin-')) {
2479
+ authProvider = authMethod;
2480
+ authMethod = 'social';
2481
+ }
2482
+ }
2483
+ // Determine if this is the current session
2484
+ const isCurrent = currentSessionId !== null && String(session.id) === currentSessionId;
2485
+ return {
2486
+ sessionId: String(session.id),
2487
+ deviceId: session.deviceId,
2488
+ deviceName: session.deviceName,
2489
+ deviceType: session.deviceType,
2490
+ platform: session.platform,
2491
+ browser: session.browser,
2492
+ ipAddress: session.ipAddress,
2493
+ ipCountry: session.ipCountry,
2494
+ ipCity: session.ipCity,
2495
+ lastActivityAt: session.lastActivityAt || session.createdAt,
2496
+ createdAt: session.createdAt,
2497
+ expiresAt: session.expiresAt,
2498
+ isRemembered: session.isRemembered,
2499
+ isCurrent,
2500
+ authMethod,
2501
+ authProvider,
2502
+ };
2503
+ });
2504
+ return { sessions: sessionInfos };
2505
+ }
2506
+ /**
2507
+ * Logout a specific session by ID
2508
+ *
2509
+ * Revokes a specific session for a user. Validates session belongs to requesting user.
2510
+ * Automatically clears cookies if logging out the current session.
2511
+ * Useful for "sign out from device" functionality in user dashboards.
2512
+ *
2513
+ * Usage Patterns:
2514
+ * - **User logging out own session**: User revokes specific session (protected endpoint, user provides their own sub)
2515
+ * - **Admin revoking any user's session**: Admin revokes specific session for any user (admin-protected endpoint, admin provides target user's sub)
2516
+ *
2517
+ * Security:
2518
+ * - Requires explicit sub parameter
2519
+ * - Validates session belongs to user (prevents unauthorized session revocation)
2520
+ * - NO built-in authentication - endpoint MUST be protected by guards
2521
+ * - For user endpoints: Extract sub from authenticated user context (@CurrentUser)
2522
+ * - For admin endpoints: Accept sub from route parameter and protect with admin guards
2523
+ *
2524
+ * @param dto - Contains sessionId and user sub
2525
+ * @returns Success status and whether it was the current session
2526
+ * @throws {NAuthException} NOT_FOUND if user not found
2527
+ * @throws {NAuthException} SESSION_NOT_FOUND if session not found
2528
+ * @throws {NAuthException} FORBIDDEN if session doesn't belong to user
2529
+ *
2530
+ * @example User logging out own session
2531
+ * ```typescript
2532
+ * @UseGuards(AuthGuard)
2533
+ * @Delete('sessions/:sessionId')
2534
+ * async logoutSession(@CurrentUser() user: IUser, @Param('sessionId') sessionId: string) {
2535
+ * return this.authService.logoutSession({ sub: user.sub, sessionId });
2536
+ * }
2537
+ * ```
2538
+ *
2539
+ * @example Admin revoking any user's session (if needed)
2540
+ * ```typescript
2541
+ * @UseGuards(AuthGuard, AdminGuard)
2542
+ * @Delete('admin/users/:sub/sessions/:sessionId')
2543
+ * async adminRevokeSession(@Param('sub') sub: string, @Param('sessionId') sessionId: string) {
2544
+ * return this.authService.logoutSession({ sub, sessionId });
2545
+ * }
2546
+ * ```
2547
+ */
2548
+ async logoutSession(dto) {
2549
+ // Ensure DTO is validated (supports direct usage without framework validation)
2550
+ dto = await (0, dto_validator_1.ensureValidatedDto)(logout_session_dto_1.LogoutSessionDTO, dto);
2551
+ // Get user by sub to get internal id
2552
+ const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
2553
+ if (!user) {
2554
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
2555
+ }
2556
+ // Parse session ID (can be string or number)
2557
+ const sessionId = typeof dto.sessionId === 'string' ? parseInt(dto.sessionId, 10) : dto.sessionId;
2558
+ if (isNaN(sessionId)) {
2559
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Invalid session ID');
2560
+ }
2561
+ // Get session to verify ownership
2562
+ const session = await this.sessionService.findById(sessionId);
2563
+ if (!session) {
2564
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.SESSION_NOT_FOUND, 'Session not found');
2565
+ }
2566
+ // Verify session belongs to user
2567
+ if (session.userId !== user.id) {
2568
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.FORBIDDEN, 'Session does not belong to user');
2569
+ }
2570
+ // Check if this is the current session
2571
+ const clientInfo = this.clientInfoService.get();
2572
+ const currentSessionId = clientInfo.sessionId ? parseInt(String(clientInfo.sessionId), 10) : null;
2573
+ const wasCurrentSession = currentSessionId !== null && sessionId === currentSessionId;
2574
+ // Revoke the session
2575
+ await this.sessionService.revokeSession(sessionId, 'User requested logout', {
2576
+ requestedBy: dto.sub,
2577
+ wasCurrentSession,
2578
+ });
2579
+ // Clear cookies if this was the current session
2580
+ if (wasCurrentSession) {
2581
+ const response = this.clientInfoService.getResponse();
2582
+ if (response && this.config.tokenDelivery?.method !== 'json') {
2583
+ this.helpers.clearAuthCookies(response, false);
2584
+ this.logger?.debug?.('Auth cookies cleared automatically on session logout');
2585
+ }
2586
+ }
2587
+ // Record audit event
2588
+ if (this.auditService) {
2589
+ try {
2590
+ await this.auditService.recordEvent({
2591
+ userId: user.id,
2592
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.SESSION_REVOKED,
2593
+ eventStatus: 'INFO',
2594
+ reason: 'user_requested',
2595
+ description: `Session revoked by user request${wasCurrentSession ? ' (current session)' : ''}`,
2596
+ metadata: {
2597
+ sessionId,
2598
+ wasCurrentSession,
2599
+ },
2600
+ });
2601
+ }
2602
+ catch (auditError) {
2603
+ // Non-blocking: Log but continue
3516
2604
  const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
3517
- this.logger?.error?.(`Failed to record GLOBAL_SIGNOUT audit event: ${errorMessage}`, {
2605
+ this.logger?.error?.(`Failed to record SESSION_REVOKED audit event: ${errorMessage}`, {
3518
2606
  error: auditError,
3519
2607
  userId: user.id,
3520
2608
  });
3521
2609
  }
3522
2610
  }
3523
- // ============================================================================
3524
- // Automatically Clear Auth Cookies (if using cookie-based token delivery)
3525
- // ============================================================================
3526
- const response = this.clientInfoService.getResponse();
3527
- if (response && this.config.tokenDelivery?.method !== 'json') {
3528
- // Clear auth cookies
3529
- // If forgetDevices is true, also clear device token cookie
3530
- this.clearAuthCookies(response, dto.forgetDevices ?? false);
3531
- this.logger?.debug?.('Auth cookies cleared automatically on global logout');
3532
- }
3533
- return { revokedCount };
2611
+ return {
2612
+ success: true,
2613
+ wasCurrentSession,
2614
+ };
3534
2615
  }
3535
2616
  // ============================================================================
3536
2617
  // Password Management
@@ -3581,7 +2662,7 @@ class AuthService {
3581
2662
  // ============================================================================
3582
2663
  // TODO: Implement provider-based hook for afterPasswordChange
3583
2664
  // await this.hookRegistry.executeAfterPasswordChange(dto.sub);
3584
- await this.updateUserPassword({
2665
+ await this.helpers.updateUserPassword({
3585
2666
  user,
3586
2667
  newPassword: dto.newPassword,
3587
2668
  mustChangePassword: false,
@@ -3591,7 +2672,7 @@ class AuthService {
3591
2672
  eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PASSWORD_CHANGED,
3592
2673
  eventStatus: 'SUCCESS',
3593
2674
  },
3594
- });
2675
+ }, this.passwordService, this.auditService);
3595
2676
  return { success: true };
3596
2677
  }
3597
2678
  /**
@@ -3599,602 +2680,59 @@ class AuthService {
3599
2680
  *
3600
2681
  * Updates user fields (name, email, phone, username, metadata) and enforces unique constraints and verification rules.
3601
2682
  *
3602
- * @param sub - User sub/UUID
3603
- * @param updateData - User fields to update
2683
+ * @param dto - UpdateUserAttributesRequestDTO containing sub and fields to update
3604
2684
  * @returns Updated user object
3605
2685
  * @throws {NAuthException} If user not found or unique constraint violated
3606
2686
  *
3607
2687
  * @example
3608
- * await authService.updateUserAttributes(sub, { email: 'test@example.com' });
2688
+ * await authService.updateUserAttributes({ sub: 'user-uuid', email: 'test@example.com' });
3609
2689
  */
3610
2690
  async updateUserAttributes(dto) {
3611
- // Ensure DTO is validated (supports direct usage without framework validation)
3612
- dto = await (0, dto_validator_1.ensureValidatedDto)(update_user_attributes_request_dto_1.UpdateUserAttributesRequestDTO, dto);
3613
- // Find user by sub (external identifier)
3614
- const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
3615
- if (!user) {
3616
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
3617
- }
3618
- // Check for uniqueness constraints - use internal id
3619
- await this.validateUniquenessConstraints(user.id, dto);
3620
- // Prepare update object
3621
- const updateFields = {};
3622
- // Update basic fields if provided
3623
- if (dto.firstName !== undefined) {
3624
- updateFields.firstName = dto.firstName;
3625
- }
3626
- if (dto.lastName !== undefined) {
3627
- updateFields.lastName = dto.lastName;
3628
- }
3629
- if (dto.username !== undefined) {
3630
- updateFields.username = dto.username;
3631
- }
3632
- if (dto.email !== undefined) {
3633
- const oldEmail = user.email;
3634
- updateFields.email = dto.email;
3635
- // Reset email verification if email changed (unless retainVerification is true)
3636
- if (dto.email !== user.email) {
3637
- if (!dto.retainVerification) {
3638
- updateFields.isEmailVerified = false;
3639
- }
3640
- else {
3641
- // Explicitly retain current verification status
3642
- updateFields.isEmailVerified = user.isEmailVerified;
3643
- }
3644
- // ============================================================================
3645
- // MFA Device Management: Handle Email MFA devices when email changes
3646
- // ============================================================================
3647
- // When email address changes, Email MFA devices become invalid.
3648
- // We deactivate them and check if user has any other active MFA devices.
3649
- // If Email was the only MFA method, user will need to set up MFA again.
3650
- // This happens automatically via challenge system at next login.
3651
- if (oldEmail && this.mfaDeviceRepository) {
3652
- try {
3653
- // Find all Email MFA devices (email field may be null in legacy devices)
3654
- const emailDevices = (await this.mfaDeviceRepository.find({
3655
- where: {
3656
- userId: user.id,
3657
- type: mfa_method_enum_1.MFAMethod.EMAIL,
3658
- isActive: true,
3659
- },
3660
- }));
3661
- if (emailDevices.length > 0) {
3662
- this.logger?.log?.(`Deleting ${emailDevices.length} Email MFA device(s) for user ${user.sub} due to email address change (old: ${oldEmail}, new: ${dto.email})`);
3663
- // Delete all Email devices (can't be reactivated with old email)
3664
- for (const device of emailDevices) {
3665
- const deviceId = device.id;
3666
- await this.mfaDeviceRepository.delete(deviceId);
3667
- }
3668
- // Record audit event for removed Email MFA devices
3669
- if (this.auditService) {
3670
- try {
3671
- await this.auditService.recordEvent({
3672
- userId: user.id,
3673
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DEVICE_REMOVED,
3674
- eventStatus: 'INFO',
3675
- reason: 'email_changed',
3676
- description: `Email MFA device(s) removed due to email address change (${oldEmail} → ${dto.email})`,
3677
- metadata: {
3678
- method: mfa_method_enum_1.MFAMethod.EMAIL,
3679
- deletedCount: emailDevices.length,
3680
- oldEmail,
3681
- newEmail: dto.email,
3682
- reason: 'email_address_changed_requires_reverification',
3683
- },
3684
- });
3685
- }
3686
- catch (auditError) {
3687
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
3688
- this.logger?.error?.(`Failed to record MFA_DEVICE_REMOVED audit event for email change: ${errorMessage}`, { error: auditError, userId: user.id });
3689
- }
3690
- }
3691
- // Check if user has any other active MFA devices
3692
- const allActiveDevices = (await this.mfaDeviceRepository.find({
3693
- where: {
3694
- userId: user.id,
3695
- isActive: true,
3696
- },
3697
- }));
3698
- // If no active devices remain and user had MFA enabled, disable MFA
3699
- if (allActiveDevices.length === 0 && user.mfaEnabled) {
3700
- updateFields.mfaEnabled = false;
3701
- updateFields.mfaMethods = [];
3702
- updateFields.preferredMfaMethod = null;
3703
- this.logger?.log?.(`MFA disabled for user ${user.sub} - no active MFA devices remaining after email change`);
3704
- }
3705
- else {
3706
- this.logger?.log?.(`User ${user.sub} still has ${allActiveDevices.length} active MFA device(s) - MFA remains enabled`);
3707
- }
3708
- }
3709
- }
3710
- catch (error) {
3711
- // Log error but don't fail the email update
3712
- // This handles cases where MFA module is not imported (mfaDeviceRepository might not be available)
3713
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
3714
- this.logger?.warn?.(`Failed to handle MFA device deactivation during email change for user ${user.sub}: ${errorMessage}`);
3715
- }
3716
- }
3717
- }
3718
- }
3719
- if (dto.phone !== undefined) {
3720
- const oldPhone = user.phone;
3721
- updateFields.phone = dto.phone;
3722
- // Reset phone verification if phone changed (unless retainVerification is true)
3723
- if (dto.phone !== user.phone) {
3724
- if (!dto.retainVerification) {
3725
- updateFields.isPhoneVerified = false;
3726
- }
3727
- else {
3728
- // Explicitly retain current verification status
3729
- updateFields.isPhoneVerified = user.isPhoneVerified;
3730
- }
3731
- // ============================================================================
3732
- // MFA Device Management: Handle SMS MFA devices when phone changes
3733
- // ============================================================================
3734
- // When phone number changes, SMS MFA devices become invalid.
3735
- // We delete them and check if user has any other active MFA devices.
3736
- // If SMS was the only MFA method, user will need to set up MFA again.
3737
- // This happens automatically via challenge system at next login.
3738
- if (oldPhone && this.mfaDeviceRepository) {
3739
- try {
3740
- // Find all SMS MFA devices (SMS MFA is tied to user.phone, not device phoneNumber)
3741
- const smsDevices = (await this.mfaDeviceRepository.find({
3742
- where: {
3743
- userId: user.id,
3744
- type: mfa_method_enum_1.MFAMethod.SMS,
3745
- isActive: true,
3746
- },
3747
- }));
3748
- if (smsDevices.length > 0) {
3749
- this.logger?.log?.(`Deleting ${smsDevices.length} SMS MFA device(s) for user ${user.sub} due to phone number change (old: ${oldPhone}, new: ${dto.phone})`);
3750
- // Delete all SMS devices (can't be reactivated with old phone number)
3751
- for (const device of smsDevices) {
3752
- const deviceId = device.id;
3753
- await this.mfaDeviceRepository.delete(deviceId);
3754
- }
3755
- // Record audit event for removed SMS MFA devices
3756
- if (this.auditService) {
3757
- try {
3758
- await this.auditService.recordEvent({
3759
- userId: user.id,
3760
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DEVICE_REMOVED,
3761
- eventStatus: 'INFO',
3762
- reason: 'phone_changed',
3763
- description: `SMS MFA device(s) removed due to phone number change (${oldPhone} → ${dto.phone})`,
3764
- metadata: {
3765
- method: mfa_method_enum_1.MFAMethod.SMS,
3766
- deletedCount: smsDevices.length,
3767
- oldPhone,
3768
- newPhone: dto.phone,
3769
- reason: 'phone_number_changed_requires_reverification',
3770
- },
3771
- });
3772
- }
3773
- catch (auditError) {
3774
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
3775
- this.logger?.error?.(`Failed to record MFA_DEVICE_REMOVED audit event for phone change: ${errorMessage}`, { error: auditError, userId: user.id });
3776
- }
3777
- }
3778
- // Check if user has any other active MFA devices
3779
- const allActiveDevices = (await this.mfaDeviceRepository.find({
3780
- where: {
3781
- userId: user.id,
3782
- isActive: true,
3783
- },
3784
- }));
3785
- // If no active devices remain and user had MFA enabled, disable MFA
3786
- if (allActiveDevices.length === 0 && user.mfaEnabled) {
3787
- updateFields.mfaEnabled = false;
3788
- updateFields.mfaMethods = [];
3789
- updateFields.preferredMfaMethod = null;
3790
- this.logger?.log?.(`MFA disabled for user ${user.sub} - no active MFA devices remaining after phone change`);
3791
- }
3792
- else {
3793
- this.logger?.log?.(`User ${user.sub} still has ${allActiveDevices.length} active MFA device(s) - MFA remains enabled`);
3794
- }
3795
- }
3796
- }
3797
- catch (error) {
3798
- // Log error but don't fail the phone update
3799
- // This handles cases where MFA module is not imported (mfaDeviceRepository might not be available)
3800
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
3801
- this.logger?.warn?.(`Failed to handle MFA device deactivation during phone change for user ${user.sub}: ${errorMessage}`);
3802
- }
3803
- }
3804
- }
3805
- }
3806
- // Handle preferred MFA method
3807
- if (dto.preferredMfaMethod !== undefined) {
3808
- updateFields.preferredMfaMethod = dto.preferredMfaMethod;
3809
- }
3810
- // Handle metadata merge
3811
- if (dto.metadata !== undefined) {
3812
- const existingMetadata = user.metadata || {};
3813
- updateFields.metadata = { ...existingMetadata, ...dto.metadata };
3814
- }
3815
- // Update user in database - use internal id for update query
3816
- await this.userRepository.update(user.id, updateFields);
3817
- // Fetch updated user - use internal id
3818
- const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
3819
- if (!updatedUser) {
3820
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after update');
3821
- }
3822
- // ============================================================================
3823
- // Audit: Record profile and attribute updates
3824
- // ============================================================================
3825
- try {
3826
- // Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
3827
- // Note: ClientInfoService is used transparently by SessionService and AuditService
3828
- const updatedFieldNames = Object.keys(updateFields);
3829
- // Build field changes map with before/after values
3830
- const fieldChanges = {};
3831
- // Capture before/after values for each updated field
3832
- if (dto.firstName !== undefined && dto.firstName !== user.firstName) {
3833
- fieldChanges.firstName = {
3834
- before: user.firstName ?? null,
3835
- after: dto.firstName ?? null,
3836
- };
3837
- }
3838
- if (dto.lastName !== undefined && dto.lastName !== user.lastName) {
3839
- fieldChanges.lastName = {
3840
- before: user.lastName ?? null,
3841
- after: dto.lastName ?? null,
3842
- };
3843
- }
3844
- if (dto.username !== undefined && dto.username !== user.username) {
3845
- fieldChanges.username = {
3846
- before: user.username ?? null,
3847
- after: dto.username ?? null,
3848
- };
3849
- }
3850
- // Note: email and phone are tracked separately with specific audit events,
3851
- // but we include them in fieldChanges for completeness
3852
- if (dto.email !== undefined && dto.email !== user.email) {
3853
- fieldChanges.email = {
3854
- before: user.email ?? null,
3855
- after: dto.email ?? null,
3856
- };
3857
- }
3858
- if (dto.phone !== undefined && dto.phone !== user.phone) {
3859
- fieldChanges.phone = {
3860
- before: user.phone ?? null,
3861
- after: dto.phone ?? null,
3862
- };
3863
- }
3864
- if (dto.preferredMfaMethod !== undefined && dto.preferredMfaMethod !== user.preferredMfaMethod) {
3865
- fieldChanges.preferredMfaMethod = {
3866
- before: user.preferredMfaMethod ?? null,
3867
- after: dto.preferredMfaMethod ?? null,
3868
- };
3869
- }
3870
- // Handle metadata changes (merged, so track what was added/changed)
3871
- if (dto.metadata !== undefined) {
3872
- const oldMetadata = user.metadata || {};
3873
- const newMetadata = { ...oldMetadata, ...dto.metadata };
3874
- const metadataChanges = {};
3875
- // Track all keys in new metadata
3876
- const allKeys = new Set([...Object.keys(oldMetadata), ...Object.keys(dto.metadata)]);
3877
- for (const key of allKeys) {
3878
- const oldValue = oldMetadata[key];
3879
- const newValue = newMetadata[key];
3880
- // Only track if value actually changed
3881
- if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
3882
- metadataChanges[key] = {
3883
- before: oldValue ?? null,
3884
- after: newValue ?? null,
3885
- };
3886
- }
3887
- }
3888
- if (Object.keys(metadataChanges).length > 0) {
3889
- fieldChanges.metadata = metadataChanges;
3890
- }
3891
- }
3892
- // Track verification status changes if email/phone changed
3893
- if (dto.email !== undefined && dto.email !== user.email) {
3894
- const emailVerificationChanged = !dto.retainVerification && updateFields.isEmailVerified === false;
3895
- if (emailVerificationChanged) {
3896
- fieldChanges.isEmailVerified = {
3897
- before: user.isEmailVerified,
3898
- after: false,
3899
- };
3900
- }
3901
- }
3902
- if (dto.phone !== undefined && dto.phone !== user.phone) {
3903
- const phoneVerificationChanged = !dto.retainVerification && updateFields.isPhoneVerified === false;
3904
- if (phoneVerificationChanged) {
3905
- fieldChanges.isPhoneVerified = {
3906
- before: user.isPhoneVerified,
3907
- after: false,
3908
- };
3909
- }
3910
- }
3911
- // Record general profile update with field changes
3912
- await this.auditService?.recordEvent({
3913
- userId: user.id,
3914
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PROFILE_UPDATED,
3915
- eventStatus: 'INFO',
3916
- metadata: {
3917
- // Client info automatically included from context
3918
- updatedFields: updatedFieldNames,
3919
- fieldChanges: Object.keys(fieldChanges).length > 0 ? fieldChanges : undefined,
3920
- },
3921
- });
3922
- // Record specific field changes
3923
- if (dto.email !== undefined && dto.email !== user.email) {
3924
- await this.auditService?.recordEvent({
3925
- userId: user.id,
3926
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.EMAIL_CHANGED,
3927
- eventStatus: 'INFO',
3928
- metadata: {
3929
- // Client info automatically included from context
3930
- oldEmail: user.email,
3931
- newEmail: dto.email,
3932
- retainVerification: dto.retainVerification || false,
3933
- },
3934
- });
3935
- }
3936
- if (dto.phone !== undefined && dto.phone !== user.phone) {
3937
- await this.auditService?.recordEvent({
3938
- userId: user.id,
3939
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PHONE_CHANGED,
3940
- eventStatus: 'INFO',
3941
- metadata: {
3942
- // Client info automatically included from context
3943
- oldPhone: user.phone,
3944
- newPhone: dto.phone,
3945
- retainVerification: dto.retainVerification || false,
3946
- },
3947
- });
3948
- }
3949
- if (dto.username !== undefined && dto.username !== user.username) {
3950
- await this.auditService?.recordEvent({
3951
- userId: user.id,
3952
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.USERNAME_CHANGED,
3953
- eventStatus: 'INFO',
3954
- metadata: {
3955
- // Client info automatically included from context
3956
- oldUsername: user.username,
3957
- newUsername: dto.username,
3958
- },
3959
- });
3960
- }
3961
- }
3962
- catch (auditError) {
3963
- // Non-blocking: Log but continue
3964
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
3965
- this.logger?.error?.(`Failed to record profile update audit events: ${errorMessage}`, {
3966
- error: auditError,
3967
- userId: user.id,
3968
- });
3969
- }
3970
- // Return user response DTO
3971
- return user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
2691
+ return await this.userService.updateUserAttributes(dto);
3972
2692
  }
3973
2693
  /**
3974
- * Ensures email, phone, and username are unique for other users before update.
2694
+ * Update email and/or phone verification status.
3975
2695
  *
3976
- * Throws if another user already has the specified email, phone, or username.
2696
+ * Intended for admin use cases such as migration or offline validation.
2697
+ * Updates verification status without requiring actual verification codes.
3977
2698
  *
3978
- * @param userId - Internal numeric user ID (excluded from check)
3979
- * @param updateData - User fields to check for uniqueness
3980
- * @throws {NAuthException} If a unique constraint is violated for email, phone, or username
2699
+ * Validation:
2700
+ * - Cannot set verified=true if email/phone doesn't exist
2701
+ * - Can set verified=false even if email/phone doesn't exist (default state)
2702
+ * - Only updates provided fields (partial update)
3981
2703
  *
3982
- * @example
3983
- * ```typescript
3984
- * await authService.validateUniquenessConstraints(1, { email: "test@example.com" });
3985
- * ```
3986
- */
3987
- async validateUniquenessConstraints(userId, updateData) {
3988
- const conflicts = [];
3989
- // Check email uniqueness
3990
- if (updateData.email) {
3991
- const existingUser = await this.userRepository.findOne({
3992
- where: { email: updateData.email },
3993
- });
3994
- if (existingUser && existingUser.id !== userId) {
3995
- conflicts.push('Email already exists');
3996
- }
3997
- }
3998
- // Check phone uniqueness
3999
- if (updateData.phone) {
4000
- const existingUser = await this.userRepository.findOne({
4001
- where: { phone: updateData.phone },
4002
- });
4003
- if (existingUser && existingUser.id !== userId) {
4004
- conflicts.push('Phone number already exists');
4005
- }
4006
- }
4007
- // Check username uniqueness
4008
- if (updateData.username) {
4009
- const existingUser = await this.userRepository.findOne({
4010
- where: { username: updateData.username },
4011
- });
4012
- if (existingUser && existingUser.id !== userId) {
4013
- conflicts.push('Username already exists');
4014
- }
4015
- }
4016
- if (conflicts.length > 0) {
4017
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, conflicts.join(', '), {
4018
- conflicts,
4019
- });
4020
- }
4021
- }
4022
- // ============================================================================
4023
- // Helper Methods
4024
- // ============================================================================
4025
- /**
4026
- * Checks if the login identifier matches the specified allowed type.
4027
- *
4028
- * Determines if the given identifier is a valid email, username, phone, or allowed hybrid,
4029
- * according to the configured identifier type restriction.
4030
- *
4031
- * @param identifier - The login identifier to check (email, username, or phone)
4032
- * @param allowedType - The permitted identifier type ('email', 'username', 'phone', or 'email_or_username')
4033
- * @returns True if the identifier conforms to the allowed type, otherwise false
4034
- *
4035
- * @example
4036
- * ```typescript
4037
- * // Email check
4038
- * const valid = this.validateIdentifierType('user@example.com', 'email'); // true
4039
- *
4040
- * // Username check
4041
- * const valid = this.validateIdentifierType('johndoe', 'username'); // true
4042
- * ```
4043
- */
4044
- validateIdentifierType(identifier, allowedType) {
4045
- // Check if identifier is an email (contains @)
4046
- const isEmail = identifier.includes('@');
4047
- // Check if identifier looks like a phone (starts with + and contains digits)
4048
- const isPhone = /^\+[1-9]\d{1,14}$/.test(identifier.trim());
4049
- // If not email or phone, assume it's a username
4050
- const isUsername = !isEmail && !isPhone;
4051
- switch (allowedType) {
4052
- case 'email':
4053
- return isEmail;
4054
- case 'username':
4055
- return isUsername;
4056
- case 'phone':
4057
- return isPhone;
4058
- case 'email_or_username':
4059
- return isEmail || isUsername;
4060
- default:
4061
- return true; // No restriction
4062
- }
4063
- }
4064
- /**
4065
- * Retrieves a user entity by login identifier.
2704
+ * Audit:
2705
+ * - Records EMAIL_VERIFIED or PHONE_VERIFIED audit events
2706
+ * - Includes performedBy from authenticated admin context
4066
2707
  *
4067
- * Performs a lookup for a user by email, username, or phone number.
4068
- * The search respects the identifierType restriction when provided, limiting which fields are queried.
4069
- *
4070
- * @param identifier - Login credential (email, username, or phone)
4071
- * @param identifierType - Restricts search to a specific identifier type ('email', 'username', 'phone', or 'email_or_username')
4072
- * @returns The user entity if found, otherwise null
2708
+ * @param dto - Request DTO containing sub and verification status flags
2709
+ * @returns Updated user object
2710
+ * @throws {NAuthException} If user not found or trying to verify non-existent email/phone
4073
2711
  *
4074
2712
  * @example
4075
2713
  * ```typescript
4076
- * const user = await this.findUserByIdentifier('user@example.com');
4077
- * const user2 = await this.findUserByIdentifier('johndoe', 'username');
4078
- * ```
4079
- */
4080
- async findUserByIdentifier(identifier, identifierType) {
4081
- const queryBuilder = this.userRepository.createQueryBuilder('user');
4082
- // Build query based on identifier type restriction
4083
- if (!identifierType) {
4084
- // No restriction - search all fields
4085
- queryBuilder
4086
- .where('user.email = :identifier', { identifier })
4087
- .orWhere('user.username = :identifier', { identifier })
4088
- .orWhere('user.phone = :identifier', { identifier });
4089
- }
4090
- else {
4091
- // Apply restriction based on identifier type
4092
- switch (identifierType) {
4093
- case 'email':
4094
- queryBuilder.where('user.email = :identifier', { identifier });
4095
- break;
4096
- case 'username':
4097
- queryBuilder.where('user.username = :identifier', { identifier });
4098
- break;
4099
- case 'phone':
4100
- queryBuilder.where('user.phone = :identifier', { identifier });
4101
- break;
4102
- case 'email_or_username':
4103
- queryBuilder
4104
- .where('user.email = :identifier', { identifier })
4105
- .orWhere('user.username = :identifier', { identifier });
4106
- break;
4107
- }
4108
- }
4109
- // Select only columns required for login checks and response shaping to reduce row size
4110
- queryBuilder.select([
4111
- 'user.id',
4112
- 'user.sub',
4113
- 'user.email',
4114
- 'user.firstName',
4115
- 'user.lastName',
4116
- 'user.username',
4117
- 'user.phone',
4118
- 'user.passwordHash',
4119
- 'user.passwordChangedAt',
4120
- 'user.mustChangePassword',
4121
- 'user.isActive',
4122
- 'user.mfaEnabled',
4123
- 'user.preferredMfaMethod',
4124
- 'user.isEmailVerified',
4125
- 'user.isPhoneVerified',
4126
- 'user.mfaExempt', // Required for MFA exemption check in challenge flow
4127
- // Lock fields - required for account lock check in login flow
4128
- 'user.isLocked',
4129
- 'user.lockReason',
4130
- 'user.lockedAt',
4131
- 'user.lockedUntil',
4132
- // The following are used for messaging/challenge determination when needed
4133
- 'user.socialProviders',
4134
- 'user.backupCodes',
4135
- ]);
4136
- return (await queryBuilder.getOne());
4137
- }
4138
- /**
4139
- * Handles a failed login by recording the attempt, applying IP-based lockout policy,
4140
- * and invoking relevant hooks.
4141
- *
4142
- * @param identifier - User identifier (email/username/phone)
4143
- * @param reason - Optional reason for failure
4144
- * @returns Promise<void>
2714
+ * // Update email verification only
2715
+ * await authService.updateVerifiedStatus({
2716
+ * sub: 'user-uuid',
2717
+ * isEmailVerified: true
2718
+ * });
4145
2719
  *
4146
- * @example
4147
- * ```typescript
4148
- * await authService.handleFailedLogin('user@example.com', 'invalid_credentials');
2720
+ * // Update both email and phone verification
2721
+ * await authService.updateVerifiedStatus({
2722
+ * sub: 'user-uuid',
2723
+ * isEmailVerified: true,
2724
+ * isPhoneVerified: false
2725
+ * });
4149
2726
  * ```
4150
2727
  */
4151
- async handleFailedLogin(identifier, reason) {
4152
- // Get client IP address for lockout tracking
4153
- const clientInfo = this.clientInfoService.get();
4154
- const ipAddress = clientInfo.ipAddress;
4155
- // Record failed attempt
4156
- await this.recordLoginAttempt(identifier, false, reason);
4157
- // Increment IP-based lockout counter if enabled
4158
- if (this.config.lockout?.enabled && ipAddress) {
4159
- const attempts = await this.accountLockoutStorage.recordFailedAttempt(ipAddress);
4160
- // Lock IP if max attempts reached
4161
- if (attempts >= (this.config.lockout.maxAttempts || 5)) {
4162
- await this.accountLockoutStorage.lockIpAddress(ipAddress, this.config.lockout.duration || 900, // 15 minutes default
4163
- 'Too many failed login attempts from this IP');
4164
- // // Execute hook with IP address
4165
- // if (this.config.hooks?.afterAccountLock) {
4166
- // await this.config.hooks.afterAccountLock(identifier, 'Too many failed attempts from IP', clientInfo);
4167
- // }
4168
- }
4169
- }
4170
- // ============================================================================
4171
- // Lifecycle Hook: afterLoginFailed (TODO: Implement provider-based hook)
4172
- // ============================================================================
4173
- // TODO: Implement provider-based hook for afterLoginFailed
4174
- // await this.hookRegistry.executeAfterLoginFailed(identifier, reason || 'unknown');
4175
- }
4176
- /**
4177
- * Records a login attempt with client context.
4178
- *
4179
- * @param email - User's email address
4180
- * @param success - True if login succeeded, false if failed
4181
- * @param failureReason - Optional reason for failure
4182
- * @param userId - Optional internal user ID (only for successful logins)
4183
- * @returns Promise<void>
4184
- */
4185
- async recordLoginAttempt(email, success, failureReason, userId) {
4186
- // Get client info from context
4187
- const clientInfo = this.clientInfoService.get();
4188
- const attempt = this.loginAttemptRepository.create({
4189
- email,
4190
- userId, // Internal user ID (integer)
4191
- ipAddress: clientInfo.ipAddress,
4192
- userAgent: clientInfo.userAgent,
4193
- success,
4194
- failureReason,
4195
- });
4196
- await this.loginAttemptRepository.save(attempt);
2728
+ async updateVerifiedStatus(dto) {
2729
+ return await this.userService.updateVerifiedStatus(dto);
4197
2730
  }
2731
+ // ============================================================================
2732
+ // Helper Methods
2733
+ // ============================================================================
2734
+ // NOTE: Private helper methods have been moved to AuthServiceInternalHelpers
2735
+ // Use this.helpers.methodName() to access them
4198
2736
  /**
4199
2737
  * Get user for authentication context
4200
2738
  *
@@ -4216,90 +2754,53 @@ class AuthService {
4216
2754
  * ```
4217
2755
  */
4218
2756
  async getUserForAuthContext(sub) {
4219
- // Load user with all fields including passwordHash (needed to compute hasPasswordHash)
4220
- // NOTE: We need to load passwordHash before @AfterLoad hook deletes it
4221
- // The hook computes hasPasswordHash but deletes passwordHash, so we check it first
4222
- const user = await this.userRepository.findOne({
4223
- where: { sub },
4224
- });
4225
- if (!user) {
4226
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
4227
- }
4228
- if (!user.isActive) {
4229
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.ACCOUNT_INACTIVE, 'Account is not active');
4230
- }
4231
- // CRITICAL: The @AfterLoad hook computes hasPasswordHash but doesn't delete passwordHash anymore
4232
- // Use the computed value from the hook, or compute it from passwordHash if hook didn't run
4233
- const userWithPassword = user;
4234
- const hasPasswordHash = user.hasPasswordHash !== undefined ? user.hasPasswordHash : Boolean(userWithPassword.passwordHash);
4235
- // Create safe user object without sensitive fields
4236
- const safeUser = {
4237
- ...user,
4238
- hasPasswordHash,
4239
- };
4240
- // Remove sensitive fields (passwordHash may already be deleted by @AfterLoad hook, but ensure it's gone)
4241
- delete safeUser.passwordHash;
4242
- delete safeUser.totpSecret;
4243
- delete safeUser.backupCodes;
4244
- delete safeUser.passwordHistory;
4245
- return safeUser;
2757
+ return await this.userService.getUserForAuthContext(sub);
4246
2758
  }
2759
+ /**
2760
+ * Get user by external identifier (sub/UUID).
2761
+ *
2762
+ * @param dto - GetUserByIdDTO containing sub
2763
+ * @returns User response DTO or null if not found
2764
+ *
2765
+ * @example
2766
+ * ```typescript
2767
+ * const user = await authService.getUserById({ sub: 'user-uuid' });
2768
+ * ```
2769
+ */
4247
2770
  async getUserById(dto) {
4248
- // Ensure DTO is validated (supports direct usage without framework validation)
4249
- dto = await (0, dto_validator_1.ensureValidatedDto)(get_user_by_id_dto_1.GetUserByIdDTO, dto);
4250
- const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
4251
- return user ? user_response_dto_1.UserResponseDto.fromEntity(user) : null;
2771
+ return await this.userService.getUserById(dto);
4252
2772
  }
4253
2773
  /**
4254
2774
  * Get user by email address.
4255
2775
  *
4256
- * @param email - User email
4257
- * @param requireEmailVerified - Only return user if email is verified (default: false)
4258
- * @returns User entity or null
2776
+ * @param dto - GetUserByEmailDTO containing email and optional requireEmailVerified
2777
+ * @returns User response DTO or null if not found
4259
2778
  * @internal - For use by social auth providers
4260
2779
  *
4261
2780
  * @example
4262
2781
  * ```typescript
4263
- * const user = await authService.getUserByEmail('user@example.com', true);
2782
+ * const user = await authService.getUserByEmail({ email: 'user@example.com', requireEmailVerified: true });
4264
2783
  * ```
4265
2784
  */
4266
2785
  async getUserByEmail(dto) {
4267
- // Ensure DTO is validated (supports direct usage without framework validation)
4268
- dto = await (0, dto_validator_1.ensureValidatedDto)(get_user_by_email_dto_1.GetUserByEmailDTO, dto);
4269
- const where = dto.requireEmailVerified
4270
- ? { email: dto.email, isEmailVerified: true }
4271
- : { email: dto.email };
4272
- const user = (await this.userRepository.findOne({ where }));
4273
- return user ? user_response_dto_1.UserResponseDto.fromEntity(user) : null;
2786
+ return await this.userService.getUserByEmail(dto);
4274
2787
  }
4275
2788
  /**
4276
2789
  * Require user to change password at next login.
4277
2790
  *
4278
2791
  * Throws if user not found or has no password set (e.g. social login only).
4279
2792
  *
4280
- * @param userId - User's sub identifier
4281
- * @returns Resolves when flag is set
2793
+ * @param dto - SetMustChangePasswordDTO containing userId (sub)
2794
+ * @returns Success response
4282
2795
  * @throws {NAuthException} If user is not found or cannot change password
4283
2796
  *
4284
2797
  * @example
4285
- * await authService.setMustChangePassword('user-uuid-123');
2798
+ * ```typescript
2799
+ * await authService.setMustChangePassword({ userId: 'user-uuid-123' });
2800
+ * ```
4286
2801
  */
4287
2802
  async setMustChangePassword(dto) {
4288
- // Ensure DTO is validated (supports direct usage without framework validation)
4289
- dto = await (0, dto_validator_1.ensureValidatedDto)(set_must_change_password_dto_1.SetMustChangePasswordDTO, dto);
4290
- const user = await this.userRepository.findOne({ where: { sub: dto.userId } });
4291
- if (!user) {
4292
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
4293
- }
4294
- // CRITICAL PROTECTION: Only allow for users with password authentication
4295
- // Pure social users cannot be forced to change password
4296
- if (!user.passwordHash) {
4297
- this.logger?.warn?.(`Cannot force password change for user ${dto.userId} - user doesn't have a password (pure social signup)`);
4298
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_CHANGE_NOT_ALLOWED, 'Password change not available. This account uses social authentication only and has no password.');
4299
- }
4300
- await this.userRepository.update({ sub: dto.userId }, { mustChangePassword: true });
4301
- this.logger?.log?.(`Must-change-password flag set for user: ${dto.userId}`);
4302
- return { success: true };
2803
+ return await this.userService.setMustChangePassword(dto);
4303
2804
  }
4304
2805
  /**
4305
2806
  * Admin-only: Reset a user's password by identifier.
@@ -4352,7 +2853,7 @@ class AuthService {
4352
2853
  // If not found by sub, try by identifier (email, username, phone)
4353
2854
  if (!user) {
4354
2855
  this.logger?.debug?.(`Searching by identifier (email/username/phone): ${dto.identifier}`);
4355
- user = await this.findUserByIdentifier(dto.identifier);
2856
+ user = await this.helpers.findUserByIdentifier(dto.identifier);
4356
2857
  }
4357
2858
  if (!user) {
4358
2859
  this.logger?.warn?.(`Password reset failed - user not found: ${dto.identifier}`);
@@ -4361,7 +2862,7 @@ class AuthService {
4361
2862
  const mustChangePassword = dto.mustChangePassword ?? true; // Default to true for security
4362
2863
  const revokeSessions = dto.revokeSessions !== false;
4363
2864
  const wasSocialOnly = !user.passwordHash;
4364
- const { sessionsRevoked } = await this.updateUserPassword({
2865
+ const { sessionsRevoked } = await this.helpers.updateUserPassword({
4365
2866
  user,
4366
2867
  newPassword: dto.newPassword,
4367
2868
  mustChangePassword,
@@ -4380,7 +2881,7 @@ class AuthService {
4380
2881
  wasSocialOnly,
4381
2882
  },
4382
2883
  },
4383
- });
2884
+ }, this.passwordService, this.auditService);
4384
2885
  // ============================================================================
4385
2886
  // Return Response
4386
2887
  // ============================================================================
@@ -4420,11 +2921,11 @@ class AuthService {
4420
2921
  }
4421
2922
  // Respect identifier type restrictions (if configured)
4422
2923
  if (this.config.login?.identifierType &&
4423
- !this.validateIdentifierType(dto.identifier, this.config.login.identifierType)) {
2924
+ !this.helpers.validateIdentifierType(dto.identifier, this.config.login.identifierType)) {
4424
2925
  // Non-enumerating: return success without sending
4425
2926
  return response;
4426
2927
  }
4427
- const user = await this.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
2928
+ const user = await this.helpers.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
4428
2929
  if (!user) {
4429
2930
  return response; // Non-enumerating
4430
2931
  }
@@ -4499,12 +3000,12 @@ class AuthService {
4499
3000
  if (!this.passwordResetService) {
4500
3001
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.SERVICE_UNAVAILABLE, 'Password reset is not available');
4501
3002
  }
4502
- const user = await this.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
3003
+ const user = await this.helpers.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
4503
3004
  if (!user) {
4504
3005
  // Non-enumerating: treat as invalid code
4505
3006
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_RESET_CODE_INVALID, 'Invalid password reset code');
4506
3007
  }
4507
- const { sessionsRevoked: _sessionsRevoked } = await this.updateUserPassword({
3008
+ const { sessionsRevoked: _sessionsRevoked } = await this.helpers.updateUserPassword({
4508
3009
  user,
4509
3010
  newPassword: dto.newPassword,
4510
3011
  mustChangePassword: false,
@@ -4521,111 +3022,9 @@ class AuthService {
4521
3022
  description: 'Password reset completed by user',
4522
3023
  reason: 'forgot_password',
4523
3024
  },
4524
- });
3025
+ }, this.passwordService, this.auditService);
4525
3026
  return { success: true, mustChangePassword: false };
4526
3027
  }
4527
- // ============================================================================
4528
- // Internal Password Update Orchestration (Single Source of Truth)
4529
- // ============================================================================
4530
- /**
4531
- * Centralized password update flow used by:
4532
- * - changePassword()
4533
- * - confirmForgotPassword()
4534
- * - adminSetPassword()
4535
- * - FORCE_CHANGE_PASSWORD challenge handler
4536
- *
4537
- * WHY:
4538
- * - Prevent logic drift between different password-changing entrypoints
4539
- * - Ensure consistent validation, history enforcement, persistence, session revocation, and audit trails
4540
- *
4541
- * @param params - Password update parameters
4542
- * @returns Sessions revoked count (0 when not revoked)
4543
- * @throws {NAuthException} WEAK_PASSWORD | PASSWORD_REUSED | NOT_FOUND
4544
- */
4545
- async updateUserPassword(params) {
4546
- const { user, newPassword, mustChangePassword, revokeSessions, revokeReason, beforePersist, audit } = params;
4547
- // ============================================================================
4548
- // Load full user entity (important for passwordHistory serialization + reuse checks)
4549
- // ============================================================================
4550
- // WHY: Some call sites use a slim projection (e.g., findUserByIdentifier) which may omit passwordHistory.
4551
- const userEntity = (await this.userRepository.findOne({ where: { id: user.id } }));
4552
- if (!userEntity) {
4553
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
4554
- }
4555
- // ============================================================================
4556
- // Validate new password + history
4557
- // ============================================================================
4558
- const validation = await this.passwordService.validatePassword(newPassword, {
4559
- email: userEntity.email,
4560
- username: userEntity.username || undefined,
4561
- });
4562
- if (!validation.valid) {
4563
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.WEAK_PASSWORD, validation.errors.join(', '), {
4564
- errors: validation.errors,
4565
- });
4566
- }
4567
- if (this.config.password?.historyCount) {
4568
- const historyToCheck = userEntity.passwordHistory || [];
4569
- const allPreviousPasswords = userEntity.passwordHash
4570
- ? [userEntity.passwordHash, ...historyToCheck]
4571
- : historyToCheck;
4572
- const isReused = await this.passwordService.isPasswordInHistory(newPassword, allPreviousPasswords);
4573
- if (isReused) {
4574
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_REUSED, 'Cannot reuse a recent password');
4575
- }
4576
- }
4577
- // Hook point for flows that must prove possession of a reset code before persisting (forgot-password confirm)
4578
- if (beforePersist) {
4579
- await beforePersist();
4580
- }
4581
- // ============================================================================
4582
- // Persist password update
4583
- // ============================================================================
4584
- const newHash = await this.passwordService.hashPassword(newPassword);
4585
- const newHistory = userEntity.passwordHash
4586
- ? this.passwordService.addToHistory(userEntity.passwordHistory || [], userEntity.passwordHash)
4587
- : userEntity.passwordHistory || [];
4588
- userEntity.passwordHash = newHash;
4589
- userEntity.passwordChangedAt = new Date();
4590
- userEntity.passwordHistory = newHistory;
4591
- userEntity.mustChangePassword = mustChangePassword;
4592
- await this.userRepository.save(userEntity);
4593
- // ============================================================================
4594
- // Session revocation
4595
- // ============================================================================
4596
- let sessionsRevoked = 0;
4597
- if (revokeSessions) {
4598
- sessionsRevoked = await this.sessionService.revokeAllUserSessions(userEntity.id, revokeReason);
4599
- }
4600
- // ============================================================================
4601
- // Audit
4602
- // ============================================================================
4603
- if (audit) {
4604
- try {
4605
- await this.auditService?.recordEvent({
4606
- userId: userEntity.id,
4607
- eventType: audit.eventType,
4608
- eventStatus: audit.eventStatus,
4609
- reason: audit.reason,
4610
- description: audit.description,
4611
- authMethod: audit.authMethod,
4612
- metadata: {
4613
- ...audit.metadata,
4614
- mustChangePassword,
4615
- sessionsRevoked,
4616
- },
4617
- });
4618
- }
4619
- catch (auditError) {
4620
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
4621
- this.logger?.error?.(`Failed to record ${audit.eventType} audit event: ${errorMessage}`, {
4622
- error: auditError,
4623
- userId: userEntity.id,
4624
- });
4625
- }
4626
- }
4627
- return { sessionsRevoked };
4628
- }
4629
3028
  }
4630
3029
  exports.AuthService = AuthService;
4631
3030
  //# sourceMappingURL=auth.service.js.map