@nauth-toolkit/core 0.1.108 → 0.1.109

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 (86) 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-response.dto.d.ts +1 -1
  18. package/dist/dto/get-user-response.dto.js +1 -1
  19. package/dist/dto/get-users.dto.d.ts +2 -2
  20. package/dist/dto/index.d.ts +4 -4
  21. package/dist/dto/index.d.ts.map +1 -1
  22. package/dist/dto/index.js +4 -4
  23. package/dist/dto/index.js.map +1 -1
  24. package/dist/dto/remove-device.dto.d.ts +49 -0
  25. package/dist/dto/remove-device.dto.d.ts.map +1 -0
  26. package/dist/dto/remove-device.dto.js +76 -0
  27. package/dist/dto/remove-device.dto.js.map +1 -0
  28. package/dist/dto/respond-challenge.dto.d.ts +8 -0
  29. package/dist/dto/respond-challenge.dto.d.ts.map +1 -1
  30. package/dist/dto/respond-challenge.dto.js +14 -0
  31. package/dist/dto/respond-challenge.dto.js.map +1 -1
  32. package/dist/dto/set-preferred-device.dto.d.ts +45 -0
  33. package/dist/dto/set-preferred-device.dto.d.ts.map +1 -0
  34. package/dist/dto/set-preferred-device.dto.js +73 -0
  35. package/dist/dto/set-preferred-device.dto.js.map +1 -0
  36. package/dist/dto/setup-mfa.dto.d.ts.map +1 -1
  37. package/dist/dto/setup-mfa.dto.js.map +1 -1
  38. package/dist/dto/user-response.dto.d.ts +3 -3
  39. package/dist/dto/user-response.dto.js +5 -5
  40. package/dist/handlers/client-info.handler.d.ts.map +1 -1
  41. package/dist/handlers/client-info.handler.js.map +1 -1
  42. package/dist/interfaces/provider.interface.d.ts.map +1 -1
  43. package/dist/openapi/components.schemas.json +78 -58
  44. package/dist/services/admin-auth.service.d.ts +5 -5
  45. package/dist/services/admin-auth.service.js +2 -2
  46. package/dist/services/auth-audit.service.d.ts.map +1 -1
  47. package/dist/services/auth-audit.service.js +1 -1
  48. package/dist/services/auth-audit.service.js.map +1 -1
  49. package/dist/services/auth-challenge-helper.service.d.ts.map +1 -1
  50. package/dist/services/auth-challenge-helper.service.js +34 -0
  51. package/dist/services/auth-challenge-helper.service.js.map +1 -1
  52. package/dist/services/auth-service-internal-helpers.d.ts.map +1 -1
  53. package/dist/services/auth-service-internal-helpers.js +4 -0
  54. package/dist/services/auth-service-internal-helpers.js.map +1 -1
  55. package/dist/services/auth.service.d.ts +2 -2
  56. package/dist/services/mfa-base.service.d.ts +23 -6
  57. package/dist/services/mfa-base.service.d.ts.map +1 -1
  58. package/dist/services/mfa-base.service.js +70 -18
  59. package/dist/services/mfa-base.service.js.map +1 -1
  60. package/dist/services/mfa.service.d.ts +54 -35
  61. package/dist/services/mfa.service.d.ts.map +1 -1
  62. package/dist/services/mfa.service.js +214 -167
  63. package/dist/services/mfa.service.js.map +1 -1
  64. package/dist/services/session.service.d.ts +1 -2
  65. package/dist/services/session.service.d.ts.map +1 -1
  66. package/dist/services/session.service.js.map +1 -1
  67. package/dist/services/user.service.d.ts +5 -5
  68. package/dist/services/user.service.d.ts.map +1 -1
  69. package/dist/services/user.service.js +16 -10
  70. package/dist/services/user.service.js.map +1 -1
  71. package/package.json +2 -2
  72. package/dist/dto/admin-remove-devices.dto.d.ts +0 -25
  73. package/dist/dto/admin-remove-devices.dto.d.ts.map +0 -1
  74. package/dist/dto/admin-remove-devices.dto.js.map +0 -1
  75. package/dist/dto/admin-set-preferred-method.dto.d.ts +0 -25
  76. package/dist/dto/admin-set-preferred-method.dto.d.ts.map +0 -1
  77. package/dist/dto/admin-set-preferred-method.dto.js +0 -50
  78. package/dist/dto/admin-set-preferred-method.dto.js.map +0 -1
  79. package/dist/dto/remove-devices.dto.d.ts +0 -48
  80. package/dist/dto/remove-devices.dto.d.ts.map +0 -1
  81. package/dist/dto/remove-devices.dto.js +0 -79
  82. package/dist/dto/remove-devices.dto.js.map +0 -1
  83. package/dist/dto/set-preferred-method.dto.d.ts +0 -44
  84. package/dist/dto/set-preferred-method.dto.d.ts.map +0 -1
  85. package/dist/dto/set-preferred-method.dto.js +0 -75
  86. 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
  /**
@@ -724,94 +682,179 @@ class MFAService {
724
682
  * }
725
683
  * ```
726
684
  */
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
- }
685
+ /**
686
+ * Remove a single MFA device by device ID (self-service).
687
+ *
688
+ * WHY: Users can register multiple devices per method (e.g., multiple passkeys, multiple TOTP apps),
689
+ * so deleting by method alone is often too destructive.
690
+ *
691
+ * @param dto - Request DTO with deviceId
692
+ * @returns Removal response (removed device id/method and whether MFA was disabled)
693
+ * @throws {NAuthException} NOT_FOUND when device does not exist or does not belong to the user
694
+ *
695
+ * @example
696
+ * ```typescript
697
+ * const result = await mfaService.removeDevice({ deviceId: 123 });
698
+ * ```
699
+ */
700
+ async removeDevice(dto) {
701
+ dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.RemoveDeviceDTO, dto);
734
702
  const currentUser = this.getCurrentUserOrThrow();
735
- return await this.removeDevicesInternal(currentUser, normalizedMethod, 'user');
703
+ return await this.removeDeviceInternal(currentUser, dto.deviceId, 'user');
736
704
  }
737
705
  /**
738
- * Admin: Remove MFA devices for a specific user by `sub`.
706
+ * Admin: Remove a single MFA device by device ID.
739
707
  *
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
708
+ * Admin APIs are allowed to target any user's device. The owning user is resolved
709
+ * from the device record and the same internal removal logic is reused.
710
+ *
711
+ * @param dto - Admin request DTO with deviceId
712
+ * @returns Removal response
713
+ * @throws {NAuthException} NOT_FOUND when device or owning user is not found
744
714
  *
745
715
  * @example
746
716
  * ```typescript
747
- * await mfaService.adminRemoveDevices({ sub: 'user-uuid', methodType: 'totp' });
717
+ * const result = await mfaService.adminRemoveDevice({ deviceId: 123 });
748
718
  * ```
749
719
  */
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(', ')}`);
720
+ async adminRemoveDevice(dto) {
721
+ dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.AdminRemoveDeviceDTO, dto);
722
+ const deviceEntity = await this.mfaDeviceRepository.findOne({
723
+ where: { id: dto.deviceId },
724
+ });
725
+ if (!deviceEntity) {
726
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'MFA device not found', { deviceId: dto.deviceId });
756
727
  }
757
- const targetUser = await this.getUserBySubOrThrow(dto.sub);
758
- return await this.removeDevicesInternal(targetUser, normalizedMethod, 'admin');
728
+ const device = deviceEntity;
729
+ const userEntity = await this.userRepository.findOne({
730
+ where: { id: device.userId },
731
+ });
732
+ if (!userEntity) {
733
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found', {
734
+ userId: device.userId,
735
+ deviceId: dto.deviceId,
736
+ });
737
+ }
738
+ const targetUser = userEntity;
739
+ return await this.removeDeviceInternal(targetUser, dto.deviceId, 'admin');
759
740
  }
760
741
  /**
761
- * Set preferred MFA method for a user
742
+ * Admin: Set a user's preferred MFA device.
762
743
  *
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.
744
+ * Updates the preferred device for a specified user by device ID.
745
+ * This is an admin-only operation that allows administrators to manage
746
+ * user MFA preferences.
765
747
  *
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
748
+ * @param dto - DTO containing user sub and device ID
749
+ * @returns Success message
750
+ * @throws {NAuthException} NOT_FOUND | VALIDATION_FAILED
772
751
  *
773
752
  * @example
774
753
  * ```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
- * }
754
+ * const result = await mfaService.adminSetPreferredDevice({
755
+ * sub: 'user-uuid',
756
+ * deviceId: 123
757
+ * });
758
+ * // Returns: { message: "Preferred MFA device updated" }
780
759
  * ```
781
760
  */
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(', ')}`);
761
+ async adminSetPreferredDevice(dto) {
762
+ dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.AdminSetPreferredDeviceDTO, dto);
763
+ // Get target user
764
+ const user = await this.userRepository.findOne({ where: { sub: dto.sub } });
765
+ if (!user) {
766
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
767
+ }
768
+ // Delegate to the regular setPreferredDevice logic
769
+ // but use SetPreferredDeviceDTO format
770
+ const deviceDto = Object.assign(new dto_1.SetPreferredDeviceDTO(), { deviceId: dto.deviceId });
771
+ // Temporarily set context user for the operation
772
+ // Keep admin user in context for audit trails
773
+ const originalUser = context_storage_1.ContextStorage.get('CURRENT_USER');
774
+ const adminUser = originalUser; // Admin user is already in context
775
+ // Set target user as current user for the operation
776
+ context_storage_1.ContextStorage.set('CURRENT_USER', user);
777
+ try {
778
+ // Pass 'admin' flag to setPreferredDevice for proper audit trails
779
+ const result = await this.setPreferredDevice(deviceDto, 'admin');
780
+ return { message: result.message };
781
+ }
782
+ finally {
783
+ // Restore original context (admin user)
784
+ if (originalUser) {
785
+ context_storage_1.ContextStorage.set('CURRENT_USER', originalUser);
786
+ }
787
+ else {
788
+ context_storage_1.ContextStorage.set('CURRENT_USER', undefined);
789
+ }
789
790
  }
790
- const currentUser = this.getCurrentUserOrThrow();
791
- return await this.setPreferredMethodInternal(currentUser, normalizedMethod, 'user');
792
791
  }
793
792
  /**
794
- * Admin: Set preferred MFA method for a specific user by `sub`.
793
+ * Set a specific MFA device as the user's preferred device (self-service).
795
794
  *
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
795
+ * This updates:
796
+ * - The user's preferred MFA method (set to the device's method)
797
+ * - Device `isPrimary` flags (exactly one active device becomes primary/preferred)
798
+ *
799
+ * @param dto - Request DTO with deviceId
800
+ * @returns Success message
801
+ * @throws {NAuthException} NOT_FOUND when device does not exist or does not belong to the user
800
802
  *
801
803
  * @example
802
804
  * ```typescript
803
- * await mfaService.adminSetPreferredMethod({ sub: 'user-uuid', methodType: 'sms' });
805
+ * await mfaService.setPreferredDevice({ deviceId: 123 });
804
806
  * ```
805
807
  */
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(', ')}`);
808
+ async setPreferredDevice(dto, updatedBy = 'user') {
809
+ dto = await (0, dto_validator_1.ensureValidatedDto)(dto_1.SetPreferredDeviceDTO, dto);
810
+ const currentUser = this.getCurrentUserOrThrow();
811
+ const activeDevices = await this.getActiveDevicesForUserId(currentUser.id);
812
+ const targetDevice = activeDevices.find((d) => d.id === dto.deviceId);
813
+ if (!targetDevice) {
814
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'MFA device not found', { deviceId: dto.deviceId });
815
+ }
816
+ // Update user's preferred method to match the preferred device's method.
817
+ await this.userRepository.update({ id: currentUser.id }, {
818
+ preferredMfaMethod: targetDevice.type,
819
+ });
820
+ // Ensure exactly one active device is primary (marks it as preferred).
821
+ for (const device of activeDevices) {
822
+ await this.mfaDeviceRepository.update({ id: device.id }, { isPrimary: device.id === targetDevice.id });
823
+ }
824
+ // ============================================================================
825
+ // Audit: Record preferred MFA device update
826
+ // ============================================================================
827
+ if (this.auditService && this.clientInfoService) {
828
+ try {
829
+ const actor = updatedBy === 'admin' ? context_storage_1.ContextStorage.get('CURRENT_USER') : currentUser;
830
+ const actorNameCandidate = actor && updatedBy === 'admin'
831
+ ? `${actor.firstName || ''} ${actor.lastName || ''}`.trim()
832
+ : '';
833
+ const performedByName = actorNameCandidate.length > 0 ? actorNameCandidate : actor?.email || undefined;
834
+ await this.auditService?.recordEvent({
835
+ userId: currentUser.id,
836
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_PREFERRED_METHOD_UPDATED,
837
+ eventStatus: 'INFO',
838
+ authMethod: updatedBy === 'admin' ? 'admin' : undefined,
839
+ performedBy: updatedBy === 'admin' && actor && actor.sub !== currentUser.sub ? actor.sub : undefined,
840
+ metadata: {
841
+ newMethod: targetDevice.type,
842
+ deviceId: targetDevice.id,
843
+ updatedBy,
844
+ ...(performedByName && updatedBy === 'admin' ? { performedByName } : {}),
845
+ },
846
+ });
847
+ }
848
+ catch (auditError) {
849
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
850
+ this.logger?.error?.(`Failed to record MFA_PREFERRED_METHOD_UPDATED audit event: ${errorMessage}`, {
851
+ error: auditError,
852
+ userId: currentUser.id,
853
+ deviceId: targetDevice.id,
854
+ });
855
+ }
812
856
  }
813
- const targetUser = await this.getUserBySubOrThrow(dto.sub);
814
- return await this.setPreferredMethodInternal(targetUser, normalizedMethod, 'admin');
857
+ return { message: 'Preferred MFA device updated' };
815
858
  }
816
859
  /**
817
860
  * Grant or revoke a user's exemption from multi-factor authentication (MFA) requirements.
@@ -879,9 +922,13 @@ class MFAService {
879
922
  // ============================================================================
880
923
  // Audit: Record MFA exemption grant/revoke
881
924
  // ============================================================================
882
- // Note: performedByName is automatically enriched by audit service from context
925
+ // Note: We also attach performedByName when possible so downstream systems
926
+ // have a stable snapshot even if admin profile changes later.
883
927
  if (this.auditService && this.clientInfoService) {
884
928
  try {
929
+ const actor = context_storage_1.ContextStorage.get('CURRENT_USER');
930
+ const actorNameCandidate = `${actor?.firstName || ''} ${actor?.lastName || ''}`.trim();
931
+ const performedByName = actorNameCandidate.length > 0 ? actorNameCandidate : actor?.email || undefined;
885
932
  await this.auditService.recordEvent({
886
933
  userId: user.id,
887
934
  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 +939,7 @@ class MFAService {
892
939
  metadata: {
893
940
  previousExemptStatus: userEntity.mfaExempt,
894
941
  newExemptStatus: dto.exempt,
895
- // performedByName will be automatically enriched by audit service from context
942
+ ...(performedByName ? { performedByName } : {}),
896
943
  },
897
944
  });
898
945
  }