@nauth-toolkit/core 0.1.108 → 0.1.111

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 (112) hide show
  1. package/dist/bootstrap.d.ts.map +1 -1
  2. package/dist/bootstrap.js.map +1 -1
  3. package/dist/dto/admin-remove-device.dto.d.ts +22 -0
  4. package/dist/dto/admin-remove-device.dto.d.ts.map +1 -0
  5. package/dist/dto/{admin-remove-devices.dto.js → admin-remove-device.dto.js} +21 -21
  6. package/dist/dto/admin-remove-device.dto.js.map +1 -0
  7. package/dist/dto/admin-set-preferred-device.dto.d.ts +32 -0
  8. package/dist/dto/admin-set-preferred-device.dto.d.ts.map +1 -0
  9. package/dist/dto/admin-set-preferred-device.dto.js +64 -0
  10. package/dist/dto/admin-set-preferred-device.dto.js.map +1 -0
  11. package/dist/dto/admin-signup-social.dto.d.ts +2 -2
  12. package/dist/dto/admin-signup.dto.d.ts +2 -2
  13. package/dist/dto/challenge-response.dto.d.ts +4 -0
  14. package/dist/dto/challenge-response.dto.d.ts.map +1 -1
  15. package/dist/dto/disable-user.dto.d.ts +2 -2
  16. package/dist/dto/enable-user.dto.d.ts +2 -2
  17. package/dist/dto/get-user-devices.dto.d.ts +5 -3
  18. package/dist/dto/get-user-devices.dto.d.ts.map +1 -1
  19. package/dist/dto/get-user-devices.dto.js +3 -1
  20. package/dist/dto/get-user-devices.dto.js.map +1 -1
  21. package/dist/dto/get-user-response.dto.d.ts +1 -1
  22. package/dist/dto/get-user-response.dto.js +1 -1
  23. package/dist/dto/get-users.dto.d.ts +2 -2
  24. package/dist/dto/index.d.ts +6 -4
  25. package/dist/dto/index.d.ts.map +1 -1
  26. package/dist/dto/index.js +6 -4
  27. package/dist/dto/index.js.map +1 -1
  28. package/dist/dto/mfa-device-response.dto.d.ts +68 -0
  29. package/dist/dto/mfa-device-response.dto.d.ts.map +1 -0
  30. package/dist/dto/mfa-device-response.dto.js +81 -0
  31. package/dist/dto/mfa-device-response.dto.js.map +1 -0
  32. package/dist/dto/remove-device.dto.d.ts +49 -0
  33. package/dist/dto/remove-device.dto.d.ts.map +1 -0
  34. package/dist/dto/remove-device.dto.js +76 -0
  35. package/dist/dto/remove-device.dto.js.map +1 -0
  36. package/dist/dto/respond-challenge.dto.d.ts +8 -0
  37. package/dist/dto/respond-challenge.dto.d.ts.map +1 -1
  38. package/dist/dto/respond-challenge.dto.js +14 -0
  39. package/dist/dto/respond-challenge.dto.js.map +1 -1
  40. package/dist/dto/set-preferred-device.dto.d.ts +46 -0
  41. package/dist/dto/set-preferred-device.dto.d.ts.map +1 -0
  42. package/dist/dto/set-preferred-device.dto.js +74 -0
  43. package/dist/dto/set-preferred-device.dto.js.map +1 -0
  44. package/dist/dto/setup-mfa.dto.d.ts.map +1 -1
  45. package/dist/dto/setup-mfa.dto.js.map +1 -1
  46. package/dist/dto/user-response.dto.d.ts +3 -3
  47. package/dist/dto/user-response.dto.js +5 -5
  48. package/dist/dto/verify-mfa-setup-response.dto.d.ts +18 -0
  49. package/dist/dto/verify-mfa-setup-response.dto.d.ts.map +1 -0
  50. package/dist/dto/verify-mfa-setup-response.dto.js +22 -0
  51. package/dist/dto/verify-mfa-setup-response.dto.js.map +1 -0
  52. package/dist/handlers/client-info.handler.d.ts.map +1 -1
  53. package/dist/handlers/client-info.handler.js.map +1 -1
  54. package/dist/interfaces/provider.interface.d.ts.map +1 -1
  55. package/dist/openapi/components.schemas.json +112 -140
  56. package/dist/services/admin-auth.service.d.ts +12 -5
  57. package/dist/services/admin-auth.service.d.ts.map +1 -1
  58. package/dist/services/admin-auth.service.js +46 -2
  59. package/dist/services/admin-auth.service.js.map +1 -1
  60. package/dist/services/auth-audit.service.d.ts.map +1 -1
  61. package/dist/services/auth-audit.service.js +1 -1
  62. package/dist/services/auth-audit.service.js.map +1 -1
  63. package/dist/services/auth-challenge-helper.service.d.ts.map +1 -1
  64. package/dist/services/auth-challenge-helper.service.js +34 -0
  65. package/dist/services/auth-challenge-helper.service.js.map +1 -1
  66. package/dist/services/auth-service-internal-helpers.d.ts.map +1 -1
  67. package/dist/services/auth-service-internal-helpers.js +4 -0
  68. package/dist/services/auth-service-internal-helpers.js.map +1 -1
  69. package/dist/services/auth.service.d.ts +9 -2
  70. package/dist/services/auth.service.d.ts.map +1 -1
  71. package/dist/services/auth.service.js +36 -0
  72. package/dist/services/auth.service.js.map +1 -1
  73. package/dist/services/challenge.service.d.ts.map +1 -1
  74. package/dist/services/challenge.service.js +1 -3
  75. package/dist/services/challenge.service.js.map +1 -1
  76. package/dist/services/email-verification.service.d.ts.map +1 -1
  77. package/dist/services/email-verification.service.js +3 -9
  78. package/dist/services/email-verification.service.js.map +1 -1
  79. package/dist/services/mfa-base.service.d.ts +23 -6
  80. package/dist/services/mfa-base.service.d.ts.map +1 -1
  81. package/dist/services/mfa-base.service.js +70 -18
  82. package/dist/services/mfa-base.service.js.map +1 -1
  83. package/dist/services/mfa.service.d.ts +54 -35
  84. package/dist/services/mfa.service.d.ts.map +1 -1
  85. package/dist/services/mfa.service.js +216 -168
  86. package/dist/services/mfa.service.js.map +1 -1
  87. package/dist/services/phone-verification.service.d.ts.map +1 -1
  88. package/dist/services/phone-verification.service.js +3 -8
  89. package/dist/services/phone-verification.service.js.map +1 -1
  90. package/dist/services/session.service.d.ts +1 -2
  91. package/dist/services/session.service.d.ts.map +1 -1
  92. package/dist/services/session.service.js.map +1 -1
  93. package/dist/services/user.service.d.ts +5 -5
  94. package/dist/services/user.service.d.ts.map +1 -1
  95. package/dist/services/user.service.js +16 -10
  96. package/dist/services/user.service.js.map +1 -1
  97. package/package.json +2 -2
  98. package/dist/dto/admin-remove-devices.dto.d.ts +0 -25
  99. package/dist/dto/admin-remove-devices.dto.d.ts.map +0 -1
  100. package/dist/dto/admin-remove-devices.dto.js.map +0 -1
  101. package/dist/dto/admin-set-preferred-method.dto.d.ts +0 -25
  102. package/dist/dto/admin-set-preferred-method.dto.d.ts.map +0 -1
  103. package/dist/dto/admin-set-preferred-method.dto.js +0 -50
  104. package/dist/dto/admin-set-preferred-method.dto.js.map +0 -1
  105. package/dist/dto/remove-devices.dto.d.ts +0 -48
  106. package/dist/dto/remove-devices.dto.d.ts.map +0 -1
  107. package/dist/dto/remove-devices.dto.js +0 -79
  108. package/dist/dto/remove-devices.dto.js.map +0 -1
  109. package/dist/dto/set-preferred-method.dto.d.ts +0 -44
  110. package/dist/dto/set-preferred-method.dto.d.ts.map +0 -1
  111. package/dist/dto/set-preferred-method.dto.js +0 -75
  112. package/dist/dto/set-preferred-method.dto.js.map +0 -1
@@ -150,24 +150,28 @@ class MFAService {
150
150
  * @param methodType - MFA method to remove (normalized)
151
151
  * @param removedBy - Actor performing the removal
152
152
  */
153
- async removeDevicesInternal(targetUser, methodType, removedBy) {
153
+ /**
154
+ * Shared implementation for removing a single MFA device by device ID.
155
+ *
156
+ * @param targetUser - Target user (self-service or admin target)
157
+ * @param deviceId - MFA device ID to remove
158
+ * @param removedBy - Actor performing the removal
159
+ * @returns Removal result
160
+ * @throws {NAuthException} NOT_FOUND when device is not found for the user
161
+ */
162
+ async removeDeviceInternal(targetUser, deviceId, removedBy) {
154
163
  const userId = targetUser.id;
155
- const preferredMethod = targetUser.preferredMfaMethod;
156
- const isPreferredMethod = preferredMethod === methodType;
157
- // Get all active devices for this user
164
+ // Get all active devices for this user and find the target device
158
165
  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;
166
+ const deviceToRemove = activeDevices.find((d) => d.id === deviceId);
167
+ if (!deviceToRemove) {
168
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'MFA device not found', { deviceId });
169
169
  }
170
- // Check if any devices remain after removal
170
+ const removedMethod = deviceToRemove.type;
171
+ // Delete the specific device
172
+ const result = await this.mfaDeviceRepository.delete(deviceId);
173
+ const deletedCount = result.affected || 0;
174
+ // Re-load remaining devices after deletion
171
175
  const remainingActiveDevices = await this.getActiveDevicesForUserId(userId);
172
176
  let mfaDisabled = false;
173
177
  // If no active devices remain, disable MFA for user
@@ -179,23 +183,30 @@ class MFAService {
179
183
  });
180
184
  mfaDisabled = true;
181
185
  // ============================================================================
182
- // Audit: Record MFA disabled (all devices removed)
186
+ // Audit: Record MFA disabled (last device removed)
183
187
  // ============================================================================
184
188
  if (this.auditService && this.clientInfoService) {
185
189
  try {
190
+ const actor = removedBy === 'admin' ? context_storage_1.ContextStorage.get('CURRENT_USER') : null;
191
+ const actorNameCandidate = actor
192
+ ? `${actor.firstName || ''} ${actor.lastName || ''}`.trim()
193
+ : '';
194
+ const performedByName = actorNameCandidate.length > 0 ? actorNameCandidate : actor?.email || undefined;
186
195
  await this.auditService?.recordEvent({
187
196
  userId,
188
197
  eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DISABLED,
189
198
  eventStatus: 'INFO',
190
- reason: removedBy === 'admin' ? 'admin_action' : 'all_devices_removed',
199
+ authMethod: removedBy === 'admin' ? 'admin' : undefined,
200
+ performedBy: removedBy === 'admin' && actor ? actor.sub : undefined,
201
+ reason: removedBy === 'admin' ? 'admin_action' : 'last_device_removed',
191
202
  description: removedBy === 'admin'
192
- ? 'MFA disabled by admin - all devices removed'
193
- : 'MFA disabled - all devices removed',
194
- // Client info automatically included from context
203
+ ? 'MFA disabled by admin - last device removed'
204
+ : 'MFA disabled - last device removed',
195
205
  metadata: {
196
- removedMethod: methodType,
197
- deletedCount,
206
+ removedDeviceId: deviceId,
207
+ removedMethod,
198
208
  removedBy,
209
+ ...(performedByName ? { performedByName } : {}),
199
210
  },
200
211
  });
201
212
  }
@@ -216,11 +227,11 @@ class MFAService {
216
227
  allowedMethods: this.config.mfa.allowedMethods || [],
217
228
  requiresSetup: true,
218
229
  });
219
- this.logger?.log?.(`Created MFA_SETUP_REQUIRED challenge for user ${targetUser.sub} after MFA removal`);
230
+ this.logger?.log?.(`Created MFA_SETUP_REQUIRED challenge for user ${targetUser.sub} after device removal`);
220
231
  }
221
232
  catch (error) {
222
233
  const errorMessage = error instanceof Error ? error.message : 'Unknown error';
223
- this.logger?.warn?.(`Failed to create MFA_SETUP_REQUIRED challenge after MFA removal: ${errorMessage}`);
234
+ this.logger?.warn?.(`Failed to create MFA_SETUP_REQUIRED challenge after device removal: ${errorMessage}`);
224
235
  }
225
236
  }
226
237
  }
@@ -228,30 +239,19 @@ class MFAService {
228
239
  else {
229
240
  // Update mfaMethods array with remaining methods
230
241
  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
- });
242
+ // Determine preferred method: keep if still configured, otherwise pick a new one.
243
+ const preferredMethod = targetUser.preferredMfaMethod;
244
+ const preferredStillConfigured = !!preferredMethod && remainingActiveDevices.some((d) => d.type === preferredMethod);
245
+ const finalPreferredMethod = preferredStillConfigured ? preferredMethod : remainingActiveDevices[0].type;
246
+ await this.userRepository.update({ id: userId }, {
247
+ mfaMethods: remainingMethods,
248
+ preferredMfaMethod: finalPreferredMethod,
249
+ });
250
+ // Normalize primary device flags: ensure exactly one active device is primary.
251
+ // Prefer a device of the preferred method (best UX).
252
+ const primaryDevice = remainingActiveDevices.find((d) => d.type === finalPreferredMethod) || remainingActiveDevices[0];
253
+ for (const device of remainingActiveDevices) {
254
+ await this.mfaDeviceRepository.update({ id: device.id }, { isPrimary: device.id === primaryDevice.id });
255
255
  }
256
256
  }
257
257
  // ============================================================================
@@ -259,18 +259,26 @@ class MFAService {
259
259
  // ============================================================================
260
260
  if (deletedCount > 0 && this.auditService && this.clientInfoService) {
261
261
  try {
262
+ const actor = removedBy === 'admin' ? context_storage_1.ContextStorage.get('CURRENT_USER') : null;
263
+ const actorNameCandidate = actor
264
+ ? `${actor.firstName || ''} ${actor.lastName || ''}`.trim()
265
+ : '';
266
+ const performedByName = actorNameCandidate.length > 0 ? actorNameCandidate : actor?.email || undefined;
262
267
  await this.auditService?.recordEvent({
263
268
  userId,
264
269
  eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_DEVICE_REMOVED,
265
270
  eventStatus: 'INFO',
271
+ authMethod: removedBy === 'admin' ? 'admin' : undefined,
272
+ performedBy: removedBy === 'admin' && actor ? actor.sub : undefined,
266
273
  metadata: {
267
- method: methodType,
274
+ removedDeviceId: deviceId,
275
+ removedMethod,
268
276
  deletedCount,
269
277
  remainingDevices: remainingActiveDevices.length,
270
278
  mfaDisabled,
271
279
  removedBy,
280
+ ...(performedByName ? { performedByName } : {}),
272
281
  },
273
- // Client info automatically included from context
274
282
  });
275
283
  }
276
284
  catch (auditError) {
@@ -278,7 +286,7 @@ class MFAService {
278
286
  this.logger?.error?.(`Failed to record MFA_DEVICE_REMOVED audit event: ${errorMessage}`, {
279
287
  error: auditError,
280
288
  userId,
281
- method: methodType,
289
+ removedDeviceId: deviceId,
282
290
  });
283
291
  }
284
292
  }
@@ -290,7 +298,7 @@ class MFAService {
290
298
  const clientInfo = this.clientInfoService.get();
291
299
  await this.hookRegistry.executeMFADeviceRemoved({
292
300
  user: targetUser,
293
- deviceType: methodType,
301
+ deviceType: removedMethod,
294
302
  removedBy,
295
303
  remainingDeviceCount: remainingActiveDevices.length,
296
304
  clientInfo: {
@@ -306,64 +314,14 @@ class MFAService {
306
314
  this.logger?.error?.(`Failed to execute mfaDeviceRemoved hooks: ${errorMessage}`, {
307
315
  error: hookError,
308
316
  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,
317
+ removedDeviceId: deviceId,
362
318
  });
363
319
  }
364
320
  }
365
321
  return {
366
- message: 'Preferred method updated',
322
+ removedDeviceId: deviceId,
323
+ removedMethod,
324
+ mfaDisabled,
367
325
  };
368
326
  }
369
327
  /**
@@ -672,8 +630,9 @@ class MFAService {
672
630
  await (0, dto_validator_1.ensureValidatedDto)(dto_1.GetUserDevicesDTO, _dto);
673
631
  // Get user from authenticated context (already has id and sub)
674
632
  const currentUser = this.getCurrentUserOrThrow();
633
+ const devices = await this.getActiveDevicesForUserId(currentUser.id);
675
634
  return {
676
- devices: await this.getActiveDevicesForUserId(currentUser.id),
635
+ devices: dto_1.MFADeviceResponseDTO.fromEntities(devices),
677
636
  };
678
637
  }
679
638
  /**
@@ -724,94 +683,179 @@ class MFAService {
724
683
  * }
725
684
  * ```
726
685
  */
727
- async removeDevices(dto) {
728
- dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.RemoveDevicesDTO, dto);
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];
730
- const normalizedMethod = dto.methodType.toLowerCase();
731
- if (!validMethods.includes(normalizedMethod)) {
732
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Invalid MFA method: ${dto.methodType}. Valid methods are: ${validMethods.join(', ')}`);
733
- }
686
+ /**
687
+ * Remove a single MFA device by device ID (self-service).
688
+ *
689
+ * WHY: Users can register multiple devices per method (e.g., multiple passkeys, multiple TOTP apps),
690
+ * so deleting by method alone is often too destructive.
691
+ *
692
+ * @param dto - Request DTO with deviceId
693
+ * @returns Removal response (removed device id/method and whether MFA was disabled)
694
+ * @throws {NAuthException} NOT_FOUND when device does not exist or does not belong to the user
695
+ *
696
+ * @example
697
+ * ```typescript
698
+ * const result = await mfaService.removeDevice({ deviceId: 123 });
699
+ * ```
700
+ */
701
+ async removeDevice(dto) {
702
+ dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.RemoveDeviceDTO, dto);
734
703
  const currentUser = this.getCurrentUserOrThrow();
735
- return await this.removeDevicesInternal(currentUser, normalizedMethod, 'user');
704
+ return await this.removeDeviceInternal(currentUser, dto.deviceId, 'user');
736
705
  }
737
706
  /**
738
- * Admin: Remove MFA devices for a specific user by `sub`.
707
+ * Admin: Remove a single MFA device by device ID.
739
708
  *
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
709
+ * Admin APIs are allowed to target any user's device. The owning user is resolved
710
+ * from the device record and the same internal removal logic is reused.
711
+ *
712
+ * @param dto - Admin request DTO with deviceId
713
+ * @returns Removal response
714
+ * @throws {NAuthException} NOT_FOUND when device or owning user is not found
744
715
  *
745
716
  * @example
746
717
  * ```typescript
747
- * await mfaService.adminRemoveDevices({ sub: 'user-uuid', methodType: 'totp' });
718
+ * const result = await mfaService.adminRemoveDevice({ deviceId: 123 });
748
719
  * ```
749
720
  */
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(', ')}`);
721
+ async adminRemoveDevice(dto) {
722
+ dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.AdminRemoveDeviceDTO, dto);
723
+ const deviceEntity = await this.mfaDeviceRepository.findOne({
724
+ where: { id: dto.deviceId },
725
+ });
726
+ if (!deviceEntity) {
727
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'MFA device not found', { deviceId: dto.deviceId });
756
728
  }
757
- const targetUser = await this.getUserBySubOrThrow(dto.sub);
758
- return await this.removeDevicesInternal(targetUser, normalizedMethod, 'admin');
729
+ const device = deviceEntity;
730
+ const userEntity = await this.userRepository.findOne({
731
+ where: { id: device.userId },
732
+ });
733
+ if (!userEntity) {
734
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found', {
735
+ userId: device.userId,
736
+ deviceId: dto.deviceId,
737
+ });
738
+ }
739
+ const targetUser = userEntity;
740
+ return await this.removeDeviceInternal(targetUser, dto.deviceId, 'admin');
759
741
  }
760
742
  /**
761
- * Set preferred MFA method for a user
743
+ * Admin: Set a user's preferred MFA device.
762
744
  *
763
- * Updates the user's preferred MFA method and device primary flags.
764
- * Validates that the method is configured for the user before setting it as preferred.
745
+ * Updates the preferred device for a specified user by device ID.
746
+ * This is an admin-only operation that allows administrators to manage
747
+ * user MFA preferences.
765
748
  *
766
- * This method encapsulates all database operations related to preferred method updates,
767
- * ensuring the consumer app doesn't need to directly manipulate nauth_* tables.
768
- *
769
- * @param dto - Request DTO with method type
770
- * @returns Response DTO with success message
771
- * @throws {NAuthException} If user not found, invalid method type, or method not configured
749
+ * @param dto - DTO containing user sub and device ID
750
+ * @returns Success message
751
+ * @throws {NAuthException} NOT_FOUND | VALIDATION_FAILED
772
752
  *
773
753
  * @example
774
754
  * ```typescript
775
- * // Consumer app controller
776
- * @Put('mfa/preferred')
777
- * async setPreferredMFAMethod(@CurrentUser() user: IUser, @Body() body: { method: string }) {
778
- * return await this.mfaService.setPreferredMethod({ methodType: body.method });
779
- * }
755
+ * const result = await mfaService.adminSetPreferredDevice({
756
+ * sub: 'user-uuid',
757
+ * deviceId: 123
758
+ * });
759
+ * // Returns: { message: "Preferred MFA device updated" }
780
760
  * ```
781
761
  */
782
- async setPreferredMethod(dto) {
783
- dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.SetPreferredMethodDTO, dto);
784
- // Validate method type
785
- 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];
786
- const normalizedMethod = dto.methodType.toLowerCase();
787
- if (!validMethods.includes(normalizedMethod)) {
788
- throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Invalid MFA method: ${dto.methodType}. Valid methods are: ${validMethods.join(', ')}`);
762
+ async adminSetPreferredDevice(dto) {
763
+ dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.AdminSetPreferredDeviceDTO, dto);
764
+ // Get target user
765
+ const user = await this.userRepository.findOne({ where: { sub: dto.sub } });
766
+ if (!user) {
767
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
768
+ }
769
+ // Delegate to the regular setPreferredDevice logic
770
+ // but use SetPreferredDeviceDTO format
771
+ const deviceDto = Object.assign(new dto_1.SetPreferredDeviceDTO(), { deviceId: dto.deviceId });
772
+ // Temporarily set context user for the operation
773
+ // Keep admin user in context for audit trails
774
+ const originalUser = context_storage_1.ContextStorage.get('CURRENT_USER');
775
+ const adminUser = originalUser; // Admin user is already in context
776
+ // Set target user as current user for the operation
777
+ context_storage_1.ContextStorage.set('CURRENT_USER', user);
778
+ try {
779
+ // Pass 'admin' flag to setPreferredDevice for proper audit trails
780
+ const result = await this.setPreferredDevice(deviceDto, 'admin');
781
+ return { message: result.message };
782
+ }
783
+ finally {
784
+ // Restore original context (admin user)
785
+ if (originalUser) {
786
+ context_storage_1.ContextStorage.set('CURRENT_USER', originalUser);
787
+ }
788
+ else {
789
+ context_storage_1.ContextStorage.set('CURRENT_USER', undefined);
790
+ }
789
791
  }
790
- const currentUser = this.getCurrentUserOrThrow();
791
- return await this.setPreferredMethodInternal(currentUser, normalizedMethod, 'user');
792
792
  }
793
793
  /**
794
- * Admin: Set preferred MFA method for a specific user by `sub`.
794
+ * Set a specific MFA device as the user's preferred device (self-service).
795
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
796
+ * This updates:
797
+ * - The user's preferred MFA method (set to the device's method)
798
+ * - Device `isPrimary` flags (exactly one active device becomes primary/preferred)
799
+ *
800
+ * @param dto - Request DTO with deviceId
801
+ * @returns Success message
802
+ * @throws {NAuthException} NOT_FOUND when device does not exist or does not belong to the user
800
803
  *
801
804
  * @example
802
805
  * ```typescript
803
- * await mfaService.adminSetPreferredMethod({ sub: 'user-uuid', methodType: 'sms' });
806
+ * await mfaService.setPreferredDevice({ deviceId: 123 });
804
807
  * ```
805
808
  */
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(', ')}`);
809
+ async setPreferredDevice(dto, updatedBy = 'user') {
810
+ dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.SetPreferredDeviceDTO, dto);
811
+ const currentUser = this.getCurrentUserOrThrow();
812
+ const activeDevices = await this.getActiveDevicesForUserId(currentUser.id);
813
+ const targetDevice = activeDevices.find((d) => d.id === dto.deviceId);
814
+ if (!targetDevice) {
815
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'MFA device not found', { deviceId: dto.deviceId });
816
+ }
817
+ // Update user's preferred method to match the preferred device's method.
818
+ await this.userRepository.update({ id: currentUser.id }, {
819
+ preferredMfaMethod: targetDevice.type,
820
+ });
821
+ // Ensure exactly one active device is primary (marks it as preferred).
822
+ for (const device of activeDevices) {
823
+ await this.mfaDeviceRepository.update({ id: device.id }, { isPrimary: device.id === targetDevice.id });
824
+ }
825
+ // ============================================================================
826
+ // Audit: Record preferred MFA device update
827
+ // ============================================================================
828
+ if (this.auditService && this.clientInfoService) {
829
+ try {
830
+ const actor = updatedBy === 'admin' ? context_storage_1.ContextStorage.get('CURRENT_USER') : currentUser;
831
+ const actorNameCandidate = actor && updatedBy === 'admin'
832
+ ? `${actor.firstName || ''} ${actor.lastName || ''}`.trim()
833
+ : '';
834
+ const performedByName = actorNameCandidate.length > 0 ? actorNameCandidate : actor?.email || undefined;
835
+ await this.auditService?.recordEvent({
836
+ userId: currentUser.id,
837
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_PREFERRED_METHOD_UPDATED,
838
+ eventStatus: 'INFO',
839
+ authMethod: updatedBy === 'admin' ? 'admin' : undefined,
840
+ performedBy: updatedBy === 'admin' && actor && actor.sub !== currentUser.sub ? actor.sub : undefined,
841
+ metadata: {
842
+ newMethod: targetDevice.type,
843
+ deviceId: targetDevice.id,
844
+ updatedBy,
845
+ ...(performedByName && updatedBy === 'admin' ? { performedByName } : {}),
846
+ },
847
+ });
848
+ }
849
+ catch (auditError) {
850
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
851
+ this.logger?.error?.(`Failed to record MFA_PREFERRED_METHOD_UPDATED audit event: ${errorMessage}`, {
852
+ error: auditError,
853
+ userId: currentUser.id,
854
+ deviceId: targetDevice.id,
855
+ });
856
+ }
812
857
  }
813
- const targetUser = await this.getUserBySubOrThrow(dto.sub);
814
- return await this.setPreferredMethodInternal(targetUser, normalizedMethod, 'admin');
858
+ return { message: 'Preferred MFA device updated' };
815
859
  }
816
860
  /**
817
861
  * Grant or revoke a user's exemption from multi-factor authentication (MFA) requirements.
@@ -879,9 +923,13 @@ class MFAService {
879
923
  // ============================================================================
880
924
  // Audit: Record MFA exemption grant/revoke
881
925
  // ============================================================================
882
- // Note: performedByName is automatically enriched by audit service from context
926
+ // Note: We also attach performedByName when possible so downstream systems
927
+ // have a stable snapshot even if admin profile changes later.
883
928
  if (this.auditService && this.clientInfoService) {
884
929
  try {
930
+ const actor = context_storage_1.ContextStorage.get('CURRENT_USER');
931
+ const actorNameCandidate = `${actor?.firstName || ''} ${actor?.lastName || ''}`.trim();
932
+ const performedByName = actorNameCandidate.length > 0 ? actorNameCandidate : actor?.email || undefined;
885
933
  await this.auditService.recordEvent({
886
934
  userId: user.id,
887
935
  eventType: dto.exempt ? auth_audit_event_type_enum_1.AuthAuditEventType.MFA_EXEMPTION_GRANTED : auth_audit_event_type_enum_1.AuthAuditEventType.MFA_EXEMPTION_REVOKED,
@@ -892,7 +940,7 @@ class MFAService {
892
940
  metadata: {
893
941
  previousExemptStatus: userEntity.mfaExempt,
894
942
  newExemptStatus: dto.exempt,
895
- // performedByName will be automatically enriched by audit service from context
943
+ ...(performedByName ? { performedByName } : {}),
896
944
  },
897
945
  });
898
946
  }