@nauth-toolkit/core 0.1.39 → 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 (33) 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 +4 -0
  10. package/dist/dto/index.d.ts.map +1 -1
  11. package/dist/dto/index.js +4 -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/services/auth-service-internal-helpers.d.ts +229 -0
  22. package/dist/services/auth-service-internal-helpers.d.ts.map +1 -0
  23. package/dist/services/auth-service-internal-helpers.js +1004 -0
  24. package/dist/services/auth-service-internal-helpers.js.map +1 -0
  25. package/dist/services/auth.service.d.ts +178 -156
  26. package/dist/services/auth.service.d.ts.map +1 -1
  27. package/dist/services/auth.service.js +486 -2308
  28. package/dist/services/auth.service.js.map +1 -1
  29. package/dist/services/user.service.d.ts +274 -0
  30. package/dist/services/user.service.d.ts.map +1 -0
  31. package/dist/services/user.service.js +1327 -0
  32. package/dist/services/user.service.js.map +1 -0
  33. package/package.json +1 -1
@@ -40,34 +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
- const update_verified_status_request_dto_1 = require("../dto/update-verified-status-request.dto");
51
45
  const user_response_dto_1 = require("../dto/user-response.dto");
52
46
  const auth_response_dto_1 = require("../dto/auth-response.dto");
53
47
  const auth_challenge_dto_1 = require("../dto/auth-challenge.dto");
54
48
  const respond_challenge_dto_1 = require("../dto/respond-challenge.dto");
55
- const get_user_by_email_dto_1 = require("../dto/get-user-by-email.dto");
56
- const get_user_by_id_dto_1 = require("../dto/get-user-by-id.dto");
57
49
  const logout_dto_1 = require("../dto/logout.dto");
58
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");
59
53
  const refresh_token_dto_1 = require("../dto/refresh-token.dto");
60
54
  const resend_code_dto_1 = require("../dto/resend-code.dto");
61
- const set_must_change_password_dto_1 = require("../dto/set-must-change-password.dto");
62
55
  const admin_set_password_dto_1 = require("../dto/admin-set-password.dto");
63
56
  const forgot_password_dto_1 = require("../dto/forgot-password.dto");
64
57
  const confirm_forgot_password_dto_1 = require("../dto/confirm-forgot-password.dto");
65
58
  const verify_email_dto_1 = require("../dto/verify-email.dto");
66
59
  const verify_phone_dto_1 = require("../dto/verify-phone.dto");
67
- 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");
68
62
  const nauth_exception_1 = require("../exceptions/nauth.exception");
69
63
  const error_codes_enum_1 = require("../enums/error-codes.enum");
70
- const mfa_method_enum_1 = require("../enums/mfa-method.enum");
71
64
  const class_validator_1 = require("class-validator");
72
65
  const crypto = __importStar(require("crypto"));
73
66
  const password_generator_1 = require("../utils/password-generator");
@@ -108,6 +101,8 @@ class AuthService {
108
101
  challengeSessionRepository;
109
102
  authAuditRepository;
110
103
  trustedDeviceRepository;
104
+ helpers;
105
+ userService;
111
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)
112
107
  phoneVerificationService, // Optional - only available when SMS provider is configured
113
108
  mfaService, // Optional - available when MFA modules are imported
@@ -147,6 +142,10 @@ class AuthService {
147
142
  this.challengeSessionRepository = challengeSessionRepository;
148
143
  this.authAuditRepository = authAuditRepository;
149
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);
150
149
  this.logger?.log?.('AuthService initialized');
151
150
  }
152
151
  // ============================================================================
@@ -853,124 +852,7 @@ class AuthService {
853
852
  * ```
854
853
  */
855
854
  async deleteUser(dto) {
856
- // Ensure DTO is validated
857
- dto = await (0, dto_validator_1.ensureValidatedDto)(delete_user_dto_1.DeleteUserDTO, dto);
858
- // Get client info for audit
859
- const clientInfo = this.clientInfoService.get();
860
- this.logger?.log?.(`Admin deleteUser initiated for sub: ${dto.sub}`);
861
- // Find user by sub
862
- const user = await this.userRepository.findOne({ where: { sub: dto.sub } });
863
- if (!user) {
864
- this.logger?.warn?.(`User not found for deletion: ${dto.sub}`);
865
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found');
866
- }
867
- this.logger?.debug?.(`Deleting user ${user.email} (id: ${user.id}, sub: ${dto.sub})`);
868
- // ============================================================================
869
- // Explicit Cascade Deletion (to track counts)
870
- // ============================================================================
871
- // Even though database has CASCADE, we explicitly delete each table to track counts
872
- // 1. Delete Sessions
873
- let sessionsCount = 0;
874
- if (this.sessionRepository) {
875
- const result = await this.sessionRepository.delete({ userId: user.id });
876
- sessionsCount = result.affected || 0;
877
- this.logger?.debug?.(`Deleted ${sessionsCount} sessions for user ${dto.sub}`);
878
- }
879
- // 2. Delete Verification Tokens
880
- let verificationTokensCount = 0;
881
- if (this.verificationTokenRepository) {
882
- const result = await this.verificationTokenRepository.delete({ userId: user.id });
883
- verificationTokensCount = result.affected || 0;
884
- this.logger?.debug?.(`Deleted ${verificationTokensCount} verification tokens for user ${dto.sub}`);
885
- }
886
- // 3. Delete MFA Devices
887
- let mfaDevicesCount = 0;
888
- if (this.mfaDeviceRepository) {
889
- const result = await this.mfaDeviceRepository.delete({ userId: user.id });
890
- mfaDevicesCount = result.affected || 0;
891
- this.logger?.debug?.(`Deleted ${mfaDevicesCount} MFA devices for user ${dto.sub}`);
892
- }
893
- // 4. Delete Trusted Devices
894
- let trustedDevicesCount = 0;
895
- if (this.trustedDeviceRepository) {
896
- const result = await this.trustedDeviceRepository.delete({ userId: user.id });
897
- trustedDevicesCount = result.affected || 0;
898
- this.logger?.debug?.(`Deleted ${trustedDevicesCount} trusted devices for user ${dto.sub}`);
899
- }
900
- // 5. Delete Social Accounts
901
- let socialAccountsCount = 0;
902
- if (this.socialAccountRepository) {
903
- const result = await this.socialAccountRepository.delete({ userId: user.id });
904
- socialAccountsCount = result.affected || 0;
905
- this.logger?.debug?.(`Deleted ${socialAccountsCount} social accounts for user ${dto.sub}`);
906
- }
907
- // 6. Delete Login Attempts
908
- let loginAttemptsCount = 0;
909
- const loginAttemptResult = await this.loginAttemptRepository.delete({ userId: user.id });
910
- loginAttemptsCount = loginAttemptResult.affected || 0;
911
- this.logger?.debug?.(`Deleted ${loginAttemptsCount} login attempts for user ${dto.sub}`);
912
- // 7. Delete Challenge Sessions
913
- let challengeSessionsCount = 0;
914
- if (this.challengeSessionRepository) {
915
- const result = await this.challengeSessionRepository.delete({ userId: user.id });
916
- challengeSessionsCount = result.affected || 0;
917
- this.logger?.debug?.(`Deleted ${challengeSessionsCount} challenge sessions for user ${dto.sub}`);
918
- }
919
- // 8. Delete Audit Logs (user-specific)
920
- let auditLogsCount = 0;
921
- if (this.authAuditRepository) {
922
- const result = await this.authAuditRepository.delete({ userId: user.id });
923
- auditLogsCount = result.affected || 0;
924
- this.logger?.debug?.(`Deleted ${auditLogsCount} audit logs for user ${dto.sub}`);
925
- }
926
- // ============================================================================
927
- // Record Admin Action (BEFORE deleting user to satisfy foreign key constraint)
928
- // ============================================================================
929
- try {
930
- await this.auditService?.recordEvent({
931
- userId: user.id,
932
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.ACCOUNT_DELETED,
933
- eventStatus: 'INFO',
934
- authMethod: 'admin',
935
- metadata: {
936
- deletedEmail: user.email,
937
- deletedSub: dto.sub,
938
- adminIdentifier: clientInfo.ipAddress || 'unknown',
939
- deletedRecords: {
940
- sessions: sessionsCount,
941
- verificationTokens: verificationTokensCount,
942
- mfaDevices: mfaDevicesCount,
943
- trustedDevices: trustedDevicesCount,
944
- socialAccounts: socialAccountsCount,
945
- loginAttempts: loginAttemptsCount,
946
- challengeSessions: challengeSessionsCount,
947
- auditLogs: auditLogsCount,
948
- },
949
- },
950
- });
951
- }
952
- catch (auditError) {
953
- // Non-blocking: Log but continue
954
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
955
- this.logger?.error?.(`Failed to record ACCOUNT_DELETED audit event: ${errorMessage}`);
956
- }
957
- // 9. Delete User Record (final)
958
- await this.userRepository.delete({ id: user.id });
959
- this.logger?.log?.(`User deleted successfully: ${user.email} (sub: ${dto.sub})`);
960
- return {
961
- success: true,
962
- deletedUserId: dto.sub,
963
- deletedRecords: {
964
- sessions: sessionsCount,
965
- verificationTokens: verificationTokensCount,
966
- mfaDevices: mfaDevicesCount,
967
- trustedDevices: trustedDevicesCount,
968
- socialAccounts: socialAccountsCount,
969
- loginAttempts: loginAttemptsCount,
970
- challengeSessions: challengeSessionsCount,
971
- auditLogs: auditLogsCount,
972
- },
973
- };
855
+ return await this.userService.deleteUser(dto);
974
856
  }
975
857
  /**
976
858
  * Get paginated list of users with advanced filtering
@@ -999,100 +881,7 @@ class AuthService {
999
881
  * ```
1000
882
  */
1001
883
  async getUsers(dto) {
1002
- // Ensure DTO is validated
1003
- dto = await (0, dto_validator_1.ensureValidatedDto)(get_users_dto_1.GetUsersDTO, dto);
1004
- this.logger?.debug?.(`Admin getUsers initiated with filters: ${JSON.stringify(dto)}`);
1005
- // ============================================================================
1006
- // Build Query with Filters
1007
- // ============================================================================
1008
- const qb = this.userRepository.createQueryBuilder('user');
1009
- // Apply partial match filters (email and phone) - case-insensitive
1010
- // Using LOWER() for cross-database compatibility (works on both MySQL and PostgreSQL)
1011
- if (dto.email) {
1012
- qb.andWhere('LOWER(user.email) LIKE LOWER(:email)', { email: `%${dto.email}%` });
1013
- }
1014
- if (dto.phone) {
1015
- qb.andWhere('LOWER(user.phone) LIKE LOWER(:phone)', { phone: `%${dto.phone}%` });
1016
- }
1017
- // Apply boolean filters
1018
- if (dto.isEmailVerified !== undefined) {
1019
- qb.andWhere('user.isEmailVerified = :isEmailVerified', { isEmailVerified: dto.isEmailVerified });
1020
- }
1021
- if (dto.isPhoneVerified !== undefined) {
1022
- qb.andWhere('user.isPhoneVerified = :isPhoneVerified', { isPhoneVerified: dto.isPhoneVerified });
1023
- }
1024
- if (dto.hasSocialAuth !== undefined) {
1025
- qb.andWhere('user.hasSocialAuth = :hasSocialAuth', { hasSocialAuth: dto.hasSocialAuth });
1026
- }
1027
- if (dto.isLocked !== undefined) {
1028
- qb.andWhere('user.isLocked = :isLocked', { isLocked: dto.isLocked });
1029
- }
1030
- if (dto.mfaEnabled !== undefined) {
1031
- qb.andWhere('user.mfaEnabled = :mfaEnabled', { mfaEnabled: dto.mfaEnabled });
1032
- }
1033
- // Apply date filters with operators
1034
- if (dto.createdAt) {
1035
- const { operator, value } = dto.createdAt;
1036
- if (operator === 'gt') {
1037
- qb.andWhere('user.createdAt > :createdAtValue', { createdAtValue: value });
1038
- }
1039
- else if (operator === 'gte') {
1040
- qb.andWhere('user.createdAt >= :createdAtValue', { createdAtValue: value });
1041
- }
1042
- else if (operator === 'lt') {
1043
- qb.andWhere('user.createdAt < :createdAtValue', { createdAtValue: value });
1044
- }
1045
- else if (operator === 'lte') {
1046
- qb.andWhere('user.createdAt <= :createdAtValue', { createdAtValue: value });
1047
- }
1048
- else if (operator === 'eq') {
1049
- qb.andWhere('user.createdAt = :createdAtValue', { createdAtValue: value });
1050
- }
1051
- }
1052
- if (dto.updatedAt) {
1053
- const { operator, value } = dto.updatedAt;
1054
- if (operator === 'gt') {
1055
- qb.andWhere('user.updatedAt > :updatedAtValue', { updatedAtValue: value });
1056
- }
1057
- else if (operator === 'gte') {
1058
- qb.andWhere('user.updatedAt >= :updatedAtValue', { updatedAtValue: value });
1059
- }
1060
- else if (operator === 'lt') {
1061
- qb.andWhere('user.updatedAt < :updatedAtValue', { updatedAtValue: value });
1062
- }
1063
- else if (operator === 'lte') {
1064
- qb.andWhere('user.updatedAt <= :updatedAtValue', { updatedAtValue: value });
1065
- }
1066
- else if (operator === 'eq') {
1067
- qb.andWhere('user.updatedAt = :updatedAtValue', { updatedAtValue: value });
1068
- }
1069
- }
1070
- // ============================================================================
1071
- // Apply Sorting
1072
- // ============================================================================
1073
- const sortBy = dto.sortBy || 'createdAt';
1074
- const sortOrder = dto.sortOrder || 'DESC';
1075
- qb.orderBy(`user.${sortBy}`, sortOrder);
1076
- // ============================================================================
1077
- // Apply Pagination
1078
- // ============================================================================
1079
- const page = dto.page || 1;
1080
- const limit = dto.limit || 10;
1081
- qb.skip((page - 1) * limit).take(limit);
1082
- // Execute query
1083
- const [users, total] = await qb.getManyAndCount();
1084
- this.logger?.debug?.(`Found ${users.length} users (total: ${total}) with filters`);
1085
- // Sanitize user data
1086
- const sanitizedUsers = users.map((user) => user_response_dto_1.UserResponseDto.fromEntity(user));
1087
- return {
1088
- users: sanitizedUsers,
1089
- pagination: {
1090
- page,
1091
- limit,
1092
- total,
1093
- totalPages: Math.ceil(total / limit),
1094
- },
1095
- };
884
+ return await this.userService.getUsers(dto);
1096
885
  }
1097
886
  /**
1098
887
  * Administrative permanent account locking
@@ -1123,112 +912,7 @@ class AuthService {
1123
912
  * ```
1124
913
  */
1125
914
  async disableUser(dto) {
1126
- // Ensure DTO is validated
1127
- dto = await (0, dto_validator_1.ensureValidatedDto)(disable_user_dto_1.DisableUserDTO, dto);
1128
- // Get client info for audit
1129
- const clientInfo = this.clientInfoService.get();
1130
- this.logger?.log?.(`Admin disableUser initiated for sub: ${dto.sub}`);
1131
- // Find user by sub
1132
- const user = await this.userRepository.findOne({ where: { sub: dto.sub } });
1133
- if (!user) {
1134
- this.logger?.warn?.(`User not found for disabling: ${dto.sub}`);
1135
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found');
1136
- }
1137
- this.logger?.debug?.(`Disabling user ${user.email} (id: ${user.id}, sub: ${dto.sub})`);
1138
- // ============================================================================
1139
- // Set Permanent Lock (lockedUntil = NULL)
1140
- // ============================================================================
1141
- // Use update() to ensure persistence and avoid entity state issues
1142
- await this.userRepository.update({ id: user.id }, {
1143
- isLocked: true,
1144
- lockReason: dto.reason || 'Account disabled',
1145
- lockedAt: new Date(),
1146
- lockedUntil: null, // NULL = permanent lock (vs rate-limit's future date)
1147
- });
1148
- // Reload user to get updated entity with lock fields
1149
- const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
1150
- if (!updatedUser) {
1151
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found after update');
1152
- }
1153
- this.logger?.log?.(`User locked permanently: ${updatedUser.email} (sub: ${dto.sub})`);
1154
- // ============================================================================
1155
- // Revoke All Sessions (force logout)
1156
- // ============================================================================
1157
- let revokedCount = 0;
1158
- try {
1159
- revokedCount = await this.sessionService.revokeAllUserSessions(updatedUser.id, 'Account disabled');
1160
- this.logger?.debug?.(`Revoked ${revokedCount} sessions for user ${dto.sub}`);
1161
- }
1162
- catch (sessionError) {
1163
- // Non-blocking: Log but continue
1164
- const errorMessage = sessionError instanceof Error ? sessionError.message : 'Unknown error';
1165
- this.logger?.warn?.(`Failed to revoke sessions for user ${dto.sub}: ${errorMessage}`);
1166
- }
1167
- // ============================================================================
1168
- // Record Admin Action (ACCOUNT_DISABLED)
1169
- // ============================================================================
1170
- if (!this.auditService) {
1171
- this.logger?.warn?.(`Audit service not available - ACCOUNT_DISABLED event not recorded for user ${dto.sub}. Enable audit logs in config.auditLogs.enabled`);
1172
- }
1173
- else {
1174
- try {
1175
- // Get admin user ID from client info (the currently logged in user performing this action)
1176
- // This is extracted from the JWT token by interceptors/handlers
1177
- const adminUserId = clientInfo?.userId;
1178
- // Set performedBy to the admin's user ID (who locked the account)
1179
- // This identifies which admin user performed the action in the audit trail
1180
- const performedBy = adminUserId ? String(adminUserId) : clientInfo.ipAddress || 'system';
1181
- if (adminUserId) {
1182
- this.logger?.debug?.(`Admin user ID ${adminUserId} (currently logged in) is disabling account for user ${dto.sub}`);
1183
- }
1184
- else {
1185
- this.logger?.warn?.(`No admin user ID in clientInfo - performedBy will be set to IP address or 'system' for user ${dto.sub}`);
1186
- }
1187
- const auditResult = await this.auditService.recordEvent({
1188
- userId: updatedUser.id, // The user whose account is being disabled
1189
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.ACCOUNT_DISABLED,
1190
- eventStatus: 'INFO',
1191
- authMethod: 'admin',
1192
- performedBy, // The admin user ID (currently logged in user) who performed this action
1193
- reason: updatedUser.lockReason || 'Account disabled',
1194
- description: `Account disabled by administrator. User: ${updatedUser.email} (sub: ${dto.sub}). ${revokedCount} session(s) revoked.`,
1195
- metadata: {
1196
- email: updatedUser.email,
1197
- userSub: dto.sub,
1198
- reason: updatedUser.lockReason,
1199
- adminIdentifier: clientInfo.ipAddress || 'unknown',
1200
- adminUserId: adminUserId || null,
1201
- revokedSessions: revokedCount,
1202
- lockedAt: updatedUser.lockedAt,
1203
- lockedUntil: updatedUser.lockedUntil,
1204
- },
1205
- });
1206
- if (auditResult) {
1207
- this.logger?.debug?.(`ACCOUNT_DISABLED audit event recorded successfully for user ${dto.sub}`);
1208
- }
1209
- else {
1210
- this.logger?.warn?.(`ACCOUNT_DISABLED audit event returned null for user ${dto.sub}`);
1211
- }
1212
- }
1213
- catch (auditError) {
1214
- // Non-blocking: Log but continue
1215
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
1216
- const errorStack = auditError instanceof Error ? auditError.stack : undefined;
1217
- this.logger?.error?.(`Failed to record ACCOUNT_DISABLED audit event: ${errorMessage}`, {
1218
- error: auditError,
1219
- errorStack,
1220
- userId: updatedUser.id,
1221
- userSub: dto.sub,
1222
- });
1223
- }
1224
- }
1225
- // Return sanitized user and revoked session count
1226
- const userDto = user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
1227
- return {
1228
- success: true,
1229
- user: userDto,
1230
- revokedSessions: revokedCount,
1231
- };
915
+ return await this.userService.disableUser(dto);
1232
916
  }
1233
917
  /**
1234
918
  * Enable (unlock) user account
@@ -1255,94 +939,7 @@ class AuthService {
1255
939
  * ```
1256
940
  */
1257
941
  async enableUser(dto) {
1258
- // Ensure DTO is validated
1259
- dto = await (0, dto_validator_1.ensureValidatedDto)(enable_user_dto_1.EnableUserDTO, dto);
1260
- // Get client info for audit
1261
- const clientInfo = this.clientInfoService.get();
1262
- this.logger?.log?.(`Admin enableUser initiated for sub: ${dto.sub}`);
1263
- // Find user by sub
1264
- const user = await this.userRepository.findOne({ where: { sub: dto.sub } });
1265
- if (!user) {
1266
- this.logger?.warn?.(`User not found for enabling: ${dto.sub}`);
1267
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found');
1268
- }
1269
- this.logger?.debug?.(`Enabling user ${user.email} (id: ${user.id}, sub: ${dto.sub})`);
1270
- // ============================================================================
1271
- // Clear Lock Fields (unlock account)
1272
- // ============================================================================
1273
- await this.userRepository.update({ id: user.id }, {
1274
- isLocked: false,
1275
- lockReason: null,
1276
- lockedAt: null,
1277
- lockedUntil: null,
1278
- failedLoginAttempts: 0, // Reset failed attempts counter
1279
- });
1280
- // Reload user to get updated entity
1281
- const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
1282
- if (!updatedUser) {
1283
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.USER_NOT_FOUND, 'User not found after update');
1284
- }
1285
- this.logger?.log?.(`User unlocked: ${updatedUser.email} (sub: ${dto.sub})`);
1286
- // ============================================================================
1287
- // Record Admin Action (ACCOUNT_ENABLED)
1288
- // ============================================================================
1289
- if (!this.auditService) {
1290
- this.logger?.warn?.(`Audit service not available - ACCOUNT_ENABLED event not recorded for user ${dto.sub}. Enable audit logs in config.auditLogs.enabled`);
1291
- }
1292
- else {
1293
- try {
1294
- // Get admin user ID from client info (the currently logged in user performing this action)
1295
- const adminUserId = clientInfo?.userId;
1296
- // Set performedBy to the admin's user ID (who unlocked the account)
1297
- const performedBy = adminUserId ? String(adminUserId) : clientInfo.ipAddress || 'system';
1298
- if (adminUserId) {
1299
- this.logger?.debug?.(`Admin user ID ${adminUserId} (currently logged in) is enabling account for user ${dto.sub}`);
1300
- }
1301
- else {
1302
- this.logger?.warn?.(`No admin user ID in clientInfo - performedBy will be set to IP address or 'system' for user ${dto.sub}`);
1303
- }
1304
- const auditResult = await this.auditService.recordEvent({
1305
- userId: updatedUser.id,
1306
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.ACCOUNT_ENABLED,
1307
- eventStatus: 'INFO',
1308
- authMethod: 'admin',
1309
- performedBy,
1310
- reason: 'admin_unlock',
1311
- description: 'Account unlocked by administrator',
1312
- metadata: {
1313
- userSub: dto.sub,
1314
- adminIdentifier: clientInfo.ipAddress || 'unknown',
1315
- adminUserId: adminUserId || null,
1316
- previousLockReason: user.lockReason,
1317
- previousLockedAt: user.lockedAt,
1318
- previousLockedUntil: user.lockedUntil,
1319
- },
1320
- });
1321
- if (auditResult) {
1322
- this.logger?.debug?.(`ACCOUNT_ENABLED audit event recorded successfully for user ${dto.sub}`);
1323
- }
1324
- else {
1325
- this.logger?.warn?.(`ACCOUNT_ENABLED audit event returned null for user ${dto.sub}`);
1326
- }
1327
- }
1328
- catch (auditError) {
1329
- // Non-blocking: Log but continue
1330
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
1331
- const errorStack = auditError instanceof Error ? auditError.stack : undefined;
1332
- this.logger?.error?.(`Failed to record ACCOUNT_ENABLED audit event: ${errorMessage}`, {
1333
- error: auditError,
1334
- errorStack,
1335
- userId: updatedUser.id,
1336
- userSub: dto.sub,
1337
- });
1338
- }
1339
- }
1340
- // Return sanitized user
1341
- const userDto = user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
1342
- return {
1343
- success: true,
1344
- user: userDto,
1345
- };
942
+ return await this.userService.enableUser(dto);
1346
943
  }
1347
944
  // ============================================================================
1348
945
  // User Login
@@ -1382,7 +979,7 @@ class AuthService {
1382
979
  const isLocked = await this.accountLockoutStorage.isAccountLocked(ipAddress);
1383
980
  if (isLocked) {
1384
981
  this.logger?.warn?.(`Login blocked - IP locked: ${ipAddress}`);
1385
- await this.recordLoginAttempt(dto.identifier, false, 'ip_locked');
982
+ await this.helpers.recordLoginAttempt(dto.identifier, false, 'ip_locked');
1386
983
  // ============================================================================
1387
984
  // Audit: Record blocked login (IP locked)
1388
985
  // ============================================================================
@@ -1432,16 +1029,16 @@ class AuthService {
1432
1029
  const identifierType = this.config.login?.identifierType;
1433
1030
  if (identifierType) {
1434
1031
  this.logger?.debug?.(`Validating identifier type for: ${dto.identifier}, allowed type: ${identifierType}`);
1435
- const isValidIdentifier = this.validateIdentifierType(dto.identifier, identifierType);
1032
+ const isValidIdentifier = this.helpers.validateIdentifierType(dto.identifier, identifierType);
1436
1033
  if (!isValidIdentifier) {
1437
1034
  this.logger?.warn?.(`Login rejected - identifier type mismatch. Identifier: ${dto.identifier}, Required: ${identifierType}`);
1438
- await this.handleFailedLogin(dto.identifier, 'identifier_type_mismatch');
1035
+ await this.helpers.handleFailedLogin(dto.identifier, 'identifier_type_mismatch');
1439
1036
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INVALID_CREDENTIALS, `Login with this identifier type is not allowed. Expected: ${identifierType}`);
1440
1037
  }
1441
1038
  }
1442
1039
  // Find user by email, username, or phone (filtered by identifierType config)
1443
1040
  this.logger?.debug?.(`Finding user by identifier: ${dto.identifier}`);
1444
- const user = await this.findUserByIdentifier(dto.identifier, identifierType);
1041
+ const user = await this.helpers.findUserByIdentifier(dto.identifier, identifierType);
1445
1042
  // SECURITY CRITICAL: Always hash password even when user doesn't exist
1446
1043
  // This ensures constant-time response to prevent user enumeration via timing attacks
1447
1044
  const hashToVerify = user?.passwordHash || DUMMY_ARGON2_HASH;
@@ -1451,7 +1048,7 @@ class AuthService {
1451
1048
  // Now check all conditions AFTER password verification (constant time achieved)
1452
1049
  if (!user || !user.passwordHash || !isPasswordValid) {
1453
1050
  this.logger?.warn?.(`Login failed - invalid credentials for: ${dto.identifier}`);
1454
- await this.handleFailedLogin(dto.identifier, 'invalid_credentials');
1051
+ await this.helpers.handleFailedLogin(dto.identifier, 'invalid_credentials');
1455
1052
  // ============================================================================
1456
1053
  // Audit: Record failed login
1457
1054
  // ============================================================================
@@ -1517,7 +1114,7 @@ class AuthService {
1517
1114
  const lockReason = user.lockReason || 'Account is locked';
1518
1115
  this.logger?.warn?.(`Login blocked - account locked for user: ${user.email} (sub: ${user.sub}). Reason: ${lockReason}`);
1519
1116
  // Record blocked login attempt
1520
- await this.recordLoginAttempt(dto.identifier, false, 'account_locked');
1117
+ await this.helpers.recordLoginAttempt(dto.identifier, false, 'account_locked');
1521
1118
  // ============================================================================
1522
1119
  // Audit: Record blocked login (account locked)
1523
1120
  // ============================================================================
@@ -1660,7 +1257,7 @@ class AuthService {
1660
1257
  [auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED]: 'mfa_required',
1661
1258
  };
1662
1259
  this.logger?.warn?.(`Login blocked - pending challenge: ${response.challengeName} for ${dto.identifier} (sub: ${user.sub})`);
1663
- 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);
1664
1261
  return response;
1665
1262
  }
1666
1263
  // If response already has tokens (session was created by challenge helper), return it
@@ -1668,7 +1265,7 @@ class AuthService {
1668
1265
  if (response.accessToken && response.refreshToken) {
1669
1266
  this.logger?.debug?.(`Login successful - session already created by challenge helper for ${dto.identifier} (sub: ${user.sub})`);
1670
1267
  // Record successful login attempt
1671
- await this.recordLoginAttempt(dto.identifier, true, undefined, user.id);
1268
+ await this.helpers.recordLoginAttempt(dto.identifier, true, undefined, user.id);
1672
1269
  this.logger?.log?.(`Login successful for: ${dto.identifier} (sub: ${user.sub}) from ${clientInfo.ipAddress}`);
1673
1270
  // Update user last login info
1674
1271
  await this.userRepository.update(user.id, {
@@ -1802,7 +1399,7 @@ class AuthService {
1802
1399
  // Check if user is active (should never happen with new signups, but keep for legacy accounts)
1803
1400
  if (!user.isActive) {
1804
1401
  this.logger?.warn?.(`Login failed - account inactive: ${dto.identifier} (sub: ${user.sub})`);
1805
- await this.recordLoginAttempt(dto.identifier, false, 'account_inactive', user.id);
1402
+ await this.helpers.recordLoginAttempt(dto.identifier, false, 'account_inactive', user.id);
1806
1403
  // ============================================================================
1807
1404
  // Audit: Record blocked login (account inactive)
1808
1405
  // ============================================================================
@@ -1890,7 +1487,7 @@ class AuthService {
1890
1487
  failedLoginAttempts: 0,
1891
1488
  });
1892
1489
  // Record successful login attempt - use internal id
1893
- await this.recordLoginAttempt(dto.identifier, true, undefined, user.id);
1490
+ await this.helpers.recordLoginAttempt(dto.identifier, true, undefined, user.id);
1894
1491
  this.logger?.log?.(`Login successful for: ${dto.identifier} (sub: ${user.sub}) from ${clientInfo.ipAddress}`);
1895
1492
  // ============================================================================
1896
1493
  // Audit: Record successful login with trusted device and MFA bypass metadata
@@ -2022,896 +1619,140 @@ class AuthService {
2022
1619
  // Validate session and get challenge type
2023
1620
  const challengeSession = await this.challengeService.validateSession(session);
2024
1621
  // Validate response matches expected challenge
2025
- this.validateChallengeTypeMatch(challengeSession.challengeName, type);
1622
+ this.helpers.validateChallengeTypeMatch(challengeSession.challengeName, type);
2026
1623
  // Validate parameters for this challenge type
2027
1624
  // TODO: Later check if we can use classvalidator to replicate the logic of DTO validation centrally
2028
- this.validateChallengeParams(type, responseData);
1625
+ this.helpers.validateChallengeParams(type, responseData);
2029
1626
  // Handle challenge based on type
2030
1627
  switch (type) {
2031
1628
  case 'VERIFY_EMAIL':
2032
- return await this.handleVerifyEmail(challengeSession, responseData.code);
1629
+ return await this.helpers.handleVerifyEmail(challengeSession, responseData.code);
2033
1630
  case 'VERIFY_PHONE':
2034
- return await this.handleVerifyPhone(challengeSession, responseData);
1631
+ return await this.helpers.handleVerifyPhone(challengeSession, responseData);
2035
1632
  case 'MFA_REQUIRED':
2036
- return await this.handleMFAVerification(challengeSession, responseData);
1633
+ return await this.helpers.handleMFAVerification(challengeSession, responseData, this.mfaService, this.trustedDeviceService, this.auditService);
2037
1634
  case 'FORCE_CHANGE_PASSWORD':
2038
- return await this.handleForceChangePassword(challengeSession, responseData.newPassword);
1635
+ return await this.helpers.handleForceChangePassword(challengeSession, responseData.newPassword, this.passwordService, this.auditService);
2039
1636
  case 'MFA_SETUP_REQUIRED':
2040
- return await this.handleMFASetup(challengeSession, responseData);
1637
+ return await this.helpers.handleMFASetup(challengeSession, responseData, this.mfaService, this.auditService);
2041
1638
  default:
2042
1639
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Unknown challenge type: ${type}`);
2043
1640
  }
2044
1641
  }
2045
1642
  /**
2046
- * Validate that response type matches expected challenge type
2047
- */
2048
- validateChallengeTypeMatch(expected, provided) {
2049
- if (expected !== provided) {
2050
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Challenge type mismatch: expected ${expected}, got ${provided}`);
2051
- }
2052
- }
2053
- /**
2054
- * 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
2055
1655
  *
2056
- * Service-level validation ensures Express/other frameworks get same validation as NestJS.
2057
- * 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
+ * ```
2058
1661
  */
2059
- validateChallengeParams(type, data) {
2060
- switch (type) {
2061
- case 'VERIFY_EMAIL': {
2062
- const response = data;
2063
- if (!response.code || typeof response.code !== 'string') {
2064
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Verification code is required', { field: 'code' });
2065
- }
2066
- 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 };
2067
1686
  }
2068
- case 'VERIFY_PHONE': {
2069
- const response = data;
2070
- const hasCode = 'code' in response && response.code;
2071
- const hasPhone = 'phone' in response && response.phone;
2072
- if (!hasCode && !hasPhone) {
2073
- 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');
2074
1694
  }
2075
- 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 };
2076
1701
  }
2077
- case 'MFA_REQUIRED': {
2078
- const response = data;
2079
- if (!response.method) {
2080
- 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');
2081
1710
  }
2082
- if (response.method === 'passkey') {
2083
- const passkeyResponse = response;
2084
- if (!passkeyResponse.credential) {
2085
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Passkey credential is required', {
2086
- 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
2087
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 };
2088
1725
  }
2089
- }
2090
- else {
2091
- const codeResponse = response;
2092
- if (!codeResponse.code || typeof codeResponse.code !== 'string') {
2093
- 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 };
2094
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 };
2095
1749
  }
2096
- break;
2097
- }
2098
- case 'FORCE_CHANGE_PASSWORD': {
2099
- const response = data;
2100
- if (!response.newPassword || typeof response.newPassword !== 'string') {
2101
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'New password is required', {
2102
- field: 'newPassword',
2103
- });
2104
- }
2105
- break;
2106
- }
2107
- case 'MFA_SETUP_REQUIRED': {
2108
- const response = data;
2109
- if (!response.method) {
2110
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA setup method is required', {
2111
- field: 'method',
2112
- });
2113
- }
2114
- if (!response.setupData || typeof response.setupData !== 'object') {
2115
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA setup data is required', {
2116
- field: 'setupData',
2117
- });
2118
- }
2119
- 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.`);
2120
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}'`);
2121
1754
  }
2122
1755
  }
2123
- /**
2124
- * Handle VERIFY_EMAIL challenge
2125
- */
2126
- async handleVerifyEmail(challengeSession, code) {
2127
- const user = challengeSession.user;
2128
- if (!user) {
2129
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
2130
- }
2131
- this.logger?.log?.(`Verifying email for user: ${user.sub}`);
2132
- // Verify email with code, ensuring it belongs to this specific challenge session
2133
- const verifyDto = Object.assign(new verify_email_dto_1.VerifyEmailWithCodeDTO(), {
2134
- email: user.email,
2135
- code,
2136
- challengeSessionId: challengeSession.id, // Link verification to this specific session
2137
- });
2138
- const result = await this.emailVerificationService.verifyEmailWithCode(verifyDto);
2139
- const isVerified = result.message === 'Email verified successfully. Please log in to continue.';
2140
- if (!isVerified) {
2141
- // Increment attempts but don't consume session
2142
- await this.challengeService.incrementAttempts(challengeSession);
2143
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
2144
- }
2145
- // Consume challenge session
2146
- await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.VERIFY_EMAIL);
2147
- // Reload user to get updated emailVerified flag
2148
- const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
2149
- if (!updatedUser) {
2150
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after email verification');
2151
- }
2152
- // Get client info
2153
- const clientInfo = this.clientInfoService.get();
2154
- // Read auth context from challenge session metadata
2155
- const authMethod = challengeSession.metadata?.authMethod || 'password';
2156
- const authProvider = challengeSession.metadata?.authProvider;
2157
- const isSocialLogin = authMethod === 'social';
2158
- // Check for next challenges
2159
- const response = await this.challengeHelper.determineAuthResponse({
2160
- user: updatedUser,
2161
- config: this.config,
2162
- deviceToken: clientInfo.deviceToken,
2163
- isSocialLogin,
2164
- skipMFAVerification: false,
2165
- authProvider,
2166
- });
2167
- if (response.challengeName) {
2168
- this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
2169
- }
2170
- else {
2171
- this.logger?.log?.(`Email verified, auth completed for: ${user.email}`);
2172
- }
2173
- return response;
2174
- }
2175
- /**
2176
- * Handle VERIFY_PHONE challenge
2177
- */
2178
- async handleVerifyPhone(challengeSession, data) {
2179
- const user = challengeSession.user;
2180
- if (!user) {
2181
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
2182
- }
2183
- // Check if this is phone collection (first step) or verification (second step)
2184
- if ('phone' in data && data.phone) {
2185
- // Phone collection step
2186
- const phone = data.phone;
2187
- this.logger?.log?.(`Collecting phone number for user: ${user.sub}`);
2188
- // Validate phone format (E.164 format: +[country][number])
2189
- const phoneRegex = /^\+[1-9]\d{1,14}$/;
2190
- if (!phoneRegex.test(phone)) {
2191
- 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)');
2192
- }
2193
- // Update user phone number
2194
- await this.userRepository.update({ sub: user.sub }, { phone });
2195
- this.logger?.log?.(`Phone number added for user ${user.sub}: ${phone}`);
2196
- // Send verification SMS to the newly added phone
2197
- let smsError;
2198
- if (this.phoneVerificationService) {
2199
- this.logger?.log?.(`Sending verification SMS to newly added phone: ${phone}`);
2200
- try {
2201
- const smsDto = Object.assign(new verify_phone_dto_1.SendVerificationSMSDTO(), {
2202
- sub: user.sub,
2203
- skipAlreadyVerifiedCheck: false, // Explicitly set to false for phone verification (not MFA)
2204
- challengeSessionId: challengeSession.id, // Link SMS code to this challenge session
2205
- });
2206
- await this.phoneVerificationService.sendVerificationSMS(smsDto);
2207
- this.logger?.log?.(`Verification SMS sent successfully to: ${phone}`);
2208
- }
2209
- catch (error) {
2210
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
2211
- this.logger?.error?.(`Failed to send verification SMS to ${phone}: ${errorMessage}`);
2212
- smsError = errorMessage;
2213
- }
2214
- }
2215
- else {
2216
- this.logger?.warn?.(`Phone verification SMS not sent - PhoneVerificationService not available. ` +
2217
- 'Phone verification requires an SMS provider to be configured.');
2218
- }
2219
- // DO NOT consume the challenge session yet - user still needs to verify the code
2220
- // Preserve auth context from original challenge session
2221
- const authMethod = challengeSession.metadata?.authMethod || 'password';
2222
- const authProvider = challengeSession.metadata?.authProvider;
2223
- // Return same challenge with updated phone in parameters
2224
- // Skip auto-send since SMS was already sent above during phone collection
2225
- const challengeResponse = await this.challengeHelper.createChallengeResponse({ ...user, phone }, auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE, this.config, authMethod, authProvider, true);
2226
- // Include SMS error in challenge parameters if SMS failed
2227
- if (smsError) {
2228
- challengeResponse.challengeParameters = challengeResponse.challengeParameters || {};
2229
- challengeResponse.challengeParameters.smsError = smsError;
2230
- }
2231
- return challengeResponse;
2232
- }
2233
- else {
2234
- // Phone verification step (code provided)
2235
- const code = data.code;
2236
- this.logger?.log?.(`Verifying phone for user: ${user.sub}`);
2237
- // Check if phone is set
2238
- if (!user.phone) {
2239
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Phone number not yet provided. Submit phone number first.');
2240
- }
2241
- // Verify phone with code, ensuring it belongs to this specific challenge session
2242
- const verifyDto = Object.assign(new verify_phone_by_sub_dto_1.VerifyPhoneWithCodeBySubDTO(), {
2243
- sub: user.sub,
2244
- code,
2245
- challengeSessionId: challengeSession.id, // Link verification to this specific session
2246
- });
2247
- const result = await this.phoneVerificationService.verifyPhoneWithCodeBySub(verifyDto);
2248
- const isVerified = result.message === 'Phone verified successfully. Please log in to continue.';
2249
- if (!isVerified) {
2250
- // Increment attempts but don't consume session
2251
- await this.challengeService.incrementAttempts(challengeSession);
2252
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
2253
- }
2254
- // Consume challenge session
2255
- await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE);
2256
- // Reload user to get updated phoneVerified flag
2257
- const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
2258
- if (!updatedUser) {
2259
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after phone verification');
2260
- }
2261
- // Get client info
2262
- const clientInfo = this.clientInfoService.get();
2263
- // Read auth context from challenge session metadata
2264
- const authMethod = challengeSession.metadata?.authMethod || 'password';
2265
- const authProvider = challengeSession.metadata?.authProvider;
2266
- const isSocialLogin = authMethod === 'social';
2267
- // Check for next challenges
2268
- const response = await this.challengeHelper.determineAuthResponse({
2269
- user: updatedUser,
2270
- config: this.config,
2271
- deviceToken: clientInfo.deviceToken,
2272
- isSocialLogin,
2273
- skipMFAVerification: false,
2274
- authProvider,
2275
- });
2276
- if (response.challengeName) {
2277
- this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
2278
- }
2279
- else {
2280
- this.logger?.log?.(`Phone verified, auth completed for: ${user.email}`);
2281
- // ============================================================================
2282
- // Audit: Record successful login after phone verification
2283
- // ============================================================================
2284
- const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
2285
- if (fireAndForget) {
2286
- this.auditService
2287
- ?.recordEvent({
2288
- userId: user.id,
2289
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
2290
- eventStatus: 'SUCCESS',
2291
- authMethod: isSocialLogin ? authProvider || 'social' : 'password',
2292
- metadata: {
2293
- completedAfterPhoneVerification: true,
2294
- },
2295
- })
2296
- .catch((err) => {
2297
- const errorMessage = err instanceof Error ? err.message : 'Unknown error';
2298
- this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after phone verification (fire-and-forget): ${errorMessage}`, {
2299
- error: err,
2300
- userId: user.id,
2301
- userSub: user.sub,
2302
- });
2303
- });
2304
- }
2305
- else {
2306
- try {
2307
- await this.auditService?.recordEvent({
2308
- userId: user.id,
2309
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
2310
- eventStatus: 'SUCCESS',
2311
- authMethod: isSocialLogin ? authProvider || 'social' : 'password',
2312
- metadata: {
2313
- completedAfterPhoneVerification: true,
2314
- },
2315
- });
2316
- }
2317
- catch (auditError) {
2318
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2319
- this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after phone verification: ${errorMessage}`, {
2320
- error: auditError,
2321
- userId: user.id,
2322
- });
2323
- }
2324
- }
2325
- }
2326
- return response;
2327
- }
2328
- }
2329
- /**
2330
- * Handle MFA_REQUIRED challenge
2331
- */
2332
- async handleMFAVerification(challengeSession, data) {
2333
- const user = challengeSession.user;
2334
- if (!user) {
2335
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
2336
- }
2337
- const method = data.method;
2338
- this.logger?.log?.(`MFA verification attempt: method=${method}, user=${user.sub}`);
2339
- // Check if MFAService is available
2340
- if (!this.mfaService) {
2341
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
2342
- }
2343
- // Get client info
2344
- const clientInfo = this.clientInfoService.get();
2345
- // Verify MFA based on method
2346
- let isValid = false;
2347
- if (method === 'passkey') {
2348
- const passkeyData = data;
2349
- const credential = passkeyData.credential;
2350
- // Get expected challenge from session metadata
2351
- const expectedChallenge = challengeSession.metadata?.passkeyChallenge;
2352
- if (!expectedChallenge) {
2353
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'No passkey challenge found in session');
2354
- }
2355
- // Verify passkey via MFAService
2356
- const wrappedCredential = { credential, expectedChallenge };
2357
- const verifyResult = await this.mfaService.verifyCode({
2358
- sub: user.sub,
2359
- methodName: mfa_method_enum_1.MFAMethod.PASSKEY,
2360
- code: wrappedCredential,
2361
- });
2362
- isValid = verifyResult.valid;
2363
- }
2364
- else {
2365
- const codeData = data;
2366
- const code = codeData.code;
2367
- // Verify code via MFAService (handles totp, sms, and backup)
2368
- const verifyResult = await this.mfaService.verifyCode({
2369
- sub: user.sub,
2370
- methodName: method,
2371
- code,
2372
- });
2373
- isValid = verifyResult.valid;
2374
- }
2375
- if (!isValid) {
2376
- this.logger?.warn?.(`MFA verification failed for user: ${user.sub}`);
2377
- // Audit: Record MFA verification failure
2378
- if (this.config.auditLogs?.fireAndForget) {
2379
- this.auditService
2380
- ?.recordEvent({
2381
- userId: user.id,
2382
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_FAILED,
2383
- eventStatus: 'FAILURE',
2384
- challengeSessionId: challengeSession.id,
2385
- authMethod: method,
2386
- metadata: { mfaMethod: method },
2387
- })
2388
- .catch((err) => {
2389
- const errorMessage = err instanceof Error ? err.message : 'Unknown error';
2390
- this.logger?.error?.(`Failed to record MFA_VERIFICATION_FAILED audit event (fire-and-forget): ${errorMessage}`, {
2391
- error: err,
2392
- userId: user.id,
2393
- userSub: user.sub,
2394
- });
2395
- });
2396
- }
2397
- else {
2398
- try {
2399
- await this.auditService?.recordEvent({
2400
- userId: user.id,
2401
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_FAILED,
2402
- eventStatus: 'FAILURE',
2403
- challengeSessionId: challengeSession.id,
2404
- authMethod: method,
2405
- metadata: { mfaMethod: method },
2406
- });
2407
- }
2408
- catch (auditError) {
2409
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2410
- this.logger?.error?.(`Failed to record MFA_VERIFICATION_FAILED audit event: ${errorMessage}`, {
2411
- error: auditError,
2412
- userId: user.id,
2413
- });
2414
- }
2415
- }
2416
- // Increment challenge attempts (session not consumed, so user can retry)
2417
- await this.challengeService.incrementAttempts(challengeSession);
2418
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid MFA code');
2419
- }
2420
- this.logger?.log?.(`MFA verified successfully for user: ${user.sub}`);
2421
- // Audit: Record MFA verification success
2422
- if (this.config.auditLogs?.fireAndForget) {
2423
- this.auditService
2424
- ?.recordEvent({
2425
- userId: user.id,
2426
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
2427
- eventStatus: 'SUCCESS',
2428
- challengeSessionId: challengeSession.id,
2429
- authMethod: method,
2430
- metadata: { mfaMethod: method },
2431
- })
2432
- .catch((err) => {
2433
- const errorMessage = err instanceof Error ? err.message : 'Unknown error';
2434
- this.logger?.error?.(`Failed to record MFA_VERIFICATION_SUCCESS audit event (fire-and-forget): ${errorMessage}`, {
2435
- error: err,
2436
- userId: user.id,
2437
- userSub: user.sub,
2438
- });
2439
- });
2440
- }
2441
- else {
2442
- try {
2443
- await this.auditService?.recordEvent({
2444
- userId: user.id,
2445
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
2446
- eventStatus: 'SUCCESS',
2447
- challengeSessionId: challengeSession.id,
2448
- authMethod: method,
2449
- metadata: { mfaMethod: method },
2450
- });
2451
- }
2452
- catch (auditError) {
2453
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2454
- this.logger?.error?.(`Failed to record MFA_VERIFICATION_SUCCESS audit event: ${errorMessage}`, {
2455
- error: auditError,
2456
- userId: user.id,
2457
- });
2458
- }
2459
- }
2460
- // Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
2461
- await this.challengeService.updateMetadata(challengeSession.sessionToken, {
2462
- mfaMethod: method,
2463
- });
2464
- // Only consume the session AFTER successful verification
2465
- await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED);
2466
- // Read auth context from challenge session metadata
2467
- const authMethod = challengeSession.metadata?.authMethod || 'password';
2468
- const authProvider = challengeSession.metadata?.authProvider;
2469
- const isSocialLogin = authMethod === 'social';
2470
- // ============================================================================
2471
- // Trusted Device Token Management (Remember Device Feature)
2472
- // ============================================================================
2473
- // NOTE:
2474
- // - We only create / update trusted device tokens AFTER MFA has been successfully
2475
- // verified to avoid trusting devices that haven't completed full auth.
2476
- // - For 'always' mode, this mirrors the behavior in the primary login flow.
2477
- let deviceToken = clientInfo.deviceToken;
2478
- let isTrustedDevice = false;
2479
- if (this.trustedDeviceService && this.config.mfa?.rememberDevices && this.config.mfa.rememberDevices !== 'never') {
2480
- const rememberMode = this.config.mfa.rememberDevices;
2481
- // If a device token is already present, check if it's trusted
2482
- if (deviceToken) {
2483
- try {
2484
- isTrustedDevice = await this.trustedDeviceService.isDeviceTrusted(deviceToken, user.id);
2485
- if (isTrustedDevice) {
2486
- this.logger?.debug?.(`MFA flow: existing trusted device token detected for user ${user.sub} (token reused)`);
2487
- }
2488
- }
2489
- catch (error) {
2490
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
2491
- this.logger?.warn?.(`MFA flow: failed to validate existing trusted device token for user ${user.sub}: ${errorMessage}`, { error });
2492
- }
2493
- }
2494
- // Auto-trust mode: create device token automatically if not already trusted
2495
- if (rememberMode === 'always' && !isTrustedDevice) {
2496
- try {
2497
- deviceToken = await this.trustedDeviceService.createTrustedDevice(user.id, clientInfo.deviceName, clientInfo.deviceType, clientInfo.ipAddress, clientInfo.userAgent, clientInfo.platform, clientInfo.browser);
2498
- isTrustedDevice = true;
2499
- this.logger?.debug?.(`MFA flow: auto-created trusted device token for user ${user.sub} (rememberDevices='always')`);
2500
- }
2501
- catch (error) {
2502
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
2503
- this.logger?.warn?.(`MFA flow: failed to create trusted device token for user ${user.sub}: ${errorMessage}`, {
2504
- error,
2505
- });
2506
- }
2507
- }
2508
- }
2509
- // Check for next challenges (MFA is usually the last challenge)
2510
- const response = await this.challengeHelper.determineAuthResponse({
2511
- user,
2512
- config: this.config,
2513
- deviceToken,
2514
- isSocialLogin,
2515
- skipMFAVerification: true, // Already verified
2516
- authProvider,
2517
- });
2518
- // Propagate trusted device metadata into response so that:
2519
- // - CookieTokenInterceptor can set the nauth_device_token cookie (cookies mode)
2520
- // - Mobile clients in JSON mode can store the device token securely
2521
- if (isTrustedDevice) {
2522
- response.trusted = response.trusted ?? true;
2523
- }
2524
- if (deviceToken && !response.deviceToken) {
2525
- response.deviceToken = deviceToken;
2526
- }
2527
- if (response.challengeName) {
2528
- this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
2529
- }
2530
- else {
2531
- this.logger?.log?.(`MFA verified, auth completed for: ${user.email}`);
2532
- // ============================================================================
2533
- // Audit: Record successful login after MFA completion
2534
- // ============================================================================
2535
- const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
2536
- if (fireAndForget) {
2537
- this.auditService
2538
- ?.recordEvent({
2539
- userId: user.id,
2540
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
2541
- eventStatus: 'SUCCESS',
2542
- authMethod: isSocialLogin ? authProvider || 'social' : 'password',
2543
- metadata: {
2544
- completedAfterMFA: true,
2545
- },
2546
- })
2547
- .catch((err) => {
2548
- const errorMessage = err instanceof Error ? err.message : 'Unknown error';
2549
- this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA (fire-and-forget): ${errorMessage}`, {
2550
- error: err,
2551
- userId: user.id,
2552
- userSub: user.sub,
2553
- });
2554
- });
2555
- }
2556
- else {
2557
- try {
2558
- await this.auditService?.recordEvent({
2559
- userId: user.id,
2560
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
2561
- eventStatus: 'SUCCESS',
2562
- authMethod: isSocialLogin ? authProvider || 'social' : 'password',
2563
- metadata: {
2564
- completedAfterMFA: true,
2565
- },
2566
- });
2567
- }
2568
- catch (auditError) {
2569
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2570
- this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA: ${errorMessage}`, {
2571
- error: auditError,
2572
- userId: user.id,
2573
- });
2574
- }
2575
- }
2576
- }
2577
- return response;
2578
- }
2579
- /**
2580
- * Handle FORCE_CHANGE_PASSWORD challenge
2581
- */
2582
- async handleForceChangePassword(challengeSession, newPassword) {
2583
- const user = challengeSession.user;
2584
- if (!user) {
2585
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
2586
- }
2587
- this.logger?.log?.(`Changing password for user: ${user.sub}`);
2588
- await this.updateUserPassword({
2589
- user,
2590
- newPassword,
2591
- mustChangePassword: false,
2592
- revokeSessions: true,
2593
- revokeReason: 'Password changed (force change password)',
2594
- audit: {
2595
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PASSWORD_CHANGED,
2596
- eventStatus: 'SUCCESS',
2597
- reason: 'force_change_password',
2598
- description: 'Password changed due to FORCE_CHANGE_PASSWORD challenge',
2599
- },
2600
- });
2601
- // Consume challenge session
2602
- await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.FORCE_CHANGE_PASSWORD);
2603
- // Reload user from database to get updated mustChangePassword flag
2604
- const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
2605
- if (!updatedUser) {
2606
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after password update');
2607
- }
2608
- // Get client info
2609
- const clientInfo = this.clientInfoService.get();
2610
- // Read auth context from challenge session metadata
2611
- const authMethod = challengeSession.metadata?.authMethod || 'password';
2612
- const authProvider = challengeSession.metadata?.authProvider;
2613
- const isSocialLogin = authMethod === 'social';
2614
- // Check for next challenges
2615
- const response = await this.challengeHelper.determineAuthResponse({
2616
- user: updatedUser,
2617
- config: this.config,
2618
- deviceToken: clientInfo.deviceToken,
2619
- isSocialLogin,
2620
- skipMFAVerification: false,
2621
- authProvider,
2622
- });
2623
- if (response.challengeName) {
2624
- this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
2625
- }
2626
- else {
2627
- this.logger?.log?.(`Password changed, auth completed for: ${user.email}`);
2628
- // ============================================================================
2629
- // Audit: Record successful login after password change
2630
- // ============================================================================
2631
- const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
2632
- if (fireAndForget) {
2633
- this.auditService
2634
- ?.recordEvent({
2635
- userId: user.id,
2636
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
2637
- eventStatus: 'SUCCESS',
2638
- authMethod: isSocialLogin ? authProvider || 'social' : 'password',
2639
- metadata: {
2640
- completedAfterPasswordChange: true,
2641
- },
2642
- })
2643
- .catch((err) => {
2644
- const errorMessage = err instanceof Error ? err.message : 'Unknown error';
2645
- this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after password change (fire-and-forget): ${errorMessage}`, {
2646
- error: err,
2647
- userId: user.id,
2648
- userSub: user.sub,
2649
- });
2650
- });
2651
- }
2652
- else {
2653
- try {
2654
- await this.auditService?.recordEvent({
2655
- userId: user.id,
2656
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
2657
- eventStatus: 'SUCCESS',
2658
- authMethod: isSocialLogin ? authProvider || 'social' : 'password',
2659
- metadata: {
2660
- completedAfterPasswordChange: true,
2661
- },
2662
- });
2663
- }
2664
- catch (auditError) {
2665
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2666
- this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after password change: ${errorMessage}`, {
2667
- error: auditError,
2668
- userId: user.id,
2669
- });
2670
- }
2671
- }
2672
- }
2673
- return response;
2674
- }
2675
- /**
2676
- * Handle MFA_SETUP_REQUIRED challenge
2677
- */
2678
- async handleMFASetup(challengeSession, data) {
2679
- const user = challengeSession.user;
2680
- if (!user) {
2681
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
2682
- }
2683
- const method = data.method;
2684
- const setupData = data.setupData;
2685
- const requestTrace = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
2686
- this.logger?.log?.(`[${requestTrace}] MFA setup attempt: method=${method}, user=${user.sub}`);
2687
- // Check if MFAService is available
2688
- if (!this.mfaService) {
2689
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
2690
- }
2691
- // Get provider
2692
- const provider = this.mfaService.getProvider(method);
2693
- // Verify setup based on method
2694
- let deviceId;
2695
- try {
2696
- deviceId = await provider.verifySetup(user, setupData);
2697
- this.logger?.log?.(`MFA device setup completed: method=${method}, deviceId=${deviceId}`);
2698
- }
2699
- catch (error) {
2700
- this.logger?.warn?.(`MFA setup verification failed: method=${method}, user=${user.sub}`);
2701
- // Increment attempts but don't consume session
2702
- await this.challengeService.incrementAttempts(challengeSession);
2703
- // Re-throw the error
2704
- throw error;
2705
- }
2706
- // Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
2707
- await this.challengeService.updateMetadata(challengeSession.sessionToken, {
2708
- mfaMethod: method,
2709
- });
2710
- // Consume challenge session
2711
- await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.MFA_SETUP_REQUIRED);
2712
- // Reload user from database to get updated mfaEnabled flag
2713
- const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
2714
- if (!updatedUser) {
2715
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after MFA setup');
2716
- }
2717
- // Get client info
2718
- const clientInfo = this.clientInfoService.get();
2719
- // Check for next challenges with updated user data
2720
- // Skip MFA verification because device was already verified during setup
2721
- const response = await this.challengeHelper.determineAuthResponse({
2722
- user: updatedUser,
2723
- config: this.config,
2724
- deviceToken: clientInfo.deviceToken,
2725
- isSocialLogin: false,
2726
- skipMFAVerification: true, // Device already verified during setup
2727
- });
2728
- if (response.challengeName) {
2729
- this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
2730
- }
2731
- else {
2732
- this.logger?.log?.(`MFA setup completed, auth completed for: ${user.email}`);
2733
- // ============================================================================
2734
- // Audit: Record successful login after MFA setup
2735
- // ============================================================================
2736
- const fireAndForget = this.config.auditLogs?.fireAndForget !== false;
2737
- if (fireAndForget) {
2738
- this.auditService
2739
- ?.recordEvent({
2740
- userId: user.id,
2741
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
2742
- eventStatus: 'SUCCESS',
2743
- authMethod: 'password',
2744
- metadata: {
2745
- completedAfterMFASetup: true,
2746
- },
2747
- })
2748
- .catch((err) => {
2749
- const errorMessage = err instanceof Error ? err.message : 'Unknown error';
2750
- this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA setup (fire-and-forget): ${errorMessage}`, {
2751
- error: err,
2752
- userId: user.id,
2753
- userSub: user.sub,
2754
- });
2755
- });
2756
- }
2757
- else {
2758
- try {
2759
- await this.auditService?.recordEvent({
2760
- userId: user.id,
2761
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.LOGIN_SUCCESS,
2762
- eventStatus: 'SUCCESS',
2763
- authMethod: 'password',
2764
- metadata: {
2765
- completedAfterMFASetup: true,
2766
- },
2767
- });
2768
- }
2769
- catch (auditError) {
2770
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2771
- this.logger?.error?.(`Failed to record LOGIN_SUCCESS audit event after MFA setup: ${errorMessage}`, {
2772
- error: auditError,
2773
- userId: user.id,
2774
- });
2775
- }
2776
- }
2777
- }
2778
- return response;
2779
- }
2780
- // ============================================================================
2781
- // Challenge Helper Methods
2782
- // ============================================================================
2783
- /**
2784
- * Resend verification code for current challenge
2785
- *
2786
- * Determines the challenge type from the session and resends the appropriate code:
2787
- * - VERIFY_EMAIL: Resends email verification code
2788
- * - VERIFY_PHONE: Resends SMS verification code
2789
- * - MFA_REQUIRED: Resends MFA code (for SMS MFA)
2790
- *
2791
- * Rate limits are enforced internally by the verification services.
2792
- *
2793
- * @param session - Challenge session token
2794
- * @returns Destination info (masked email/phone)
2795
- * @throws {NAuthException} INVALID_CHALLENGE_SESSION | RATE_LIMIT_* | VALIDATION_FAILED
2796
- *
2797
- * @example
2798
- * ```typescript
2799
- * const result = await authService.resendCode(session);
2800
- * // Returns: { destination: 'u***r@example.com' }
2801
- * ```
2802
- */
2803
- async resendCode(dto) {
2804
- // Ensure DTO is validated (supports direct usage without framework validation)
2805
- dto = await (0, dto_validator_1.ensureValidatedDto)(resend_code_dto_1.ResendCodeDTO, dto);
2806
- this.logger?.debug?.(`Resending verification code: session=${dto.session}`);
2807
- // Validate session (session must be valid to resend)
2808
- const challengeSession = await this.challengeService.validateSession(dto.session);
2809
- // Get user from session
2810
- const user = challengeSession.user;
2811
- if (!user) {
2812
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Challenge session has no associated user');
2813
- }
2814
- // Handle based on challenge type
2815
- switch (challengeSession.challengeName) {
2816
- case auth_challenge_dto_1.AuthChallenge.VERIFY_EMAIL: {
2817
- // Resend email verification
2818
- // Pass challengeSessionId to ensure new token is linked to this challenge session
2819
- const resendDto = Object.assign(new verify_email_dto_1.ResendVerificationEmailDTO(), {
2820
- sub: user.sub,
2821
- challengeSessionId: challengeSession.id,
2822
- });
2823
- await this.emailVerificationService.resendVerificationEmail(resendDto);
2824
- const maskedEmail = this.maskEmail(user.email);
2825
- this.logger?.debug?.(`Email verification code resent: user=${user.sub}, email=${maskedEmail}`);
2826
- return { destination: maskedEmail };
2827
- }
2828
- case auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE: {
2829
- // Check if phone already collected
2830
- if (!user.phone) {
2831
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Phone number not yet provided. Submit phone number first.');
2832
- }
2833
- if (!this.phoneVerificationService) {
2834
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'Phone verification service is not available');
2835
- }
2836
- // Resend SMS verification
2837
- const resendDto = Object.assign(new verify_phone_dto_1.ResendVerificationSMSDTO(), { sub: user.sub });
2838
- await this.phoneVerificationService.resendVerificationSMS(resendDto);
2839
- const maskedPhone = this.maskPhone(user.phone);
2840
- this.logger?.debug?.(`Phone verification code resent: user=${user.sub}, phone=${maskedPhone}`);
2841
- return { destination: maskedPhone };
2842
- }
2843
- case auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED: {
2844
- // For MFA, we need to know which method is being used
2845
- // Method is stored in metadata when challenge is created (see auth-challenge-helper.service.ts line 403)
2846
- // Note: challengeParameters is never populated - only metadata is used
2847
- const metadata = challengeSession.metadata;
2848
- const method = metadata?.method;
2849
- if (!method) {
2850
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Cannot resend MFA code: method not specified in session');
2851
- }
2852
- // SMS and Email MFA support resending codes
2853
- if (method === 'sms' || method === 'email') {
2854
- // For SMS, use phone verification service directly to pass challengeSessionId
2855
- if (method === 'sms' && this.phoneVerificationService) {
2856
- const smsDto = Object.assign(new verify_phone_dto_1.SendVerificationSMSDTO(), {
2857
- sub: user.sub,
2858
- skipAlreadyVerifiedCheck: true,
2859
- challengeSessionId: challengeSession.id, // Link resend code to this challenge session
2860
- });
2861
- await this.phoneVerificationService.sendVerificationSMS(smsDto);
2862
- this.logger?.debug?.(`SMS MFA code resent: user=${user.sub}`);
2863
- // Get masked phone from user or device
2864
- const maskedPhone = user.phone ? this.maskPhone(user.phone) : '***-***-****';
2865
- return { destination: maskedPhone };
2866
- }
2867
- // For Email, use email verification service directly to pass challengeSessionId
2868
- if (method === 'email' && this.emailVerificationService) {
2869
- const emailDto = Object.assign(new verify_email_dto_1.ResendVerificationEmailDTO(), {
2870
- sub: user.sub,
2871
- challengeSessionId: challengeSession.id, // Link resend code to this challenge session
2872
- });
2873
- await this.emailVerificationService.resendVerificationEmail(emailDto);
2874
- this.logger?.debug?.(`Email MFA code resent: user=${user.sub}`);
2875
- const maskedEmail = user.email ? this.maskEmail(user.email) : 'u***r@example.com';
2876
- return { destination: maskedEmail };
2877
- }
2878
- // Fallback to provider if services not available (shouldn't happen)
2879
- if (!this.mfaService) {
2880
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
2881
- }
2882
- const provider = this.mfaService.getProvider(method);
2883
- if (!provider.sendChallenge) {
2884
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `${method.toUpperCase()} MFA provider does not support sending challenges`);
2885
- }
2886
- const result = await provider.sendChallenge(user);
2887
- this.logger?.debug?.(`${method.toUpperCase()} MFA code resent: user=${user.sub}`);
2888
- // Provider returns masked phone or email
2889
- return { destination: result };
2890
- }
2891
- 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.`);
2892
- }
2893
- default:
2894
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Cannot resend code for challenge type '${challengeSession.challengeName}'`);
2895
- }
2896
- }
2897
- /**
2898
- * Mask email for display (helper method)
2899
- */
2900
- maskEmail(email) {
2901
- const [localPart, domain] = email.split('@');
2902
- if (localPart.length <= 2) {
2903
- return `${localPart[0]}***@${domain}`;
2904
- }
2905
- return `${localPart[0]}***${localPart[localPart.length - 1]}@${domain}`;
2906
- }
2907
- /**
2908
- * Mask phone number for display (helper method)
2909
- */
2910
- maskPhone(phone) {
2911
- const digits = phone.replace(/\D/g, '');
2912
- const lastFour = digits.slice(-4);
2913
- return `***-***-${lastFour}`;
2914
- }
2915
1756
  /**
2916
1757
  * Registers the current device as trusted for the user (opt-in).
2917
1758
  *
@@ -3289,13 +2130,39 @@ class AuthService {
3289
2130
  // Logout
3290
2131
  // ============================================================================
3291
2132
  /**
3292
- * Logout user (revoke session)
2133
+ * Logout user from current session
3293
2134
  *
3294
- * 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()
3295
2144
  *
3296
- * @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)
3297
2152
  * @returns Success status
3298
- * @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
+ * ```
3299
2166
  */
3300
2167
  async logout(dto) {
3301
2168
  // Ensure DTO is validated (supports direct usage without framework validation)
@@ -3388,44 +2255,50 @@ class AuthService {
3388
2255
  // ============================================================================
3389
2256
  const response = this.clientInfoService.getResponse();
3390
2257
  if (response && this.config.tokenDelivery?.method !== 'json') {
3391
- this.clearAuthCookies(response, dto.forgetMe ?? false);
2258
+ this.helpers.clearAuthCookies(response, dto.forgetMe ?? false);
3392
2259
  this.logger?.debug?.('Auth cookies cleared automatically on logout');
3393
2260
  }
3394
2261
  return { success: true };
3395
2262
  }
3396
- /**
3397
- * Clear authentication cookies from response
3398
- *
3399
- * @param response - HTTP response object with clearCookie method
3400
- * @param forgetDevice - Whether to also clear device token cookie
3401
- * @private
3402
- */
3403
- clearAuthCookies(response, forgetDevice) {
3404
- if (!response.clearCookie) {
3405
- return; // Response doesn't support cookie clearing (shouldn't happen)
3406
- }
3407
- const cookieOptions = this.config.tokenDelivery?.cookieOptions || {};
3408
- const prefix = this.config.tokenDelivery?.cookieNamePrefix || 'nauth';
3409
- // Clear access and refresh tokens
3410
- response.clearCookie(`${prefix}_access_token`, cookieOptions);
3411
- response.clearCookie(`${prefix}_refresh_token`, cookieOptions);
3412
- // Clear CSRF token cookie (httpOnly: false, so it can be cleared)
3413
- // Use the same cookie options but with httpOnly: false to match how it was set
3414
- const csrfCookieOptions = {
3415
- ...cookieOptions,
3416
- httpOnly: false, // CSRF token cookie is not httpOnly
3417
- };
3418
- const csrfCookieName = this.config.security?.csrf?.cookieName || `${prefix}_csrf_token`;
3419
- response.clearCookie(csrfCookieName, csrfCookieOptions);
3420
- // Clear device token if forgetting device
3421
- if (forgetDevice) {
3422
- response.clearCookie(`${prefix}_device_token`, cookieOptions);
3423
- }
3424
- }
3425
2263
  /**
3426
2264
  * Global signout (revoke all user sessions)
3427
- * @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
3428
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
+ * ```
3429
2302
  */
3430
2303
  async logoutAll(dto) {
3431
2304
  // Ensure DTO is validated (supports direct usage without framework validation)
@@ -3534,888 +2407,332 @@ class AuthService {
3534
2407
  if (response && this.config.tokenDelivery?.method !== 'json') {
3535
2408
  // Clear auth cookies
3536
2409
  // If forgetDevices is true, also clear device token cookie
3537
- this.clearAuthCookies(response, dto.forgetDevices ?? false);
2410
+ this.helpers.clearAuthCookies(response, dto.forgetDevices ?? false);
3538
2411
  this.logger?.debug?.('Auth cookies cleared automatically on global logout');
3539
2412
  }
3540
2413
  return { revokedCount };
3541
2414
  }
3542
- // ============================================================================
3543
- // Password Management
3544
- // ============================================================================
3545
2415
  /**
3546
- * Change the password for an existing user.
2416
+ * Get all active sessions for a user
3547
2417
  *
3548
- * Verifies the current password, validates the new password,
3549
- * checks password reuse policy, and updates the user's password hash and history.
3550
- * Executes configured pre-change hooks if provided.
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.
3551
2421
  *
3552
- * @param sub - External user identifier (sub/UUID)
3553
- * @param dto - ChangePasswordDTO containing old and new password
3554
- * @returns void
3555
- * @throws {NAuthException} If the user is not found, current password is incorrect, the new password is weak, password reuse is detected, or password change is disallowed by hooks.
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)
3556
2425
  *
3557
- * @example
3558
- * ```typescript
3559
- * await authService.changePassword('user-uuid', {
3560
- * oldPassword: 'currentPass123!',
3561
- * newPassword: 'newStr0ngPass!@#',
3562
- * });
3563
- * ```
3564
- */
3565
- async changePassword(dto) {
3566
- // Ensure DTO is validated (supports direct usage without framework validation)
3567
- dto = await (0, dto_validator_1.ensureValidatedDto)(change_password_request_dto_1.ChangePasswordRequestDTO, dto);
3568
- // Get user by sub
3569
- const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
3570
- if (!user || !user.passwordHash) {
3571
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
3572
- }
3573
- // ============================================================================
3574
- // Lifecycle Hook: beforePasswordChange (TODO: Implement provider-based hook)
3575
- // ============================================================================
3576
- // TODO: Implement provider-based hook for beforePasswordChange
3577
- // const allowed = await this.hookRegistry.executeBeforePasswordChange(dto.sub, dto.oldPassword);
3578
- // if (!allowed) {
3579
- // throw new NAuthException(AuthErrorCode.PASSWORD_CHANGE_NOT_ALLOWED, 'Password change not allowed');
3580
- // }
3581
- // Verify old password
3582
- const isValid = await this.passwordService.verifyPassword(dto.oldPassword, user.passwordHash);
3583
- if (!isValid) {
3584
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_INCORRECT, 'Current password is incorrect');
3585
- }
3586
- // ============================================================================
3587
- // Lifecycle Hook: afterPasswordChange (TODO: Implement provider-based hook)
3588
- // ============================================================================
3589
- // TODO: Implement provider-based hook for afterPasswordChange
3590
- // await this.hookRegistry.executeAfterPasswordChange(dto.sub);
3591
- await this.updateUserPassword({
3592
- user,
3593
- newPassword: dto.newPassword,
3594
- mustChangePassword: false,
3595
- revokeSessions: true,
3596
- revokeReason: 'Password changed',
3597
- audit: {
3598
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PASSWORD_CHANGED,
3599
- eventStatus: 'SUCCESS',
3600
- },
3601
- });
3602
- return { success: true };
3603
- }
3604
- /**
3605
- * Update user profile attributes.
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
3606
2431
  *
3607
- * Updates user fields (name, email, phone, username, metadata) and enforces unique constraints and verification rules.
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
3608
2435
  *
3609
- * @param sub - User sub/UUID
3610
- * @param updateData - User fields to update
3611
- * @returns Updated user object
3612
- * @throws {NAuthException} If user not found or unique constraint violated
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
+ * ```
3613
2444
  *
3614
- * @example
3615
- * await authService.updateUserAttributes(sub, { email: 'test@example.com' });
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
+ * ```
3616
2453
  */
3617
- async updateUserAttributes(dto) {
2454
+ async getUserSessions(dto) {
3618
2455
  // Ensure DTO is validated (supports direct usage without framework validation)
3619
- dto = await (0, dto_validator_1.ensureValidatedDto)(update_user_attributes_request_dto_1.UpdateUserAttributesRequestDTO, dto);
3620
- // Find user by sub (external identifier)
2456
+ dto = await (0, dto_validator_1.ensureValidatedDto)(get_user_sessions_dto_1.GetUserSessionsDTO, dto);
2457
+ // Get user by sub to get internal id
3621
2458
  const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
3622
2459
  if (!user) {
3623
2460
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
3624
2461
  }
3625
- // Check for uniqueness constraints - use internal id
3626
- await this.validateUniquenessConstraints(user.id, dto);
3627
- // Prepare update object
3628
- const updateFields = {};
3629
- // Update basic fields if provided
3630
- if (dto.firstName !== undefined) {
3631
- updateFields.firstName = dto.firstName;
3632
- }
3633
- if (dto.lastName !== undefined) {
3634
- updateFields.lastName = dto.lastName;
3635
- }
3636
- if (dto.username !== undefined) {
3637
- updateFields.username = dto.username;
3638
- }
3639
- if (dto.email !== undefined) {
3640
- const oldEmail = user.email;
3641
- updateFields.email = dto.email;
3642
- // Reset email verification if email changed (unless retainVerification is true)
3643
- if (dto.email !== user.email) {
3644
- if (!dto.retainVerification) {
3645
- updateFields.isEmailVerified = false;
3646
- }
3647
- else {
3648
- // Explicitly retain current verification status
3649
- updateFields.isEmailVerified = user.isEmailVerified;
3650
- }
3651
- // ============================================================================
3652
- // MFA Device Management: Handle Email MFA devices when email changes
3653
- // ============================================================================
3654
- // When email address changes, Email MFA devices become invalid.
3655
- // We deactivate them and check if user has any other active MFA devices.
3656
- // If Email was the only MFA method, user will need to set up MFA again.
3657
- // This happens automatically via challenge system at next login.
3658
- if (oldEmail && this.mfaDeviceRepository) {
3659
- try {
3660
- // Find all Email MFA devices (email field may be null in legacy devices)
3661
- const emailDevices = (await this.mfaDeviceRepository.find({
3662
- where: {
3663
- userId: user.id,
3664
- type: mfa_method_enum_1.MFAMethod.EMAIL,
3665
- isActive: true,
3666
- },
3667
- }));
3668
- if (emailDevices.length > 0) {
3669
- this.logger?.log?.(`Deleting ${emailDevices.length} Email MFA device(s) for user ${user.sub} due to email address change (old: ${oldEmail}, new: ${dto.email})`);
3670
- // Delete all Email devices (can't be reactivated with old email)
3671
- for (const device of emailDevices) {
3672
- const deviceId = device.id;
3673
- await this.mfaDeviceRepository.delete(deviceId);
3674
- }
3675
- // Record audit event for removed Email MFA devices
3676
- if (this.auditService) {
3677
- try {
3678
- await this.auditService.recordEvent({
3679
- userId: user.id,
3680
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DEVICE_REMOVED,
3681
- eventStatus: 'INFO',
3682
- reason: 'email_changed',
3683
- description: `Email MFA device(s) removed due to email address change (${oldEmail} → ${dto.email})`,
3684
- metadata: {
3685
- method: mfa_method_enum_1.MFAMethod.EMAIL,
3686
- deletedCount: emailDevices.length,
3687
- oldEmail,
3688
- newEmail: dto.email,
3689
- reason: 'email_address_changed_requires_reverification',
3690
- },
3691
- });
3692
- }
3693
- catch (auditError) {
3694
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
3695
- this.logger?.error?.(`Failed to record MFA_DEVICE_REMOVED audit event for email change: ${errorMessage}`, { error: auditError, userId: user.id });
3696
- }
3697
- }
3698
- // Check if user has any other active MFA devices
3699
- const allActiveDevices = (await this.mfaDeviceRepository.find({
3700
- where: {
3701
- userId: user.id,
3702
- isActive: true,
3703
- },
3704
- }));
3705
- // If no active devices remain and user had MFA enabled, disable MFA
3706
- if (allActiveDevices.length === 0 && user.mfaEnabled) {
3707
- updateFields.mfaEnabled = false;
3708
- updateFields.mfaMethods = [];
3709
- updateFields.preferredMfaMethod = null;
3710
- this.logger?.log?.(`MFA disabled for user ${user.sub} - no active MFA devices remaining after email change`);
3711
- }
3712
- else {
3713
- this.logger?.log?.(`User ${user.sub} still has ${allActiveDevices.length} active MFA device(s) - MFA remains enabled`);
3714
- }
3715
- }
3716
- }
3717
- catch (error) {
3718
- // Log error but don't fail the email update
3719
- // This handles cases where MFA module is not imported (mfaDeviceRepository might not be available)
3720
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
3721
- this.logger?.warn?.(`Failed to handle MFA device deactivation during email change for user ${user.sub}: ${errorMessage}`);
3722
- }
3723
- }
3724
- }
3725
- }
3726
- if (dto.phone !== undefined) {
3727
- const oldPhone = user.phone;
3728
- updateFields.phone = dto.phone;
3729
- // Reset phone verification if phone changed (unless retainVerification is true)
3730
- if (dto.phone !== user.phone) {
3731
- if (!dto.retainVerification) {
3732
- updateFields.isPhoneVerified = false;
3733
- }
3734
- else {
3735
- // Explicitly retain current verification status
3736
- updateFields.isPhoneVerified = user.isPhoneVerified;
3737
- }
3738
- // ============================================================================
3739
- // MFA Device Management: Handle SMS MFA devices when phone changes
3740
- // ============================================================================
3741
- // When phone number changes, SMS MFA devices become invalid.
3742
- // We delete them and check if user has any other active MFA devices.
3743
- // If SMS was the only MFA method, user will need to set up MFA again.
3744
- // This happens automatically via challenge system at next login.
3745
- if (oldPhone && this.mfaDeviceRepository) {
3746
- try {
3747
- // Find all SMS MFA devices (SMS MFA is tied to user.phone, not device phoneNumber)
3748
- const smsDevices = (await this.mfaDeviceRepository.find({
3749
- where: {
3750
- userId: user.id,
3751
- type: mfa_method_enum_1.MFAMethod.SMS,
3752
- isActive: true,
3753
- },
3754
- }));
3755
- if (smsDevices.length > 0) {
3756
- this.logger?.log?.(`Deleting ${smsDevices.length} SMS MFA device(s) for user ${user.sub} due to phone number change (old: ${oldPhone}, new: ${dto.phone})`);
3757
- // Delete all SMS devices (can't be reactivated with old phone number)
3758
- for (const device of smsDevices) {
3759
- const deviceId = device.id;
3760
- await this.mfaDeviceRepository.delete(deviceId);
3761
- }
3762
- // Record audit event for removed SMS MFA devices
3763
- if (this.auditService) {
3764
- try {
3765
- await this.auditService.recordEvent({
3766
- userId: user.id,
3767
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DEVICE_REMOVED,
3768
- eventStatus: 'INFO',
3769
- reason: 'phone_changed',
3770
- description: `SMS MFA device(s) removed due to phone number change (${oldPhone} → ${dto.phone})`,
3771
- metadata: {
3772
- method: mfa_method_enum_1.MFAMethod.SMS,
3773
- deletedCount: smsDevices.length,
3774
- oldPhone,
3775
- newPhone: dto.phone,
3776
- reason: 'phone_number_changed_requires_reverification',
3777
- },
3778
- });
3779
- }
3780
- catch (auditError) {
3781
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
3782
- this.logger?.error?.(`Failed to record MFA_DEVICE_REMOVED audit event for phone change: ${errorMessage}`, { error: auditError, userId: user.id });
3783
- }
3784
- }
3785
- // Check if user has any other active MFA devices
3786
- const allActiveDevices = (await this.mfaDeviceRepository.find({
3787
- where: {
3788
- userId: user.id,
3789
- isActive: true,
3790
- },
3791
- }));
3792
- // If no active devices remain and user had MFA enabled, disable MFA
3793
- if (allActiveDevices.length === 0 && user.mfaEnabled) {
3794
- updateFields.mfaEnabled = false;
3795
- updateFields.mfaMethods = [];
3796
- updateFields.preferredMfaMethod = null;
3797
- this.logger?.log?.(`MFA disabled for user ${user.sub} - no active MFA devices remaining after phone change`);
3798
- }
3799
- else {
3800
- this.logger?.log?.(`User ${user.sub} still has ${allActiveDevices.length} active MFA device(s) - MFA remains enabled`);
3801
- }
3802
- }
3803
- }
3804
- catch (error) {
3805
- // Log error but don't fail the phone update
3806
- // This handles cases where MFA module is not imported (mfaDeviceRepository might not be available)
3807
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
3808
- this.logger?.warn?.(`Failed to handle MFA device deactivation during phone change for user ${user.sub}: ${errorMessage}`);
3809
- }
3810
- }
3811
- }
3812
- }
3813
- // Handle preferred MFA method
3814
- if (dto.preferredMfaMethod !== undefined) {
3815
- updateFields.preferredMfaMethod = dto.preferredMfaMethod;
3816
- }
3817
- // Handle metadata merge
3818
- if (dto.metadata !== undefined) {
3819
- const existingMetadata = user.metadata || {};
3820
- updateFields.metadata = { ...existingMetadata, ...dto.metadata };
3821
- }
3822
- // Update user in database - use internal id for update query
3823
- await this.userRepository.update(user.id, updateFields);
3824
- // Fetch updated user - use internal id
3825
- const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
3826
- if (!updatedUser) {
3827
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after update');
3828
- }
3829
- // ============================================================================
3830
- // Audit: Record profile and attribute updates
3831
- // ============================================================================
3832
- try {
3833
- // Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
3834
- // Note: ClientInfoService is used transparently by SessionService and AuditService
3835
- const updatedFieldNames = Object.keys(updateFields);
3836
- // Build field changes map with before/after values
3837
- const fieldChanges = {};
3838
- // Capture before/after values for each updated field
3839
- if (dto.firstName !== undefined && dto.firstName !== user.firstName) {
3840
- fieldChanges.firstName = {
3841
- before: user.firstName ?? null,
3842
- after: dto.firstName ?? null,
3843
- };
3844
- }
3845
- if (dto.lastName !== undefined && dto.lastName !== user.lastName) {
3846
- fieldChanges.lastName = {
3847
- before: user.lastName ?? null,
3848
- after: dto.lastName ?? null,
3849
- };
3850
- }
3851
- if (dto.username !== undefined && dto.username !== user.username) {
3852
- fieldChanges.username = {
3853
- before: user.username ?? null,
3854
- after: dto.username ?? null,
3855
- };
3856
- }
3857
- // Note: email and phone are tracked separately with specific audit events,
3858
- // but we include them in fieldChanges for completeness
3859
- if (dto.email !== undefined && dto.email !== user.email) {
3860
- fieldChanges.email = {
3861
- before: user.email ?? null,
3862
- after: dto.email ?? null,
3863
- };
3864
- }
3865
- if (dto.phone !== undefined && dto.phone !== user.phone) {
3866
- fieldChanges.phone = {
3867
- before: user.phone ?? null,
3868
- after: dto.phone ?? null,
3869
- };
3870
- }
3871
- if (dto.preferredMfaMethod !== undefined && dto.preferredMfaMethod !== user.preferredMfaMethod) {
3872
- fieldChanges.preferredMfaMethod = {
3873
- before: user.preferredMfaMethod ?? null,
3874
- after: dto.preferredMfaMethod ?? null,
3875
- };
3876
- }
3877
- // Handle metadata changes (merged, so track what was added/changed)
3878
- if (dto.metadata !== undefined) {
3879
- const oldMetadata = user.metadata || {};
3880
- const newMetadata = { ...oldMetadata, ...dto.metadata };
3881
- const metadataChanges = {};
3882
- // Track all keys in new metadata
3883
- const allKeys = new Set([...Object.keys(oldMetadata), ...Object.keys(dto.metadata)]);
3884
- for (const key of allKeys) {
3885
- const oldValue = oldMetadata[key];
3886
- const newValue = newMetadata[key];
3887
- // Only track if value actually changed
3888
- if (JSON.stringify(oldValue) !== JSON.stringify(newValue)) {
3889
- metadataChanges[key] = {
3890
- before: oldValue ?? null,
3891
- after: newValue ?? null,
3892
- };
3893
- }
3894
- }
3895
- if (Object.keys(metadataChanges).length > 0) {
3896
- fieldChanges.metadata = metadataChanges;
3897
- }
3898
- }
3899
- // Track verification status changes if email/phone changed
3900
- if (dto.email !== undefined && dto.email !== user.email) {
3901
- const emailVerificationChanged = !dto.retainVerification && updateFields.isEmailVerified === false;
3902
- if (emailVerificationChanged) {
3903
- fieldChanges.isEmailVerified = {
3904
- before: user.isEmailVerified,
3905
- after: false,
3906
- };
3907
- }
3908
- }
3909
- if (dto.phone !== undefined && dto.phone !== user.phone) {
3910
- const phoneVerificationChanged = !dto.retainVerification && updateFields.isPhoneVerified === false;
3911
- if (phoneVerificationChanged) {
3912
- fieldChanges.isPhoneVerified = {
3913
- before: user.isPhoneVerified,
3914
- after: false,
3915
- };
3916
- }
3917
- }
3918
- // Record general profile update with field changes
3919
- await this.auditService?.recordEvent({
3920
- userId: user.id,
3921
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PROFILE_UPDATED,
3922
- eventStatus: 'INFO',
3923
- metadata: {
3924
- // Client info automatically included from context
3925
- updatedFields: updatedFieldNames,
3926
- fieldChanges: Object.keys(fieldChanges).length > 0 ? fieldChanges : undefined,
3927
- },
3928
- });
3929
- // Record specific field changes
3930
- if (dto.email !== undefined && dto.email !== user.email) {
3931
- await this.auditService?.recordEvent({
3932
- userId: user.id,
3933
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.EMAIL_CHANGED,
3934
- eventStatus: 'INFO',
3935
- metadata: {
3936
- // Client info automatically included from context
3937
- oldEmail: user.email,
3938
- newEmail: dto.email,
3939
- retainVerification: dto.retainVerification || false,
3940
- },
3941
- });
3942
- }
3943
- if (dto.phone !== undefined && dto.phone !== user.phone) {
3944
- await this.auditService?.recordEvent({
3945
- userId: user.id,
3946
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PHONE_CHANGED,
3947
- eventStatus: 'INFO',
3948
- metadata: {
3949
- // Client info automatically included from context
3950
- oldPhone: user.phone,
3951
- newPhone: dto.phone,
3952
- retainVerification: dto.retainVerification || false,
3953
- },
3954
- });
3955
- }
3956
- if (dto.username !== undefined && dto.username !== user.username) {
3957
- await this.auditService?.recordEvent({
3958
- userId: user.id,
3959
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.USERNAME_CHANGED,
3960
- eventStatus: 'INFO',
3961
- metadata: {
3962
- // Client info automatically included from context
3963
- oldUsername: user.username,
3964
- newUsername: dto.username,
3965
- },
3966
- });
3967
- }
3968
- }
3969
- catch (auditError) {
3970
- // Non-blocking: Log but continue
3971
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
3972
- this.logger?.error?.(`Failed to record profile update audit events: ${errorMessage}`, {
3973
- error: auditError,
3974
- userId: user.id,
3975
- });
3976
- }
3977
- // ============================================================================
3978
- // Hook: Execute user profile updated hooks
3979
- // ============================================================================
3980
- try {
3981
- // Build changed fields array with old/new values
3982
- const changedFields = [];
3983
- // Track all fields that were in updateFields
3984
- for (const fieldName of Object.keys(updateFields)) {
3985
- changedFields.push({
3986
- fieldName,
3987
- oldValue: user[fieldName],
3988
- newValue: updateFields[fieldName],
3989
- });
3990
- }
3991
- // Get client info from ClientInfoService
3992
- const clientInfo = this.clientInfoService.get();
3993
- // Execute hooks (non-blocking)
3994
- await this.hookRegistry.executeUserProfileUpdated({
3995
- user: updatedUser,
3996
- changedFields,
3997
- updateSource: 'user_request',
3998
- clientInfo: {
3999
- ipAddress: clientInfo.ipAddress,
4000
- userAgent: clientInfo.userAgent,
4001
- ipCountry: clientInfo.ipCountry,
4002
- ipCity: clientInfo.ipCity,
4003
- },
4004
- });
4005
- }
4006
- catch (hookError) {
4007
- // Non-blocking: Log but continue
4008
- const errorMessage = hookError instanceof Error ? hookError.message : 'Unknown error';
4009
- this.logger?.error?.(`Failed to execute userProfileUpdated hooks: ${errorMessage}`, {
4010
- error: hookError,
4011
- userId: user.id,
4012
- });
4013
- }
4014
- // Return user response DTO
4015
- return user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
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 };
4016
2505
  }
4017
2506
  /**
4018
- * Update email and/or phone verification status.
4019
- *
4020
- * Intended for admin use cases such as migration or offline validation.
4021
- * Updates verification status without requiring actual verification codes.
4022
- *
4023
- * Validation:
4024
- * - Cannot set verified=true if email/phone doesn't exist
4025
- * - Can set verified=false even if email/phone doesn't exist (default state)
4026
- * - Only updates provided fields (partial update)
4027
- *
4028
- * Audit:
4029
- * - Records EMAIL_VERIFIED or PHONE_VERIFIED audit events
4030
- * - Includes performedBy from authenticated admin context
2507
+ * Logout a specific session by ID
4031
2508
  *
4032
- * @param dto - Request DTO containing sub and verification status flags
4033
- * @returns Updated user object
4034
- * @throws {NAuthException} If user not found or trying to verify non-existent email/phone
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.
4035
2512
  *
4036
- * @example
4037
- * ```typescript
4038
- * // Update email verification only
4039
- * await authService.updateVerifiedStatus({
4040
- * sub: 'user-uuid',
4041
- * isEmailVerified: true
4042
- * });
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)
4043
2516
  *
4044
- * // Update both email and phone verification
4045
- * await authService.updateVerifiedStatus({
4046
- * sub: 'user-uuid',
4047
- * isEmailVerified: true,
4048
- * isPhoneVerified: false
4049
- * });
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
+ * }
4050
2546
  * ```
4051
2547
  */
4052
- async updateVerifiedStatus(dto) {
2548
+ async logoutSession(dto) {
4053
2549
  // Ensure DTO is validated (supports direct usage without framework validation)
4054
- dto = await (0, dto_validator_1.ensureValidatedDto)(update_verified_status_request_dto_1.UpdateVerifiedStatusRequestDTO, dto);
4055
- // Find user by sub (external identifier)
2550
+ dto = await (0, dto_validator_1.ensureValidatedDto)(logout_session_dto_1.LogoutSessionDTO, dto);
2551
+ // Get user by sub to get internal id
4056
2552
  const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
4057
2553
  if (!user) {
4058
2554
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
4059
2555
  }
4060
- // Validate that email exists if trying to set isEmailVerified to true
4061
- if (dto.isEmailVerified === true && !user.email) {
4062
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Cannot set email verification to true: user does not have an email address');
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');
4063
2560
  }
4064
- // Validate that phone exists if trying to set isPhoneVerified to true
4065
- if (dto.isPhoneVerified === true && !user.phone) {
4066
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Cannot set phone verification to true: user does not have a phone number');
4067
- }
4068
- // Prepare update object - only include fields that were provided
4069
- const updateFields = {};
4070
- if (dto.isEmailVerified !== undefined) {
4071
- updateFields.isEmailVerified = dto.isEmailVerified;
4072
- }
4073
- if (dto.isPhoneVerified !== undefined) {
4074
- updateFields.isPhoneVerified = dto.isPhoneVerified;
4075
- }
4076
- // If no fields to update, return current user
4077
- if (Object.keys(updateFields).length === 0) {
4078
- return user_response_dto_1.UserResponseDto.fromEntity(user);
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');
4079
2565
  }
4080
- // Update user - use internal id for database update
4081
- await this.userRepository.update(user.id, updateFields);
4082
- // Reload user to get updated values
4083
- const updatedUser = (await this.userRepository.findOne({ where: { id: user.id } }));
4084
- if (!updatedUser) {
4085
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'Failed to reload user after update');
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');
4086
2569
  }
4087
- // ============================================================================
4088
- // Audit: Record verification status changes
4089
- // ============================================================================
4090
- if (this.auditService) {
4091
- // Record email verification change if provided
4092
- if (dto.isEmailVerified !== undefined) {
4093
- try {
4094
- await this.auditService.recordEvent({
4095
- userId: user.id,
4096
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.EMAIL_VERIFIED,
4097
- eventStatus: dto.isEmailVerified ? 'SUCCESS' : 'INFO',
4098
- description: dto.isEmailVerified
4099
- ? 'Email verification status set to verified (admin action)'
4100
- : 'Email verification status set to unverified (admin action)',
4101
- reason: 'admin_verification_update',
4102
- metadata: {
4103
- previousStatus: user.isEmailVerified,
4104
- newStatus: dto.isEmailVerified,
4105
- updateMethod: 'admin_direct',
4106
- // Client info automatically included from context (performedBy auto-populated)
4107
- },
4108
- });
4109
- }
4110
- catch (auditError) {
4111
- // Non-blocking: Log but continue
4112
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
4113
- this.logger?.error?.(`Failed to record EMAIL_VERIFIED audit event: ${errorMessage}`, {
4114
- error: auditError,
4115
- userId: user.id,
4116
- });
4117
- }
4118
- }
4119
- // Record phone verification change if provided
4120
- if (dto.isPhoneVerified !== undefined) {
4121
- try {
4122
- await this.auditService.recordEvent({
4123
- userId: user.id,
4124
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PHONE_VERIFIED,
4125
- eventStatus: dto.isPhoneVerified ? 'SUCCESS' : 'INFO',
4126
- description: dto.isPhoneVerified
4127
- ? 'Phone verification status set to verified (admin action)'
4128
- : 'Phone verification status set to unverified (admin action)',
4129
- reason: 'admin_verification_update',
4130
- metadata: {
4131
- previousStatus: user.isPhoneVerified,
4132
- newStatus: dto.isPhoneVerified,
4133
- updateMethod: 'admin_direct',
4134
- // Client info automatically included from context (performedBy auto-populated)
4135
- },
4136
- });
4137
- }
4138
- catch (auditError) {
4139
- // Non-blocking: Log but continue
4140
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
4141
- this.logger?.error?.(`Failed to record PHONE_VERIFIED audit event: ${errorMessage}`, {
4142
- error: auditError,
4143
- userId: user.id,
4144
- });
4145
- }
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');
4146
2585
  }
4147
2586
  }
4148
- // ============================================================================
4149
- // Hook: Execute user profile updated hooks
4150
- // ============================================================================
4151
- try {
4152
- // Build changed fields array with old/new values
4153
- const changedFields = [];
4154
- if (dto.isEmailVerified !== undefined) {
4155
- changedFields.push({
4156
- fieldName: 'isEmailVerified',
4157
- oldValue: user.isEmailVerified,
4158
- newValue: dto.isEmailVerified,
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
+ },
4159
2600
  });
4160
2601
  }
4161
- if (dto.isPhoneVerified !== undefined) {
4162
- changedFields.push({
4163
- fieldName: 'isPhoneVerified',
4164
- oldValue: user.isPhoneVerified,
4165
- newValue: dto.isPhoneVerified,
2602
+ catch (auditError) {
2603
+ // Non-blocking: Log but continue
2604
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
2605
+ this.logger?.error?.(`Failed to record SESSION_REVOKED audit event: ${errorMessage}`, {
2606
+ error: auditError,
2607
+ userId: user.id,
4166
2608
  });
4167
2609
  }
4168
- // Get client info from ClientInfoService
4169
- const clientInfo = this.clientInfoService.get();
4170
- // Execute hooks (non-blocking)
4171
- await this.hookRegistry.executeUserProfileUpdated({
4172
- user: updatedUser,
4173
- changedFields,
4174
- updateSource: 'admin_action',
4175
- clientInfo: {
4176
- ipAddress: clientInfo.ipAddress,
4177
- userAgent: clientInfo.userAgent,
4178
- ipCountry: clientInfo.ipCountry,
4179
- ipCity: clientInfo.ipCity,
4180
- },
4181
- });
4182
- }
4183
- catch (hookError) {
4184
- // Non-blocking: Log but continue
4185
- const errorMessage = hookError instanceof Error ? hookError.message : 'Unknown error';
4186
- this.logger?.error?.(`Failed to execute userProfileUpdated hooks: ${errorMessage}`, {
4187
- error: hookError,
4188
- userId: user.id,
4189
- });
4190
2610
  }
4191
- // Return user response DTO
4192
- return user_response_dto_1.UserResponseDto.fromEntity(updatedUser);
2611
+ return {
2612
+ success: true,
2613
+ wasCurrentSession,
2614
+ };
4193
2615
  }
2616
+ // ============================================================================
2617
+ // Password Management
2618
+ // ============================================================================
4194
2619
  /**
4195
- * Ensures email, phone, and username are unique for other users before update.
2620
+ * Change the password for an existing user.
4196
2621
  *
4197
- * Throws if another user already has the specified email, phone, or username.
2622
+ * Verifies the current password, validates the new password,
2623
+ * checks password reuse policy, and updates the user's password hash and history.
2624
+ * Executes configured pre-change hooks if provided.
4198
2625
  *
4199
- * @param userId - Internal numeric user ID (excluded from check)
4200
- * @param updateData - User fields to check for uniqueness
4201
- * @throws {NAuthException} If a unique constraint is violated for email, phone, or username
2626
+ * @param sub - External user identifier (sub/UUID)
2627
+ * @param dto - ChangePasswordDTO containing old and new password
2628
+ * @returns void
2629
+ * @throws {NAuthException} If the user is not found, current password is incorrect, the new password is weak, password reuse is detected, or password change is disallowed by hooks.
4202
2630
  *
4203
2631
  * @example
4204
2632
  * ```typescript
4205
- * await authService.validateUniquenessConstraints(1, { email: "test@example.com" });
2633
+ * await authService.changePassword('user-uuid', {
2634
+ * oldPassword: 'currentPass123!',
2635
+ * newPassword: 'newStr0ngPass!@#',
2636
+ * });
4206
2637
  * ```
4207
2638
  */
4208
- async validateUniquenessConstraints(userId, updateData) {
4209
- const conflicts = [];
4210
- // Check email uniqueness
4211
- if (updateData.email) {
4212
- const existingUser = await this.userRepository.findOne({
4213
- where: { email: updateData.email },
4214
- });
4215
- if (existingUser && existingUser.id !== userId) {
4216
- conflicts.push('Email already exists');
4217
- }
4218
- }
4219
- // Check phone uniqueness
4220
- if (updateData.phone) {
4221
- const existingUser = await this.userRepository.findOne({
4222
- where: { phone: updateData.phone },
4223
- });
4224
- if (existingUser && existingUser.id !== userId) {
4225
- conflicts.push('Phone number already exists');
4226
- }
4227
- }
4228
- // Check username uniqueness
4229
- if (updateData.username) {
4230
- const existingUser = await this.userRepository.findOne({
4231
- where: { username: updateData.username },
4232
- });
4233
- if (existingUser && existingUser.id !== userId) {
4234
- conflicts.push('Username already exists');
4235
- }
2639
+ async changePassword(dto) {
2640
+ // Ensure DTO is validated (supports direct usage without framework validation)
2641
+ dto = await (0, dto_validator_1.ensureValidatedDto)(change_password_request_dto_1.ChangePasswordRequestDTO, dto);
2642
+ // Get user by sub
2643
+ const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
2644
+ if (!user || !user.passwordHash) {
2645
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
4236
2646
  }
4237
- if (conflicts.length > 0) {
4238
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, conflicts.join(', '), {
4239
- conflicts,
4240
- });
2647
+ // ============================================================================
2648
+ // Lifecycle Hook: beforePasswordChange (TODO: Implement provider-based hook)
2649
+ // ============================================================================
2650
+ // TODO: Implement provider-based hook for beforePasswordChange
2651
+ // const allowed = await this.hookRegistry.executeBeforePasswordChange(dto.sub, dto.oldPassword);
2652
+ // if (!allowed) {
2653
+ // throw new NAuthException(AuthErrorCode.PASSWORD_CHANGE_NOT_ALLOWED, 'Password change not allowed');
2654
+ // }
2655
+ // Verify old password
2656
+ const isValid = await this.passwordService.verifyPassword(dto.oldPassword, user.passwordHash);
2657
+ if (!isValid) {
2658
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_INCORRECT, 'Current password is incorrect');
4241
2659
  }
2660
+ // ============================================================================
2661
+ // Lifecycle Hook: afterPasswordChange (TODO: Implement provider-based hook)
2662
+ // ============================================================================
2663
+ // TODO: Implement provider-based hook for afterPasswordChange
2664
+ // await this.hookRegistry.executeAfterPasswordChange(dto.sub);
2665
+ await this.helpers.updateUserPassword({
2666
+ user,
2667
+ newPassword: dto.newPassword,
2668
+ mustChangePassword: false,
2669
+ revokeSessions: true,
2670
+ revokeReason: 'Password changed',
2671
+ audit: {
2672
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PASSWORD_CHANGED,
2673
+ eventStatus: 'SUCCESS',
2674
+ },
2675
+ }, this.passwordService, this.auditService);
2676
+ return { success: true };
4242
2677
  }
4243
- // ============================================================================
4244
- // Helper Methods
4245
- // ============================================================================
4246
2678
  /**
4247
- * Checks if the login identifier matches the specified allowed type.
2679
+ * Update user profile attributes.
4248
2680
  *
4249
- * Determines if the given identifier is a valid email, username, phone, or allowed hybrid,
4250
- * according to the configured identifier type restriction.
2681
+ * Updates user fields (name, email, phone, username, metadata) and enforces unique constraints and verification rules.
4251
2682
  *
4252
- * @param identifier - The login identifier to check (email, username, or phone)
4253
- * @param allowedType - The permitted identifier type ('email', 'username', 'phone', or 'email_or_username')
4254
- * @returns True if the identifier conforms to the allowed type, otherwise false
2683
+ * @param dto - UpdateUserAttributesRequestDTO containing sub and fields to update
2684
+ * @returns Updated user object
2685
+ * @throws {NAuthException} If user not found or unique constraint violated
4255
2686
  *
4256
2687
  * @example
4257
- * ```typescript
4258
- * // Email check
4259
- * const valid = this.validateIdentifierType('user@example.com', 'email'); // true
4260
- *
4261
- * // Username check
4262
- * const valid = this.validateIdentifierType('johndoe', 'username'); // true
4263
- * ```
2688
+ * await authService.updateUserAttributes({ sub: 'user-uuid', email: 'test@example.com' });
4264
2689
  */
4265
- validateIdentifierType(identifier, allowedType) {
4266
- // Check if identifier is an email (contains @)
4267
- const isEmail = identifier.includes('@');
4268
- // Check if identifier looks like a phone (starts with + and contains digits)
4269
- const isPhone = /^\+[1-9]\d{1,14}$/.test(identifier.trim());
4270
- // If not email or phone, assume it's a username
4271
- const isUsername = !isEmail && !isPhone;
4272
- switch (allowedType) {
4273
- case 'email':
4274
- return isEmail;
4275
- case 'username':
4276
- return isUsername;
4277
- case 'phone':
4278
- return isPhone;
4279
- case 'email_or_username':
4280
- return isEmail || isUsername;
4281
- default:
4282
- return true; // No restriction
4283
- }
2690
+ async updateUserAttributes(dto) {
2691
+ return await this.userService.updateUserAttributes(dto);
4284
2692
  }
4285
2693
  /**
4286
- * Retrieves a user entity by login identifier.
2694
+ * Update email and/or phone verification status.
4287
2695
  *
4288
- * Performs a lookup for a user by email, username, or phone number.
4289
- * The search respects the identifierType restriction when provided, limiting which fields are queried.
2696
+ * Intended for admin use cases such as migration or offline validation.
2697
+ * Updates verification status without requiring actual verification codes.
4290
2698
  *
4291
- * @param identifier - Login credential (email, username, or phone)
4292
- * @param identifierType - Restricts search to a specific identifier type ('email', 'username', 'phone', or 'email_or_username')
4293
- * @returns The user entity if found, otherwise null
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)
4294
2703
  *
4295
- * @example
4296
- * ```typescript
4297
- * const user = await this.findUserByIdentifier('user@example.com');
4298
- * const user2 = await this.findUserByIdentifier('johndoe', 'username');
4299
- * ```
4300
- */
4301
- async findUserByIdentifier(identifier, identifierType) {
4302
- const queryBuilder = this.userRepository.createQueryBuilder('user');
4303
- // Build query based on identifier type restriction
4304
- if (!identifierType) {
4305
- // No restriction - search all fields
4306
- queryBuilder
4307
- .where('user.email = :identifier', { identifier })
4308
- .orWhere('user.username = :identifier', { identifier })
4309
- .orWhere('user.phone = :identifier', { identifier });
4310
- }
4311
- else {
4312
- // Apply restriction based on identifier type
4313
- switch (identifierType) {
4314
- case 'email':
4315
- queryBuilder.where('user.email = :identifier', { identifier });
4316
- break;
4317
- case 'username':
4318
- queryBuilder.where('user.username = :identifier', { identifier });
4319
- break;
4320
- case 'phone':
4321
- queryBuilder.where('user.phone = :identifier', { identifier });
4322
- break;
4323
- case 'email_or_username':
4324
- queryBuilder
4325
- .where('user.email = :identifier', { identifier })
4326
- .orWhere('user.username = :identifier', { identifier });
4327
- break;
4328
- }
4329
- }
4330
- // Select only columns required for login checks and response shaping to reduce row size
4331
- queryBuilder.select([
4332
- 'user.id',
4333
- 'user.sub',
4334
- 'user.email',
4335
- 'user.firstName',
4336
- 'user.lastName',
4337
- 'user.username',
4338
- 'user.phone',
4339
- 'user.passwordHash',
4340
- 'user.passwordChangedAt',
4341
- 'user.mustChangePassword',
4342
- 'user.isActive',
4343
- 'user.mfaEnabled',
4344
- 'user.preferredMfaMethod',
4345
- 'user.isEmailVerified',
4346
- 'user.isPhoneVerified',
4347
- 'user.mfaExempt', // Required for MFA exemption check in challenge flow
4348
- // Lock fields - required for account lock check in login flow
4349
- 'user.isLocked',
4350
- 'user.lockReason',
4351
- 'user.lockedAt',
4352
- 'user.lockedUntil',
4353
- // The following are used for messaging/challenge determination when needed
4354
- 'user.socialProviders',
4355
- 'user.backupCodes',
4356
- ]);
4357
- return (await queryBuilder.getOne());
4358
- }
4359
- /**
4360
- * Handles a failed login by recording the attempt, applying IP-based lockout policy,
4361
- * and invoking relevant hooks.
2704
+ * Audit:
2705
+ * - Records EMAIL_VERIFIED or PHONE_VERIFIED audit events
2706
+ * - Includes performedBy from authenticated admin context
4362
2707
  *
4363
- * @param identifier - User identifier (email/username/phone)
4364
- * @param reason - Optional reason for failure
4365
- * @returns Promise<void>
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
4366
2711
  *
4367
2712
  * @example
4368
2713
  * ```typescript
4369
- * await authService.handleFailedLogin('user@example.com', 'invalid_credentials');
4370
- * ```
4371
- */
4372
- async handleFailedLogin(identifier, reason) {
4373
- // Get client IP address for lockout tracking
4374
- const clientInfo = this.clientInfoService.get();
4375
- const ipAddress = clientInfo.ipAddress;
4376
- // Record failed attempt
4377
- await this.recordLoginAttempt(identifier, false, reason);
4378
- // Increment IP-based lockout counter if enabled
4379
- if (this.config.lockout?.enabled && ipAddress) {
4380
- const attempts = await this.accountLockoutStorage.recordFailedAttempt(ipAddress);
4381
- // Lock IP if max attempts reached
4382
- if (attempts >= (this.config.lockout.maxAttempts || 5)) {
4383
- await this.accountLockoutStorage.lockIpAddress(ipAddress, this.config.lockout.duration || 900, // 15 minutes default
4384
- 'Too many failed login attempts from this IP');
4385
- // // Execute hook with IP address
4386
- // if (this.config.hooks?.afterAccountLock) {
4387
- // await this.config.hooks.afterAccountLock(identifier, 'Too many failed attempts from IP', clientInfo);
4388
- // }
4389
- }
4390
- }
4391
- // ============================================================================
4392
- // Lifecycle Hook: afterLoginFailed (TODO: Implement provider-based hook)
4393
- // ============================================================================
4394
- // TODO: Implement provider-based hook for afterLoginFailed
4395
- // await this.hookRegistry.executeAfterLoginFailed(identifier, reason || 'unknown');
4396
- }
4397
- /**
4398
- * Records a login attempt with client context.
2714
+ * // Update email verification only
2715
+ * await authService.updateVerifiedStatus({
2716
+ * sub: 'user-uuid',
2717
+ * isEmailVerified: true
2718
+ * });
4399
2719
  *
4400
- * @param email - User's email address
4401
- * @param success - True if login succeeded, false if failed
4402
- * @param failureReason - Optional reason for failure
4403
- * @param userId - Optional internal user ID (only for successful logins)
4404
- * @returns Promise<void>
2720
+ * // Update both email and phone verification
2721
+ * await authService.updateVerifiedStatus({
2722
+ * sub: 'user-uuid',
2723
+ * isEmailVerified: true,
2724
+ * isPhoneVerified: false
2725
+ * });
2726
+ * ```
4405
2727
  */
4406
- async recordLoginAttempt(email, success, failureReason, userId) {
4407
- // Get client info from context
4408
- const clientInfo = this.clientInfoService.get();
4409
- const attempt = this.loginAttemptRepository.create({
4410
- email,
4411
- userId, // Internal user ID (integer)
4412
- ipAddress: clientInfo.ipAddress,
4413
- userAgent: clientInfo.userAgent,
4414
- success,
4415
- failureReason,
4416
- });
4417
- await this.loginAttemptRepository.save(attempt);
2728
+ async updateVerifiedStatus(dto) {
2729
+ return await this.userService.updateVerifiedStatus(dto);
4418
2730
  }
2731
+ // ============================================================================
2732
+ // Helper Methods
2733
+ // ============================================================================
2734
+ // NOTE: Private helper methods have been moved to AuthServiceInternalHelpers
2735
+ // Use this.helpers.methodName() to access them
4419
2736
  /**
4420
2737
  * Get user for authentication context
4421
2738
  *
@@ -4437,90 +2754,53 @@ class AuthService {
4437
2754
  * ```
4438
2755
  */
4439
2756
  async getUserForAuthContext(sub) {
4440
- // Load user with all fields including passwordHash (needed to compute hasPasswordHash)
4441
- // NOTE: We need to load passwordHash before @AfterLoad hook deletes it
4442
- // The hook computes hasPasswordHash but deletes passwordHash, so we check it first
4443
- const user = await this.userRepository.findOne({
4444
- where: { sub },
4445
- });
4446
- if (!user) {
4447
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
4448
- }
4449
- if (!user.isActive) {
4450
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.ACCOUNT_INACTIVE, 'Account is not active');
4451
- }
4452
- // CRITICAL: The @AfterLoad hook computes hasPasswordHash but doesn't delete passwordHash anymore
4453
- // Use the computed value from the hook, or compute it from passwordHash if hook didn't run
4454
- const userWithPassword = user;
4455
- const hasPasswordHash = user.hasPasswordHash !== undefined ? user.hasPasswordHash : Boolean(userWithPassword.passwordHash);
4456
- // Create safe user object without sensitive fields
4457
- const safeUser = {
4458
- ...user,
4459
- hasPasswordHash,
4460
- };
4461
- // Remove sensitive fields (passwordHash may already be deleted by @AfterLoad hook, but ensure it's gone)
4462
- delete safeUser.passwordHash;
4463
- delete safeUser.totpSecret;
4464
- delete safeUser.backupCodes;
4465
- delete safeUser.passwordHistory;
4466
- return safeUser;
2757
+ return await this.userService.getUserForAuthContext(sub);
4467
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
+ */
4468
2770
  async getUserById(dto) {
4469
- // Ensure DTO is validated (supports direct usage without framework validation)
4470
- dto = await (0, dto_validator_1.ensureValidatedDto)(get_user_by_id_dto_1.GetUserByIdDTO, dto);
4471
- const user = (await this.userRepository.findOne({ where: { sub: dto.sub } }));
4472
- return user ? user_response_dto_1.UserResponseDto.fromEntity(user) : null;
2771
+ return await this.userService.getUserById(dto);
4473
2772
  }
4474
2773
  /**
4475
2774
  * Get user by email address.
4476
2775
  *
4477
- * @param email - User email
4478
- * @param requireEmailVerified - Only return user if email is verified (default: false)
4479
- * @returns User entity or null
2776
+ * @param dto - GetUserByEmailDTO containing email and optional requireEmailVerified
2777
+ * @returns User response DTO or null if not found
4480
2778
  * @internal - For use by social auth providers
4481
2779
  *
4482
2780
  * @example
4483
2781
  * ```typescript
4484
- * const user = await authService.getUserByEmail('user@example.com', true);
2782
+ * const user = await authService.getUserByEmail({ email: 'user@example.com', requireEmailVerified: true });
4485
2783
  * ```
4486
2784
  */
4487
2785
  async getUserByEmail(dto) {
4488
- // Ensure DTO is validated (supports direct usage without framework validation)
4489
- dto = await (0, dto_validator_1.ensureValidatedDto)(get_user_by_email_dto_1.GetUserByEmailDTO, dto);
4490
- const where = dto.requireEmailVerified
4491
- ? { email: dto.email, isEmailVerified: true }
4492
- : { email: dto.email };
4493
- const user = (await this.userRepository.findOne({ where }));
4494
- return user ? user_response_dto_1.UserResponseDto.fromEntity(user) : null;
2786
+ return await this.userService.getUserByEmail(dto);
4495
2787
  }
4496
2788
  /**
4497
2789
  * Require user to change password at next login.
4498
2790
  *
4499
2791
  * Throws if user not found or has no password set (e.g. social login only).
4500
2792
  *
4501
- * @param userId - User's sub identifier
4502
- * @returns Resolves when flag is set
2793
+ * @param dto - SetMustChangePasswordDTO containing userId (sub)
2794
+ * @returns Success response
4503
2795
  * @throws {NAuthException} If user is not found or cannot change password
4504
2796
  *
4505
2797
  * @example
4506
- * await authService.setMustChangePassword('user-uuid-123');
2798
+ * ```typescript
2799
+ * await authService.setMustChangePassword({ userId: 'user-uuid-123' });
2800
+ * ```
4507
2801
  */
4508
2802
  async setMustChangePassword(dto) {
4509
- // Ensure DTO is validated (supports direct usage without framework validation)
4510
- dto = await (0, dto_validator_1.ensureValidatedDto)(set_must_change_password_dto_1.SetMustChangePasswordDTO, dto);
4511
- const user = await this.userRepository.findOne({ where: { sub: dto.userId } });
4512
- if (!user) {
4513
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
4514
- }
4515
- // CRITICAL PROTECTION: Only allow for users with password authentication
4516
- // Pure social users cannot be forced to change password
4517
- if (!user.passwordHash) {
4518
- this.logger?.warn?.(`Cannot force password change for user ${dto.userId} - user doesn't have a password (pure social signup)`);
4519
- 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.');
4520
- }
4521
- await this.userRepository.update({ sub: dto.userId }, { mustChangePassword: true });
4522
- this.logger?.log?.(`Must-change-password flag set for user: ${dto.userId}`);
4523
- return { success: true };
2803
+ return await this.userService.setMustChangePassword(dto);
4524
2804
  }
4525
2805
  /**
4526
2806
  * Admin-only: Reset a user's password by identifier.
@@ -4573,7 +2853,7 @@ class AuthService {
4573
2853
  // If not found by sub, try by identifier (email, username, phone)
4574
2854
  if (!user) {
4575
2855
  this.logger?.debug?.(`Searching by identifier (email/username/phone): ${dto.identifier}`);
4576
- user = await this.findUserByIdentifier(dto.identifier);
2856
+ user = await this.helpers.findUserByIdentifier(dto.identifier);
4577
2857
  }
4578
2858
  if (!user) {
4579
2859
  this.logger?.warn?.(`Password reset failed - user not found: ${dto.identifier}`);
@@ -4582,7 +2862,7 @@ class AuthService {
4582
2862
  const mustChangePassword = dto.mustChangePassword ?? true; // Default to true for security
4583
2863
  const revokeSessions = dto.revokeSessions !== false;
4584
2864
  const wasSocialOnly = !user.passwordHash;
4585
- const { sessionsRevoked } = await this.updateUserPassword({
2865
+ const { sessionsRevoked } = await this.helpers.updateUserPassword({
4586
2866
  user,
4587
2867
  newPassword: dto.newPassword,
4588
2868
  mustChangePassword,
@@ -4601,7 +2881,7 @@ class AuthService {
4601
2881
  wasSocialOnly,
4602
2882
  },
4603
2883
  },
4604
- });
2884
+ }, this.passwordService, this.auditService);
4605
2885
  // ============================================================================
4606
2886
  // Return Response
4607
2887
  // ============================================================================
@@ -4641,11 +2921,11 @@ class AuthService {
4641
2921
  }
4642
2922
  // Respect identifier type restrictions (if configured)
4643
2923
  if (this.config.login?.identifierType &&
4644
- !this.validateIdentifierType(dto.identifier, this.config.login.identifierType)) {
2924
+ !this.helpers.validateIdentifierType(dto.identifier, this.config.login.identifierType)) {
4645
2925
  // Non-enumerating: return success without sending
4646
2926
  return response;
4647
2927
  }
4648
- const user = await this.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
2928
+ const user = await this.helpers.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
4649
2929
  if (!user) {
4650
2930
  return response; // Non-enumerating
4651
2931
  }
@@ -4720,12 +3000,12 @@ class AuthService {
4720
3000
  if (!this.passwordResetService) {
4721
3001
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.SERVICE_UNAVAILABLE, 'Password reset is not available');
4722
3002
  }
4723
- const user = await this.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
3003
+ const user = await this.helpers.findUserByIdentifier(dto.identifier, this.config.login?.identifierType);
4724
3004
  if (!user) {
4725
3005
  // Non-enumerating: treat as invalid code
4726
3006
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_RESET_CODE_INVALID, 'Invalid password reset code');
4727
3007
  }
4728
- const { sessionsRevoked: _sessionsRevoked } = await this.updateUserPassword({
3008
+ const { sessionsRevoked: _sessionsRevoked } = await this.helpers.updateUserPassword({
4729
3009
  user,
4730
3010
  newPassword: dto.newPassword,
4731
3011
  mustChangePassword: false,
@@ -4742,111 +3022,9 @@ class AuthService {
4742
3022
  description: 'Password reset completed by user',
4743
3023
  reason: 'forgot_password',
4744
3024
  },
4745
- });
3025
+ }, this.passwordService, this.auditService);
4746
3026
  return { success: true, mustChangePassword: false };
4747
3027
  }
4748
- // ============================================================================
4749
- // Internal Password Update Orchestration (Single Source of Truth)
4750
- // ============================================================================
4751
- /**
4752
- * Centralized password update flow used by:
4753
- * - changePassword()
4754
- * - confirmForgotPassword()
4755
- * - adminSetPassword()
4756
- * - FORCE_CHANGE_PASSWORD challenge handler
4757
- *
4758
- * WHY:
4759
- * - Prevent logic drift between different password-changing entrypoints
4760
- * - Ensure consistent validation, history enforcement, persistence, session revocation, and audit trails
4761
- *
4762
- * @param params - Password update parameters
4763
- * @returns Sessions revoked count (0 when not revoked)
4764
- * @throws {NAuthException} WEAK_PASSWORD | PASSWORD_REUSED | NOT_FOUND
4765
- */
4766
- async updateUserPassword(params) {
4767
- const { user, newPassword, mustChangePassword, revokeSessions, revokeReason, beforePersist, audit } = params;
4768
- // ============================================================================
4769
- // Load full user entity (important for passwordHistory serialization + reuse checks)
4770
- // ============================================================================
4771
- // WHY: Some call sites use a slim projection (e.g., findUserByIdentifier) which may omit passwordHistory.
4772
- const userEntity = (await this.userRepository.findOne({ where: { id: user.id } }));
4773
- if (!userEntity) {
4774
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
4775
- }
4776
- // ============================================================================
4777
- // Validate new password + history
4778
- // ============================================================================
4779
- const validation = await this.passwordService.validatePassword(newPassword, {
4780
- email: userEntity.email,
4781
- username: userEntity.username || undefined,
4782
- });
4783
- if (!validation.valid) {
4784
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.WEAK_PASSWORD, validation.errors.join(', '), {
4785
- errors: validation.errors,
4786
- });
4787
- }
4788
- if (this.config.password?.historyCount) {
4789
- const historyToCheck = userEntity.passwordHistory || [];
4790
- const allPreviousPasswords = userEntity.passwordHash
4791
- ? [userEntity.passwordHash, ...historyToCheck]
4792
- : historyToCheck;
4793
- const isReused = await this.passwordService.isPasswordInHistory(newPassword, allPreviousPasswords);
4794
- if (isReused) {
4795
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_REUSED, 'Cannot reuse a recent password');
4796
- }
4797
- }
4798
- // Hook point for flows that must prove possession of a reset code before persisting (forgot-password confirm)
4799
- if (beforePersist) {
4800
- await beforePersist();
4801
- }
4802
- // ============================================================================
4803
- // Persist password update
4804
- // ============================================================================
4805
- const newHash = await this.passwordService.hashPassword(newPassword);
4806
- const newHistory = userEntity.passwordHash
4807
- ? this.passwordService.addToHistory(userEntity.passwordHistory || [], userEntity.passwordHash)
4808
- : userEntity.passwordHistory || [];
4809
- userEntity.passwordHash = newHash;
4810
- userEntity.passwordChangedAt = new Date();
4811
- userEntity.passwordHistory = newHistory;
4812
- userEntity.mustChangePassword = mustChangePassword;
4813
- await this.userRepository.save(userEntity);
4814
- // ============================================================================
4815
- // Session revocation
4816
- // ============================================================================
4817
- let sessionsRevoked = 0;
4818
- if (revokeSessions) {
4819
- sessionsRevoked = await this.sessionService.revokeAllUserSessions(userEntity.id, revokeReason);
4820
- }
4821
- // ============================================================================
4822
- // Audit
4823
- // ============================================================================
4824
- if (audit) {
4825
- try {
4826
- await this.auditService?.recordEvent({
4827
- userId: userEntity.id,
4828
- eventType: audit.eventType,
4829
- eventStatus: audit.eventStatus,
4830
- reason: audit.reason,
4831
- description: audit.description,
4832
- authMethod: audit.authMethod,
4833
- metadata: {
4834
- ...audit.metadata,
4835
- mustChangePassword,
4836
- sessionsRevoked,
4837
- },
4838
- });
4839
- }
4840
- catch (auditError) {
4841
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
4842
- this.logger?.error?.(`Failed to record ${audit.eventType} audit event: ${errorMessage}`, {
4843
- error: auditError,
4844
- userId: userEntity.id,
4845
- });
4846
- }
4847
- }
4848
- return { sessionsRevoked };
4849
- }
4850
3028
  }
4851
3029
  exports.AuthService = AuthService;
4852
3030
  //# sourceMappingURL=auth.service.js.map