@nauth-toolkit/core 0.1.86 → 0.1.88

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