@nauth-toolkit/core 0.1.87 → 0.1.89

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 (174) hide show
  1. package/dist/dto/admin-get-mfa-status.dto.d.ts +20 -0
  2. package/dist/dto/admin-get-mfa-status.dto.d.ts.map +1 -0
  3. package/dist/dto/{change-password-request.dto.js → admin-get-mfa-status.dto.js} +22 -32
  4. package/dist/dto/admin-get-mfa-status.dto.js.map +1 -0
  5. package/dist/dto/admin-get-user-auth-history.dto.d.ts +62 -0
  6. package/dist/dto/admin-get-user-auth-history.dto.d.ts.map +1 -0
  7. package/dist/dto/admin-get-user-auth-history.dto.js +87 -0
  8. package/dist/dto/admin-get-user-auth-history.dto.js.map +1 -0
  9. package/dist/dto/admin-logout-all.dto.d.ts +48 -0
  10. package/dist/dto/admin-logout-all.dto.d.ts.map +1 -0
  11. package/dist/dto/admin-logout-all.dto.js +85 -0
  12. package/dist/dto/admin-logout-all.dto.js.map +1 -0
  13. package/dist/dto/admin-remove-devices.dto.d.ts +25 -0
  14. package/dist/dto/admin-remove-devices.dto.d.ts.map +1 -0
  15. package/dist/dto/admin-remove-devices.dto.js +50 -0
  16. package/dist/dto/admin-remove-devices.dto.js.map +1 -0
  17. package/dist/dto/admin-reset-password.dto.d.ts +15 -19
  18. package/dist/dto/admin-reset-password.dto.d.ts.map +1 -1
  19. package/dist/dto/admin-reset-password.dto.js +21 -41
  20. package/dist/dto/admin-reset-password.dto.js.map +1 -1
  21. package/dist/dto/admin-revoke-session.dto.d.ts +22 -0
  22. package/dist/dto/admin-revoke-session.dto.d.ts.map +1 -0
  23. package/dist/dto/admin-revoke-session.dto.js +48 -0
  24. package/dist/dto/admin-revoke-session.dto.js.map +1 -0
  25. package/dist/dto/admin-set-password.dto.d.ts +8 -10
  26. package/dist/dto/admin-set-password.dto.d.ts.map +1 -1
  27. package/dist/dto/admin-set-password.dto.js +11 -21
  28. package/dist/dto/admin-set-password.dto.js.map +1 -1
  29. package/dist/dto/admin-set-preferred-method.dto.d.ts +25 -0
  30. package/dist/dto/admin-set-preferred-method.dto.d.ts.map +1 -0
  31. package/dist/dto/admin-set-preferred-method.dto.js +50 -0
  32. package/dist/dto/admin-set-preferred-method.dto.js.map +1 -0
  33. package/dist/dto/admin-update-user-attributes.dto.d.ts +41 -0
  34. package/dist/dto/admin-update-user-attributes.dto.d.ts.map +1 -0
  35. package/dist/dto/{update-user-attributes-request.dto.js → admin-update-user-attributes.dto.js} +12 -17
  36. package/dist/dto/admin-update-user-attributes.dto.js.map +1 -0
  37. package/dist/dto/auth-challenge.dto.d.ts +2 -2
  38. package/dist/dto/auth-challenge.dto.d.ts.map +1 -1
  39. package/dist/dto/auth-challenge.dto.js +3 -3
  40. package/dist/dto/auth-challenge.dto.js.map +1 -1
  41. package/dist/dto/auth-response.dto.d.ts +1 -1
  42. package/dist/dto/auth-response.dto.d.ts.map +1 -1
  43. package/dist/dto/auth-response.dto.js +1 -1
  44. package/dist/dto/auth-response.dto.js.map +1 -1
  45. package/dist/dto/get-mfa-status.dto.d.ts +3 -32
  46. package/dist/dto/get-mfa-status.dto.d.ts.map +1 -1
  47. package/dist/dto/get-mfa-status.dto.js +4 -55
  48. package/dist/dto/get-mfa-status.dto.js.map +1 -1
  49. package/dist/dto/get-risk-assessment-history.dto.d.ts +3 -3
  50. package/dist/dto/get-risk-assessment-history.dto.d.ts.map +1 -1
  51. package/dist/dto/get-risk-assessment-history.dto.js +5 -5
  52. package/dist/dto/get-risk-assessment-history.dto.js.map +1 -1
  53. package/dist/dto/get-suspicious-activity.dto.d.ts +3 -3
  54. package/dist/dto/get-suspicious-activity.dto.d.ts.map +1 -1
  55. package/dist/dto/get-suspicious-activity.dto.js +5 -5
  56. package/dist/dto/get-suspicious-activity.dto.js.map +1 -1
  57. package/dist/dto/get-user-auth-history.dto.d.ts +4 -39
  58. package/dist/dto/get-user-auth-history.dto.d.ts.map +1 -1
  59. package/dist/dto/get-user-auth-history.dto.js +53 -51
  60. package/dist/dto/get-user-auth-history.dto.js.map +1 -1
  61. package/dist/dto/get-user-devices.dto.d.ts +5 -18
  62. package/dist/dto/get-user-devices.dto.d.ts.map +1 -1
  63. package/dist/dto/get-user-devices.dto.js +5 -39
  64. package/dist/dto/get-user-devices.dto.js.map +1 -1
  65. package/dist/dto/get-user-sessions-response.dto.d.ts +1 -1
  66. package/dist/dto/get-user-sessions-response.dto.js +1 -1
  67. package/dist/dto/get-user-sessions.dto.d.ts +1 -1
  68. package/dist/dto/get-user-sessions.dto.js +1 -1
  69. package/dist/dto/index.d.ts +9 -2
  70. package/dist/dto/index.d.ts.map +1 -1
  71. package/dist/dto/index.js +9 -2
  72. package/dist/dto/index.js.map +1 -1
  73. package/dist/dto/logout-all-response.dto.d.ts +1 -1
  74. package/dist/dto/logout-all-response.dto.js +1 -1
  75. package/dist/dto/logout-all.dto.d.ts +1 -18
  76. package/dist/dto/logout-all.dto.d.ts.map +1 -1
  77. package/dist/dto/logout-all.dto.js +1 -30
  78. package/dist/dto/logout-all.dto.js.map +1 -1
  79. package/dist/dto/logout-session.dto.d.ts +0 -5
  80. package/dist/dto/logout-session.dto.d.ts.map +1 -1
  81. package/dist/dto/logout-session.dto.js +0 -12
  82. package/dist/dto/logout-session.dto.js.map +1 -1
  83. package/dist/dto/logout.dto.d.ts +1 -18
  84. package/dist/dto/logout.dto.d.ts.map +1 -1
  85. package/dist/dto/logout.dto.js +1 -30
  86. package/dist/dto/logout.dto.js.map +1 -1
  87. package/dist/dto/remove-devices.dto.d.ts +4 -16
  88. package/dist/dto/remove-devices.dto.d.ts.map +1 -1
  89. package/dist/dto/remove-devices.dto.js +4 -26
  90. package/dist/dto/remove-devices.dto.js.map +1 -1
  91. package/dist/dto/set-mfa-exemption.dto.d.ts +8 -9
  92. package/dist/dto/set-mfa-exemption.dto.d.ts.map +1 -1
  93. package/dist/dto/set-mfa-exemption.dto.js +11 -13
  94. package/dist/dto/set-mfa-exemption.dto.js.map +1 -1
  95. package/dist/dto/set-must-change-password.dto.d.ts +3 -3
  96. package/dist/dto/set-must-change-password.dto.d.ts.map +1 -1
  97. package/dist/dto/set-must-change-password.dto.js +5 -5
  98. package/dist/dto/set-must-change-password.dto.js.map +1 -1
  99. package/dist/dto/set-preferred-method.dto.d.ts +4 -16
  100. package/dist/dto/set-preferred-method.dto.d.ts.map +1 -1
  101. package/dist/dto/set-preferred-method.dto.js +4 -26
  102. package/dist/dto/set-preferred-method.dto.js.map +1 -1
  103. package/dist/dto/setup-mfa.dto.d.ts +3 -18
  104. package/dist/dto/setup-mfa.dto.d.ts.map +1 -1
  105. package/dist/dto/setup-mfa.dto.js +3 -30
  106. package/dist/dto/setup-mfa.dto.js.map +1 -1
  107. package/dist/dto/social-auth.dto.d.ts +4 -34
  108. package/dist/dto/social-auth.dto.d.ts.map +1 -1
  109. package/dist/dto/social-auth.dto.js +10 -68
  110. package/dist/dto/social-auth.dto.js.map +1 -1
  111. package/dist/dto/update-user-attributes.dto.d.ts +26 -0
  112. package/dist/dto/update-user-attributes.dto.d.ts.map +1 -0
  113. package/dist/dto/update-user-attributes.dto.js +30 -0
  114. package/dist/dto/update-user-attributes.dto.js.map +1 -0
  115. package/dist/index.d.ts +5 -0
  116. package/dist/index.d.ts.map +1 -1
  117. package/dist/index.js +5 -0
  118. package/dist/index.js.map +1 -1
  119. package/dist/interfaces/hooks.interface.d.ts +2 -1
  120. package/dist/interfaces/hooks.interface.d.ts.map +1 -1
  121. package/dist/interfaces/mfa-provider.interface.d.ts +7 -8
  122. package/dist/interfaces/mfa-provider.interface.d.ts.map +1 -1
  123. package/dist/interfaces/provider.interface.d.ts +1 -1
  124. package/dist/interfaces/provider.interface.d.ts.map +1 -1
  125. package/dist/services/adaptive-mfa-decision.service.js +2 -2
  126. package/dist/services/adaptive-mfa-decision.service.js.map +1 -1
  127. package/dist/services/admin-auth.service.d.ts +307 -0
  128. package/dist/services/admin-auth.service.d.ts.map +1 -0
  129. package/dist/services/admin-auth.service.js +885 -0
  130. package/dist/services/admin-auth.service.js.map +1 -0
  131. package/dist/services/auth-audit.service.d.ts +16 -16
  132. package/dist/services/auth-audit.service.d.ts.map +1 -1
  133. package/dist/services/auth-audit.service.js +33 -33
  134. package/dist/services/auth-audit.service.js.map +1 -1
  135. package/dist/services/auth-challenge-helper.service.js +3 -3
  136. package/dist/services/auth-challenge-helper.service.js.map +1 -1
  137. package/dist/services/auth-service-internal-helpers.d.ts +13 -2
  138. package/dist/services/auth-service-internal-helpers.d.ts.map +1 -1
  139. package/dist/services/auth-service-internal-helpers.js +39 -1
  140. package/dist/services/auth-service-internal-helpers.js.map +1 -1
  141. package/dist/services/auth.service.d.ts +94 -438
  142. package/dist/services/auth.service.d.ts.map +1 -1
  143. package/dist/services/auth.service.js +388 -1255
  144. package/dist/services/auth.service.js.map +1 -1
  145. package/dist/services/mfa-base.service.d.ts +14 -4
  146. package/dist/services/mfa-base.service.d.ts.map +1 -1
  147. package/dist/services/mfa-base.service.js +22 -1
  148. package/dist/services/mfa-base.service.js.map +1 -1
  149. package/dist/services/mfa.service.d.ts +107 -33
  150. package/dist/services/mfa.service.d.ts.map +1 -1
  151. package/dist/services/mfa.service.js +456 -333
  152. package/dist/services/mfa.service.js.map +1 -1
  153. package/dist/services/social-auth.service.d.ts +7 -0
  154. package/dist/services/social-auth.service.d.ts.map +1 -1
  155. package/dist/services/social-auth.service.js +38 -26
  156. package/dist/services/social-auth.service.js.map +1 -1
  157. package/dist/services/user.service.d.ts +3 -3
  158. package/dist/services/user.service.d.ts.map +1 -1
  159. package/dist/services/user.service.js +7 -7
  160. package/dist/services/user.service.js.map +1 -1
  161. package/dist/utils/dto-validator.d.ts.map +1 -1
  162. package/dist/utils/dto-validator.js +50 -4
  163. package/dist/utils/dto-validator.js.map +1 -1
  164. package/dist/utils/setup/init-services.d.ts +2 -1
  165. package/dist/utils/setup/init-services.d.ts.map +1 -1
  166. package/dist/utils/setup/init-services.js +2 -0
  167. package/dist/utils/setup/init-services.js.map +1 -1
  168. package/package.json +1 -1
  169. package/dist/dto/change-password-request.dto.d.ts +0 -43
  170. package/dist/dto/change-password-request.dto.d.ts.map +0 -1
  171. package/dist/dto/change-password-request.dto.js.map +0 -1
  172. package/dist/dto/update-user-attributes-request.dto.d.ts +0 -44
  173. package/dist/dto/update-user-attributes-request.dto.d.ts.map +0 -1
  174. package/dist/dto/update-user-attributes-request.dto.js.map +0 -1
@@ -8,6 +8,7 @@ const auth_challenge_dto_1 = require("../dto/auth-challenge.dto");
8
8
  const auth_audit_event_type_enum_1 = require("../enums/auth-audit-event-type.enum");
9
9
  const dto_validator_1 = require("../utils/dto-validator");
10
10
  const class_validator_1 = require("class-validator");
11
+ const context_storage_1 = require("../utils/context-storage");
11
12
  const dto_1 = require("../dto");
12
13
  /**
13
14
  * MFA Service Registry
@@ -33,7 +34,7 @@ const dto_1 = require("../dto");
33
34
  * @Post('mfa/verify')
34
35
  * async verifyMFA(@Body() dto: { method: string; code: string }) {
35
36
  * const provider = this.mfaService.getProvider(dto.method);
36
- * return await provider.verify(user, dto.code);
37
+ * return await provider.verify(dto.code);
37
38
  * }
38
39
  * }
39
40
  * ```
@@ -48,6 +49,323 @@ class MFAService {
48
49
  clientInfoService;
49
50
  hookRegistry;
50
51
  providers = new Map();
52
+ // ============================================================================
53
+ // MFA Status (User + Admin)
54
+ // ============================================================================
55
+ /**
56
+ * Shared implementation for retrieving MFA status by target user sub.
57
+ *
58
+ * @param sub - Target user sub (UUID v4)
59
+ * @returns Comprehensive MFA status
60
+ */
61
+ async getMfaStatusBySub(sub) {
62
+ // Get user entity with MFA-related fields
63
+ // Note: mfaExemptGrantedBy is intentionally excluded as it's sensitive admin information
64
+ const userEntity = await this.userRepository.findOne({
65
+ select: [
66
+ 'id',
67
+ 'mfaEnabled',
68
+ 'backupCodes',
69
+ 'preferredMfaMethod',
70
+ 'mfaExempt',
71
+ 'mfaExemptReason',
72
+ 'mfaExemptGrantedAt',
73
+ ],
74
+ where: { sub },
75
+ });
76
+ if (!userEntity) {
77
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
78
+ }
79
+ const enabled = userEntity.mfaEnabled || false;
80
+ // Get available methods (all registered & allowed methods)
81
+ const availableMethodsResult = await this.getAvailableMethods({ sub });
82
+ // Add 'backup' to available methods if backup codes are enabled in config
83
+ const finalAvailableMethods = [...availableMethodsResult.availableMethods];
84
+ if (this.config?.mfa?.backup?.enabled) {
85
+ if (!finalAvailableMethods.includes(mfa_method_enum_1.MFAMethod.BACKUP)) {
86
+ finalAvailableMethods.push(mfa_method_enum_1.MFAMethod.BACKUP);
87
+ }
88
+ }
89
+ // Get user's configured devices for the target user
90
+ const devicesResult = await this.mfaDeviceRepository.find({
91
+ where: { userId: userEntity.id, isActive: true },
92
+ order: { createdAt: 'DESC' },
93
+ });
94
+ const configuredMethods = [
95
+ ...new Set(devicesResult.filter((d) => d.isActive).map((d) => d.type)),
96
+ ];
97
+ // Determine if MFA is required based on config and user state
98
+ const required = enabled && configuredMethods.length > 0;
99
+ // Check backup codes
100
+ const hasBackupCodes = !!userEntity.backupCodes?.length;
101
+ return {
102
+ enabled,
103
+ required,
104
+ configuredMethods,
105
+ availableMethods: finalAvailableMethods,
106
+ hasBackupCodes,
107
+ preferredMethod: userEntity.preferredMfaMethod,
108
+ mfaExempt: userEntity.mfaExempt || false,
109
+ mfaExemptReason: (userEntity.mfaExemptReason ?? null),
110
+ mfaExemptGrantedAt: (userEntity.mfaExemptGrantedAt ??
111
+ null),
112
+ };
113
+ }
114
+ // ============================================================================
115
+ // Internal helpers (shared by user + admin APIs)
116
+ // ============================================================================
117
+ /**
118
+ * Fetch active MFA devices for a given internal user ID.
119
+ *
120
+ * @param userId - Internal DB user ID
121
+ * @returns Active MFA devices
122
+ */
123
+ async getActiveDevicesForUserId(userId) {
124
+ const devices = await this.mfaDeviceRepository.find({
125
+ where: { userId, isActive: true },
126
+ order: { createdAt: 'DESC' },
127
+ });
128
+ return devices;
129
+ }
130
+ /**
131
+ * Resolve a target user by `sub` (admin-style targeting).
132
+ *
133
+ * @param sub - Target user sub (UUID v4)
134
+ * @returns User entity
135
+ * @throws {NAuthException} NOT_FOUND when user is not found
136
+ */
137
+ async getUserBySubOrThrow(sub) {
138
+ const user = (await this.userRepository.findOne({
139
+ where: { sub },
140
+ }));
141
+ if (!user) {
142
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
143
+ }
144
+ return user;
145
+ }
146
+ /**
147
+ * Shared implementation for removing MFA devices.
148
+ *
149
+ * @param targetUser - Target user (self-service or admin target)
150
+ * @param methodType - MFA method to remove (normalized)
151
+ * @param removedBy - Actor performing the removal
152
+ */
153
+ async removeDevicesInternal(targetUser, methodType, removedBy) {
154
+ const userId = targetUser.id;
155
+ const preferredMethod = targetUser.preferredMfaMethod;
156
+ const isPreferredMethod = preferredMethod === methodType;
157
+ // Get all active devices for this user
158
+ const activeDevices = await this.getActiveDevicesForUserId(userId);
159
+ // Get devices of the method type to remove
160
+ const devicesToRemove = activeDevices.filter((d) => d.type.toLowerCase() === methodType);
161
+ if (devicesToRemove.length === 0) {
162
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `No active ${methodType} MFA devices found for this user`);
163
+ }
164
+ // Delete all devices of this method type
165
+ let deletedCount = 0;
166
+ for (const device of devicesToRemove) {
167
+ const result = await this.mfaDeviceRepository.delete(device.id);
168
+ deletedCount += result.affected || 0;
169
+ }
170
+ // Check if any devices remain after removal
171
+ const remainingActiveDevices = await this.getActiveDevicesForUserId(userId);
172
+ let mfaDisabled = false;
173
+ // If no active devices remain, disable MFA for user
174
+ if (remainingActiveDevices.length === 0) {
175
+ await this.userRepository.update({ id: userId }, {
176
+ mfaEnabled: false,
177
+ mfaMethods: [],
178
+ preferredMfaMethod: null,
179
+ });
180
+ mfaDisabled = true;
181
+ // ============================================================================
182
+ // Audit: Record MFA disabled (all devices removed)
183
+ // ============================================================================
184
+ if (this.auditService && this.clientInfoService) {
185
+ try {
186
+ await this.auditService?.recordEvent({
187
+ userId,
188
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DISABLED,
189
+ eventStatus: 'INFO',
190
+ reason: removedBy === 'admin' ? 'admin_action' : 'all_devices_removed',
191
+ description: removedBy === 'admin'
192
+ ? 'MFA disabled by admin - all devices removed'
193
+ : 'MFA disabled - all devices removed',
194
+ // Client info automatically included from context
195
+ metadata: {
196
+ removedMethod: methodType,
197
+ deletedCount,
198
+ removedBy,
199
+ },
200
+ });
201
+ }
202
+ catch (auditError) {
203
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
204
+ this.logger?.error?.(`Failed to record MFA_DISABLED audit event: ${errorMessage}`, {
205
+ error: auditError,
206
+ userId,
207
+ });
208
+ }
209
+ }
210
+ // Automatically create MFA_SETUP_REQUIRED challenge if MFA enforcement requires it
211
+ if (this.challengeService && this.config?.mfa?.enabled) {
212
+ const enforcement = this.config.mfa.enforcement || 'OPTIONAL';
213
+ if (enforcement === 'REQUIRED' || enforcement === 'ADAPTIVE') {
214
+ try {
215
+ await this.challengeService.createChallengeSession(targetUser, auth_challenge_dto_1.AuthChallenge.MFA_SETUP_REQUIRED, {
216
+ allowedMethods: this.config.mfa.allowedMethods || [],
217
+ requiresSetup: true,
218
+ });
219
+ this.logger?.log?.(`Created MFA_SETUP_REQUIRED challenge for user ${targetUser.sub} after MFA removal`);
220
+ }
221
+ catch (error) {
222
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
223
+ this.logger?.warn?.(`Failed to create MFA_SETUP_REQUIRED challenge after MFA removal: ${errorMessage}`);
224
+ }
225
+ }
226
+ }
227
+ }
228
+ else {
229
+ // Update mfaMethods array with remaining methods
230
+ const remainingMethods = [...new Set(remainingActiveDevices.map((d) => d.type))];
231
+ // If the removed method was preferred, update preferred method and device primary flags
232
+ if (isPreferredMethod) {
233
+ const newPreferredMethod = remainingActiveDevices[0].type;
234
+ await this.userRepository.update({ id: userId }, {
235
+ mfaMethods: remainingMethods,
236
+ preferredMfaMethod: newPreferredMethod,
237
+ });
238
+ // Update device primary flags - set first remaining device as primary
239
+ if (remainingActiveDevices[0].id) {
240
+ await this.mfaDeviceRepository.update({ id: remainingActiveDevices[0].id }, { isPrimary: true });
241
+ }
242
+ // Unset primary flag on other devices
243
+ for (let i = 1; i < remainingActiveDevices.length; i++) {
244
+ if (remainingActiveDevices[i].id) {
245
+ await this.mfaDeviceRepository.update({ id: remainingActiveDevices[i].id }, { isPrimary: false });
246
+ }
247
+ }
248
+ this.logger?.log?.(`Updated preferred MFA method to ${newPreferredMethod} after removing ${methodType}`);
249
+ }
250
+ else {
251
+ // No preferred method change needed, just update mfaMethods
252
+ await this.userRepository.update({ id: userId }, {
253
+ mfaMethods: remainingMethods,
254
+ });
255
+ }
256
+ }
257
+ // ============================================================================
258
+ // Audit: Record MFA device removal
259
+ // ============================================================================
260
+ if (deletedCount > 0 && this.auditService && this.clientInfoService) {
261
+ try {
262
+ await this.auditService?.recordEvent({
263
+ userId,
264
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DEVICE_REMOVED,
265
+ eventStatus: 'INFO',
266
+ metadata: {
267
+ method: methodType,
268
+ deletedCount,
269
+ remainingDevices: remainingActiveDevices.length,
270
+ mfaDisabled,
271
+ removedBy,
272
+ },
273
+ // Client info automatically included from context
274
+ });
275
+ }
276
+ catch (auditError) {
277
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
278
+ this.logger?.error?.(`Failed to record MFA_DEVICE_REMOVED audit event: ${errorMessage}`, {
279
+ error: auditError,
280
+ userId,
281
+ method: methodType,
282
+ });
283
+ }
284
+ }
285
+ // ============================================================================
286
+ // Lifecycle Hook: MFA Device Removed
287
+ // ============================================================================
288
+ if (deletedCount > 0 && this.hookRegistry && this.clientInfoService) {
289
+ try {
290
+ const clientInfo = this.clientInfoService.get();
291
+ await this.hookRegistry.executeMFADeviceRemoved({
292
+ user: targetUser,
293
+ deviceType: methodType,
294
+ removedBy,
295
+ remainingDeviceCount: remainingActiveDevices.length,
296
+ clientInfo: {
297
+ ipAddress: clientInfo.ipAddress,
298
+ userAgent: clientInfo.userAgent,
299
+ ipCountry: clientInfo.ipCountry,
300
+ ipCity: clientInfo.ipCity,
301
+ },
302
+ });
303
+ }
304
+ catch (hookError) {
305
+ const errorMessage = hookError instanceof Error ? hookError.message : 'Unknown error';
306
+ this.logger?.error?.(`Failed to execute mfaDeviceRemoved hooks: ${errorMessage}`, {
307
+ error: hookError,
308
+ userId,
309
+ method: methodType,
310
+ });
311
+ }
312
+ }
313
+ return { deletedCount, mfaDisabled };
314
+ }
315
+ /**
316
+ * Shared implementation for setting preferred MFA method.
317
+ *
318
+ * @param targetUser - Target user (self-service or admin target)
319
+ * @param methodType - Preferred method (normalized)
320
+ * @param updatedBy - Actor performing the update
321
+ */
322
+ async setPreferredMethodInternal(targetUser, methodType, updatedBy) {
323
+ // Verify user has this method configured
324
+ const activeDevices = await this.getActiveDevicesForUserId(targetUser.id);
325
+ const preferredDevice = activeDevices.find((d) => d.type.toLowerCase() === methodType && d.isActive);
326
+ if (!preferredDevice) {
327
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `MFA method '${methodType}' is not configured for this user`);
328
+ }
329
+ // Update user's preferred method
330
+ await this.userRepository.update({ id: targetUser.id }, {
331
+ preferredMfaMethod: methodType,
332
+ });
333
+ // Update device isPrimary flags: set preferred device as primary, unset others
334
+ for (const device of activeDevices) {
335
+ await this.mfaDeviceRepository.update({ id: device.id }, { isPrimary: device.id === preferredDevice.id });
336
+ }
337
+ this.logger?.log?.(`Device ${preferredDevice.id} set as primary for user ${targetUser.sub} (by ${updatedBy})`);
338
+ // ============================================================================
339
+ // Audit: Record preferred MFA method update
340
+ // ============================================================================
341
+ if (this.auditService && this.clientInfoService) {
342
+ try {
343
+ const previousMethod = targetUser.preferredMfaMethod;
344
+ await this.auditService?.recordEvent({
345
+ userId: targetUser.id,
346
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_PREFERRED_METHOD_UPDATED,
347
+ eventStatus: 'INFO',
348
+ metadata: {
349
+ previousMethod: previousMethod || null,
350
+ newMethod: methodType,
351
+ deviceId: preferredDevice.id,
352
+ updatedBy,
353
+ },
354
+ });
355
+ }
356
+ catch (auditError) {
357
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
358
+ this.logger?.error?.(`Failed to record MFA_PREFERRED_METHOD_UPDATED audit event: ${errorMessage}`, {
359
+ error: auditError,
360
+ userId: targetUser.id,
361
+ method: methodType,
362
+ });
363
+ }
364
+ }
365
+ return {
366
+ message: 'Preferred method updated',
367
+ };
368
+ }
51
369
  /**
52
370
  * Resolve a user entity by flexible identifier.
53
371
  *
@@ -98,6 +416,51 @@ class MFAService {
98
416
  this.clientInfoService = clientInfoService;
99
417
  this.hookRegistry = hookRegistry;
100
418
  }
419
+ /**
420
+ * Get current user from authenticated context
421
+ *
422
+ * @returns Current authenticated user
423
+ * @throws {NAuthException} If user not found in context
424
+ */
425
+ getCurrentUserOrThrow() {
426
+ const currentUser = context_storage_1.ContextStorage.get('CURRENT_USER');
427
+ if (!currentUser) {
428
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.FORBIDDEN, 'Authentication required');
429
+ }
430
+ return currentUser;
431
+ }
432
+ /**
433
+ * Execute a callback with a specific user bound into CURRENT_USER context.
434
+ *
435
+ * This is required for flows where the user is resolved outside of request auth context
436
+ * (e.g., challenge sessions) but providers must still derive the user from context.
437
+ *
438
+ * @param user - User to bind into context
439
+ * @param callback - Callback to execute
440
+ * @returns Callback result
441
+ */
442
+ async withUserContext(user, callback) {
443
+ const store = context_storage_1.ContextStorage.getStore();
444
+ if (!store) {
445
+ return await context_storage_1.ContextStorage.run(async () => {
446
+ context_storage_1.ContextStorage.set('CURRENT_USER', user);
447
+ return await callback();
448
+ });
449
+ }
450
+ const previousUser = context_storage_1.ContextStorage.get('CURRENT_USER');
451
+ context_storage_1.ContextStorage.set('CURRENT_USER', user);
452
+ try {
453
+ return await callback();
454
+ }
455
+ finally {
456
+ if (previousUser) {
457
+ context_storage_1.ContextStorage.set('CURRENT_USER', previousUser);
458
+ }
459
+ else {
460
+ context_storage_1.ContextStorage.delete('CURRENT_USER');
461
+ }
462
+ }
463
+ }
101
464
  /**
102
465
  * Register an MFA provider
103
466
  *
@@ -260,9 +623,11 @@ class MFAService {
260
623
  }
261
624
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Backup code verification not available');
262
625
  }
263
- // Get provider and verify
626
+ // Get provider and verify (provider derives user from context)
264
627
  const provider = this.getProvider(dto.methodName);
265
- const isValid = await provider.verify(user, dto.code, dto.deviceId);
628
+ const isValid = await this.withUserContext(user, async () => {
629
+ return await provider.verify(dto.code, dto.deviceId);
630
+ });
266
631
  return { valid: isValid };
267
632
  }
268
633
  /**
@@ -282,14 +647,9 @@ class MFAService {
282
647
  */
283
648
  async setup(dto) {
284
649
  dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.SetupMFADTO, dto);
285
- // Look up user by sub
286
- const userEntity = await this.userRepository.findOne({ where: { sub: dto.sub } });
287
- if (!userEntity) {
288
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
289
- }
290
- const user = userEntity;
650
+ // Get user from authenticated context (already has all fields)
291
651
  const provider = this.getProvider(dto.methodName);
292
- const setupData = await provider.setup(user, dto.setupData);
652
+ const setupData = await provider.setup(dto.setupData);
293
653
  return {
294
654
  setupData: setupData,
295
655
  };
@@ -297,110 +657,49 @@ class MFAService {
297
657
  /**
298
658
  * Get user's MFA devices
299
659
  *
300
- * @param dto - Request DTO with user sub
660
+ * User self-service method: current user is derived from authenticated context.
661
+ *
662
+ * @param _dto - Optional (empty) DTO for validation consistency
301
663
  * @returns Response DTO with array of MFA devices
302
664
  *
303
665
  * @example
304
666
  * ```typescript
305
- * const result = await this.mfaService.getUserDevices({ sub: user.sub });
667
+ * const result = await this.mfaService.getUserDevices();
306
668
  * // Returns: { devices: [...] }
307
669
  * ```
308
670
  */
309
- async getUserDevices(dto) {
310
- dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.GetUserDevicesDTO, dto);
311
- // Look up user by sub to get internal ID
312
- const userEntity = await this.userRepository.findOne({ where: { sub: dto.sub } });
313
- if (!userEntity) {
314
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
315
- }
316
- // Only fetch active devices (inactive devices are soft-deleted)
317
- const devices = await this.mfaDeviceRepository.find({
318
- where: { userId: userEntity.id, isActive: true },
319
- order: { createdAt: 'DESC' },
320
- });
671
+ async getUserDevices(_dto = {}) {
672
+ await (0, dto_validator_1.ensureValidatedDto)(dto_1.GetUserDevicesDTO, _dto);
673
+ // Get user from authenticated context (already has id and sub)
674
+ const currentUser = this.getCurrentUserOrThrow();
321
675
  return {
322
- devices: devices,
676
+ devices: await this.getActiveDevicesForUserId(currentUser.id),
323
677
  };
324
678
  }
325
679
  /**
326
- * Get comprehensive MFA status for a user
680
+ * Get comprehensive MFA status for the current authenticated user (self-service).
327
681
  *
328
- * Returns complete MFA configuration status including:
329
- * - Whether MFA is enabled/required
330
- * - Configured and available methods
331
- * - Preferred method
332
- * - Backup codes status
333
- * - MFA exemption information
334
- *
335
- * This method encapsulates all business logic for MFA status,
336
- * ensuring consumer apps don't need to query databases or build responses manually.
337
- *
338
- * @param dto - Request DTO with user sub
339
682
  * @returns Response DTO with complete MFA status
683
+ */
684
+ async getMfaStatus() {
685
+ const currentUser = this.getCurrentUserOrThrow();
686
+ return await this.getMfaStatusBySub(currentUser.sub);
687
+ }
688
+ /**
689
+ * Get comprehensive MFA status for a target user (admin-only).
340
690
  *
341
- * @example
342
- * ```typescript
343
- * @Get('mfa/status')
344
- * async getMFAStatus(@CurrentUser() user: IUser) {
345
- * return await this.mfaService.getMFAStatus({ sub: user.sub });
346
- * }
347
- * ```
691
+ * @param dto - Admin request DTO with target user sub
692
+ * @returns Response DTO with complete MFA status
348
693
  */
349
- async getMFAStatus(dto) {
350
- dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.GetMFAStatusDTO, dto);
351
- // Get user entity with MFA-related fields
352
- // Note: mfaExemptGrantedBy is intentionally excluded as it's sensitive admin information
353
- const userEntity = await this.userRepository.findOne({
354
- select: [
355
- 'id',
356
- 'mfaEnabled',
357
- 'backupCodes',
358
- 'preferredMfaMethod',
359
- 'mfaExempt',
360
- 'mfaExemptReason',
361
- 'mfaExemptGrantedAt',
362
- ],
363
- where: { sub: dto.sub },
364
- });
365
- if (!userEntity) {
366
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
367
- }
368
- const enabled = userEntity.mfaEnabled || false;
369
- // Get available methods (all registered & allowed methods)
370
- const availableMethodsResult = await this.getAvailableMethods({ sub: dto.sub });
371
- // Add 'backup' to available methods if backup codes are enabled in config
372
- const finalAvailableMethods = [...availableMethodsResult.availableMethods];
373
- if (this.config?.mfa?.backup?.enabled) {
374
- if (!finalAvailableMethods.includes(mfa_method_enum_1.MFAMethod.BACKUP)) {
375
- finalAvailableMethods.push(mfa_method_enum_1.MFAMethod.BACKUP);
376
- }
377
- }
378
- // Get user's configured devices
379
- const devicesResult = await this.getUserDevices({ sub: dto.sub });
380
- const configuredMethods = [
381
- ...new Set(devicesResult.devices.filter((d) => d.isActive).map((d) => d.type)),
382
- ];
383
- // Determine if MFA is required based on config and user state
384
- const required = enabled && configuredMethods.length > 0;
385
- // Check backup codes
386
- const hasBackupCodes = !!userEntity.backupCodes && userEntity.backupCodes.length > 0;
387
- return {
388
- enabled,
389
- required,
390
- configuredMethods,
391
- availableMethods: finalAvailableMethods,
392
- hasBackupCodes,
393
- preferredMethod: userEntity.preferredMfaMethod,
394
- mfaExempt: userEntity.mfaExempt || false,
395
- mfaExemptReason: userEntity.mfaExemptReason || null,
396
- mfaExemptGrantedAt: userEntity.mfaExemptGrantedAt || null,
397
- };
694
+ async adminGetMfaStatus(dto) {
695
+ dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.AdminGetMFAStatusDTO, dto);
696
+ return await this.getMfaStatusBySub(dto.sub);
398
697
  }
399
698
  /**
400
699
  * Remove MFA devices by method type
401
700
  *
402
701
  * Comprehensive method that handles all aspects of MFA device removal:
403
- * - Looks up user by sub (consumer apps should pass user.sub from @CurrentUser())
702
+ * - Uses the authenticated user context (self-service)
404
703
  * - Validates method type
405
704
  * - Removes all active devices of the specified method type
406
705
  * - Updates user's preferred method if the removed method was preferred
@@ -411,7 +710,7 @@ class MFAService {
411
710
  * This method encapsulates all database operations related to MFA device removal,
412
711
  * ensuring the consumer app doesn't need to directly manipulate nauth_* tables.
413
712
  *
414
- * @param dto - Request DTO with user sub and method type
713
+ * @param dto - Request DTO with method type
415
714
  * @returns Response DTO with deletedCount and whether MFA was disabled
416
715
  * @throws {NAuthException} If user not found, invalid method type, or no devices found
417
716
  *
@@ -420,189 +719,43 @@ class MFAService {
420
719
  * // Consumer app controller
421
720
  * @Delete('mfa/devices/:method')
422
721
  * async removeMFAMethod(@CurrentUser() user: IUser, @Param('method') method: string) {
423
- * const result = await this.mfaService.removeDevices({ userSub: user.sub, methodType: method });
722
+ * const result = await this.mfaService.removeDevices({ methodType: method });
424
723
  * return { message: 'MFA method removed successfully', ...result };
425
724
  * }
426
725
  * ```
427
726
  */
428
727
  async removeDevices(dto) {
429
728
  dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.RemoveDevicesDTO, dto);
430
- // Validate method type
431
729
  const validMethods = [mfa_method_enum_1.MFAMethod.TOTP, mfa_method_enum_1.MFAMethod.SMS, mfa_method_enum_1.MFAMethod.EMAIL, mfa_method_enum_1.MFAMethod.PASSKEY];
432
730
  const normalizedMethod = dto.methodType.toLowerCase();
433
731
  if (!validMethods.includes(normalizedMethod)) {
434
732
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Invalid MFA method: ${dto.methodType}. Valid methods are: ${validMethods.join(', ')}`);
435
733
  }
436
- // Look up user by sub using repository directly (no AuthService dependency needed)
437
- const userEntity = await this.userRepository.findOne({ where: { sub: dto.userSub } });
438
- if (!userEntity) {
439
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User entity not found');
440
- }
441
- const userId = userEntity.id;
442
- if (!userId) {
443
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'User entity missing internal ID');
444
- }
445
- // Cast to IUser for type safety
446
- const user = userEntity;
447
- const preferredMethod = userEntity.preferredMfaMethod;
448
- const isPreferredMethod = preferredMethod === normalizedMethod;
449
- // Get all active devices for this user
450
- const devicesResult = await this.getUserDevices({ sub: dto.userSub });
451
- const activeDevices = devicesResult.devices.filter((d) => d.isActive);
452
- // Get devices of the method type to remove
453
- const devicesToRemove = activeDevices.filter((d) => d.type.toLowerCase() === normalizedMethod);
454
- if (devicesToRemove.length === 0) {
455
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `No active ${normalizedMethod} MFA devices found for this user`);
456
- }
457
- // Delete all devices of this method type
458
- let deletedCount = 0;
459
- for (const device of devicesToRemove) {
460
- const result = await this.mfaDeviceRepository.delete(device.id);
461
- deletedCount += result.affected || 0;
462
- }
463
- // Check if any devices remain after removal
464
- const remainingDevicesResult = await this.getUserDevices({ sub: dto.userSub });
465
- const remainingActiveDevices = remainingDevicesResult.devices.filter((d) => d.isActive);
466
- let mfaDisabled = false;
467
- // If no active devices remain, disable MFA for user
468
- if (remainingActiveDevices.length === 0) {
469
- userEntity.mfaEnabled = false;
470
- userEntity.mfaMethods = [];
471
- userEntity.preferredMfaMethod = null;
472
- await this.userRepository.save(userEntity);
473
- mfaDisabled = true;
474
- // ============================================================================
475
- // Audit: Record MFA disabled (all devices removed)
476
- // ============================================================================
477
- if (this.auditService && this.clientInfoService) {
478
- try {
479
- await this.auditService?.recordEvent({
480
- userId: user.id,
481
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DISABLED,
482
- eventStatus: 'INFO',
483
- reason: 'all_devices_removed',
484
- description: 'MFA disabled - all devices removed',
485
- // Client info automatically included from context
486
- metadata: {
487
- removedMethod: normalizedMethod,
488
- deletedCount,
489
- },
490
- });
491
- }
492
- catch (auditError) {
493
- // Non-blocking: Log but continue
494
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
495
- this.logger?.error?.(`Failed to record MFA_DISABLED audit event: ${errorMessage}`, {
496
- error: auditError,
497
- userId: user.id,
498
- });
499
- }
500
- }
501
- // Automatically create MFA_SETUP_REQUIRED challenge if MFA enforcement requires it
502
- if (this.challengeService && this.config?.mfa?.enabled) {
503
- const enforcement = this.config.mfa.enforcement || 'OPTIONAL';
504
- if (enforcement === 'REQUIRED' || enforcement === 'ADAPTIVE') {
505
- const user = userEntity;
506
- try {
507
- // Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
508
- await this.challengeService.createChallengeSession(user, auth_challenge_dto_1.AuthChallenge.MFA_SETUP_REQUIRED, {
509
- allowedMethods: this.config.mfa.allowedMethods || [],
510
- requiresSetup: true,
511
- });
512
- this.logger?.log?.(`Created MFA_SETUP_REQUIRED challenge for user ${user.sub} after MFA removal`);
513
- }
514
- catch (error) {
515
- // Log but don't fail the removal if challenge creation fails
516
- this.logger?.warn?.(`Failed to create MFA_SETUP_REQUIRED challenge after MFA removal: ${error}`);
517
- }
518
- }
519
- }
520
- }
521
- else {
522
- // Update mfaMethods array with remaining methods
523
- const remainingMethods = [...new Set(remainingActiveDevices.map((d) => d.type))];
524
- userEntity.mfaMethods = remainingMethods;
525
- // If the removed method was preferred, update preferred method and device primary flags
526
- if (isPreferredMethod) {
527
- const newPreferredMethod = remainingActiveDevices[0].type;
528
- userEntity.preferredMfaMethod = newPreferredMethod;
529
- await this.userRepository.save(userEntity);
530
- // Update device primary flags - set first remaining device as primary
531
- if (remainingActiveDevices[0].id) {
532
- await this.mfaDeviceRepository.update({ id: remainingActiveDevices[0].id }, { isPrimary: true });
533
- }
534
- // Unset primary flag on other devices
535
- for (let i = 1; i < remainingActiveDevices.length; i++) {
536
- if (remainingActiveDevices[i].id) {
537
- await this.mfaDeviceRepository.update({ id: remainingActiveDevices[i].id }, { isPrimary: false });
538
- }
539
- }
540
- this.logger?.log?.(`Updated preferred MFA method to ${newPreferredMethod} after removing ${normalizedMethod}`);
541
- }
542
- else {
543
- // No preferred method change needed, just update mfaMethods
544
- await this.userRepository.save(userEntity);
545
- }
546
- }
547
- // ============================================================================
548
- // Audit: Record MFA device removal
549
- // ============================================================================
550
- if (deletedCount > 0 && this.auditService && this.clientInfoService) {
551
- try {
552
- const user = userEntity;
553
- await this.auditService?.recordEvent({
554
- userId: user.id,
555
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DEVICE_REMOVED,
556
- eventStatus: 'INFO',
557
- metadata: {
558
- method: normalizedMethod,
559
- deletedCount,
560
- remainingDevices: remainingActiveDevices.length,
561
- mfaDisabled,
562
- },
563
- // Client info automatically included from context
564
- });
565
- }
566
- catch (auditError) {
567
- // Non-blocking: Log but continue
568
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
569
- this.logger?.error?.(`Failed to record MFA_DEVICE_REMOVED audit event: ${errorMessage}`, {
570
- error: auditError,
571
- userId: user.id,
572
- method: normalizedMethod,
573
- });
574
- }
575
- }
576
- // ============================================================================
577
- // Lifecycle Hook: MFA Device Removed
578
- // ============================================================================
579
- if (deletedCount > 0 && this.hookRegistry && this.clientInfoService) {
580
- try {
581
- const clientInfo = this.clientInfoService.get();
582
- await this.hookRegistry.executeMFADeviceRemoved({
583
- user,
584
- deviceType: normalizedMethod,
585
- removedBy: 'user',
586
- remainingDeviceCount: remainingActiveDevices.length,
587
- clientInfo: {
588
- ipAddress: clientInfo.ipAddress,
589
- userAgent: clientInfo.userAgent,
590
- ipCountry: clientInfo.ipCountry,
591
- ipCity: clientInfo.ipCity,
592
- },
593
- });
594
- }
595
- catch (hookError) {
596
- // Non-blocking: Log but continue
597
- const errorMessage = hookError instanceof Error ? hookError.message : 'Unknown error';
598
- this.logger?.error?.(`Failed to execute mfaDeviceRemoved hooks: ${errorMessage}`, {
599
- error: hookError,
600
- userId: user.id,
601
- method: normalizedMethod,
602
- });
603
- }
734
+ const currentUser = this.getCurrentUserOrThrow();
735
+ return await this.removeDevicesInternal(currentUser, normalizedMethod, 'user');
736
+ }
737
+ /**
738
+ * Admin: Remove MFA devices for a specific user by `sub`.
739
+ *
740
+ * @param dto - Admin DTO containing target `sub` and method type
741
+ * @returns Removal result
742
+ * @throws {NAuthException} NOT_FOUND when user is not found
743
+ * @throws {NAuthException} VALIDATION_FAILED on invalid method type
744
+ *
745
+ * @example
746
+ * ```typescript
747
+ * await mfaService.adminRemoveDevices({ sub: 'user-uuid', methodType: 'totp' });
748
+ * ```
749
+ */
750
+ async adminRemoveDevices(dto) {
751
+ dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.AdminRemoveDevicesDTO, dto);
752
+ const validMethods = [mfa_method_enum_1.MFAMethod.TOTP, mfa_method_enum_1.MFAMethod.SMS, mfa_method_enum_1.MFAMethod.EMAIL, mfa_method_enum_1.MFAMethod.PASSKEY];
753
+ const normalizedMethod = dto.methodType.toLowerCase();
754
+ if (!validMethods.includes(normalizedMethod)) {
755
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Invalid MFA method: ${dto.methodType}. Valid methods are: ${validMethods.join(', ')}`);
604
756
  }
605
- return { deletedCount, mfaDisabled };
757
+ const targetUser = await this.getUserBySubOrThrow(dto.sub);
758
+ return await this.removeDevicesInternal(targetUser, normalizedMethod, 'admin');
606
759
  }
607
760
  /**
608
761
  * Set preferred MFA method for a user
@@ -613,7 +766,7 @@ class MFAService {
613
766
  * This method encapsulates all database operations related to preferred method updates,
614
767
  * ensuring the consumer app doesn't need to directly manipulate nauth_* tables.
615
768
  *
616
- * @param dto - Request DTO with user sub and method type
769
+ * @param dto - Request DTO with method type
617
770
  * @returns Response DTO with success message
618
771
  * @throws {NAuthException} If user not found, invalid method type, or method not configured
619
772
  *
@@ -622,7 +775,7 @@ class MFAService {
622
775
  * // Consumer app controller
623
776
  * @Put('mfa/preferred')
624
777
  * async setPreferredMFAMethod(@CurrentUser() user: IUser, @Body() body: { method: string }) {
625
- * return await this.mfaService.setPreferredMethod({ userSub: user.sub, methodType: body.method });
778
+ * return await this.mfaService.setPreferredMethod({ methodType: body.method });
626
779
  * }
627
780
  * ```
628
781
  */
@@ -634,65 +787,31 @@ class MFAService {
634
787
  if (!validMethods.includes(normalizedMethod)) {
635
788
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Invalid MFA method: ${dto.methodType}. Valid methods are: ${validMethods.join(', ')}`);
636
789
  }
637
- // Look up user by sub using repository directly (no AuthService dependency needed)
638
- const userEntity = await this.userRepository.findOne({ where: { sub: dto.userSub } });
639
- if (!userEntity) {
640
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
641
- }
642
- const userId = userEntity.id;
643
- if (!userId) {
644
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'User entity missing internal ID');
645
- }
646
- // Cast to IUser for type safety
647
- const user = userEntity;
648
- // Verify user has this method configured
649
- const devicesResult = await this.getUserDevices({ sub: dto.userSub });
650
- // Normalize device types for comparison (database might store in different case)
651
- const preferredDevice = devicesResult.devices.find((d) => d.type.toLowerCase() === normalizedMethod && d.isActive);
652
- if (!preferredDevice) {
653
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `MFA method '${normalizedMethod}' is not configured for this user`);
654
- }
655
- // Update user's preferred method directly via repository
656
- await this.userRepository.update({ id: userId }, {
657
- preferredMfaMethod: normalizedMethod,
658
- });
659
- // Update device isPrimary flags: set preferred device as primary, unset others
660
- const activeDevices = devicesResult.devices.filter((d) => d.isActive);
661
- for (const device of activeDevices) {
662
- await this.mfaDeviceRepository.update({ id: device.id }, { isPrimary: device.id === preferredDevice.id });
663
- }
664
- this.logger?.log?.(`Device ${preferredDevice.id} set as primary for user ${dto.userSub}`);
665
- // ============================================================================
666
- // Audit: Record preferred MFA method update
667
- // ============================================================================
668
- if (this.auditService && this.clientInfoService) {
669
- try {
670
- const previousMethod = userEntity.preferredMfaMethod;
671
- await this.auditService?.recordEvent({
672
- userId: user.id,
673
- eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_PREFERRED_METHOD_UPDATED,
674
- eventStatus: 'INFO',
675
- metadata: {
676
- // Client info automatically included from context
677
- previousMethod: previousMethod || null,
678
- newMethod: normalizedMethod,
679
- deviceId: preferredDevice.id,
680
- },
681
- });
682
- }
683
- catch (auditError) {
684
- // Non-blocking: Log but continue
685
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
686
- this.logger?.error?.(`Failed to record MFA_PREFERRED_METHOD_UPDATED audit event: ${errorMessage}`, {
687
- error: auditError,
688
- userId: user.id,
689
- method: normalizedMethod,
690
- });
691
- }
790
+ const currentUser = this.getCurrentUserOrThrow();
791
+ return await this.setPreferredMethodInternal(currentUser, normalizedMethod, 'user');
792
+ }
793
+ /**
794
+ * Admin: Set preferred MFA method for a specific user by `sub`.
795
+ *
796
+ * @param dto - Admin DTO containing target `sub` and method type
797
+ * @returns Success response
798
+ * @throws {NAuthException} NOT_FOUND when user is not found
799
+ * @throws {NAuthException} VALIDATION_FAILED when method is invalid or not configured
800
+ *
801
+ * @example
802
+ * ```typescript
803
+ * await mfaService.adminSetPreferredMethod({ sub: 'user-uuid', methodType: 'sms' });
804
+ * ```
805
+ */
806
+ async adminSetPreferredMethod(dto) {
807
+ dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.AdminSetPreferredMethodDTO, dto);
808
+ const validMethods = [mfa_method_enum_1.MFAMethod.TOTP, mfa_method_enum_1.MFAMethod.SMS, mfa_method_enum_1.MFAMethod.EMAIL, mfa_method_enum_1.MFAMethod.PASSKEY];
809
+ const normalizedMethod = dto.methodType.toLowerCase();
810
+ if (!validMethods.includes(normalizedMethod)) {
811
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Invalid MFA method: ${dto.methodType}. Valid methods are: ${validMethods.join(', ')}`);
692
812
  }
693
- return {
694
- message: 'Preferred method updated',
695
- };
813
+ const targetUser = await this.getUserBySubOrThrow(dto.sub);
814
+ return await this.setPreferredMethodInternal(targetUser, normalizedMethod, 'admin');
696
815
  }
697
816
  /**
698
817
  * Grant or revoke a user's exemption from multi-factor authentication (MFA) requirements.
@@ -700,7 +819,7 @@ class MFAService {
700
819
  * SECURITY: This admin-only operation updates the user's MFA exemption status, logs the action,
701
820
  * and records an audit event. MFA exemption bypasses MFA at login, but all other security controls remain enforced.
702
821
  *
703
- * @param dto - Request DTO with identifier, exempt flag, reason, and grantedBy
822
+ * @param dto - Request DTO with sub, exempt flag, reason, and grantedBy
704
823
  * @returns Response DTO with updated exemption fields
705
824
  * @throws {NAuthException} If the user is not found
706
825
  *
@@ -708,7 +827,7 @@ class MFAService {
708
827
  * ```typescript
709
828
  * // Grant MFA exemption
710
829
  * await mfaService.setMFAExemption({
711
- * identifier: 'user@example.com',
830
+ * sub: 'a21b654c-2746-4168-acee-c175083a65cd',
712
831
  * exempt: true,
713
832
  * reason: 'Business partner requires MFA bypass',
714
833
  * grantedBy: 'admin@example.com'
@@ -716,7 +835,7 @@ class MFAService {
716
835
  *
717
836
  * // Revoke MFA exemption
718
837
  * await mfaService.setMFAExemption({
719
- * identifier: 'user@example.com',
838
+ * sub: 'a21b654c-2746-4168-acee-c175083a65cd',
720
839
  * exempt: false,
721
840
  * reason: 'MFA now mandatory for this user',
722
841
  * grantedBy: 'admin@example.com'
@@ -728,8 +847,8 @@ class MFAService {
728
847
  // ============================================================================
729
848
  // SECURITY: Resolve the TARGET user from the DTO (admin-only API)
730
849
  // ============================================================================
731
- // Use `identifier` (email/username/phone/sub).
732
- const userEntity = await this.findUserByIdentifier(dto.identifier);
850
+ // Use `sub` (UUID v4) per ADMIN_USER_API_SEPARATION_PLAN.md
851
+ const userEntity = await this.userRepository.findOne({ where: { sub: dto.sub } });
733
852
  if (!userEntity) {
734
853
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
735
854
  }
@@ -850,7 +969,9 @@ class MFAService {
850
969
  };
851
970
  this.logger?.debug?.(`Passing challengeSessionId=${challengeSession.id} to ${dto.method} provider for MFA setup`);
852
971
  const provider = this.getProvider(dto.method);
853
- const result = await provider.setup(user, setupDataWithSession);
972
+ const result = await this.withUserContext(user, async () => {
973
+ return await provider.setup(setupDataWithSession);
974
+ });
854
975
  this.logger?.debug?.(`MFA setup data generated: method=${dto.method}, user=${user.sub}`);
855
976
  return {
856
977
  setupData: result,
@@ -916,7 +1037,9 @@ class MFAService {
916
1037
  if (!provider.sendChallenge) {
917
1038
  throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `MFA method '${dto.method}' does not support challenge data generation`);
918
1039
  }
919
- const challengeData = await provider.sendChallenge(user);
1040
+ const challengeData = await this.withUserContext(user, async () => {
1041
+ return await provider.sendChallenge?.();
1042
+ });
920
1043
  // For passkey, store the challenge in session metadata for verification
921
1044
  if (dto.method === 'passkey') {
922
1045
  const passkeyOptions = challengeData;