@nauth-toolkit/core 0.1.0 → 0.1.3

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 (184) hide show
  1. package/LICENSE +90 -0
  2. package/README.md +30 -0
  3. package/package.json +7 -2
  4. package/jest.config.js +0 -15
  5. package/jest.setup.ts +0 -6
  6. package/src/adapters/database-columns.ts +0 -165
  7. package/src/adapters/express.adapter.ts +0 -385
  8. package/src/adapters/fastify.adapter.ts +0 -416
  9. package/src/adapters/index.ts +0 -16
  10. package/src/adapters/storage.factory.ts +0 -143
  11. package/src/bootstrap.ts +0 -374
  12. package/src/dto/auth-challenge.dto.ts +0 -231
  13. package/src/dto/auth-response.dto.ts +0 -253
  14. package/src/dto/challenge-response.dto.ts +0 -234
  15. package/src/dto/change-password-request.dto.ts +0 -50
  16. package/src/dto/change-password-response.dto.ts +0 -29
  17. package/src/dto/change-password.dto.ts +0 -57
  18. package/src/dto/error-response.dto.ts +0 -136
  19. package/src/dto/get-available-methods.dto.ts +0 -55
  20. package/src/dto/get-challenge-data-response.dto.ts +0 -28
  21. package/src/dto/get-challenge-data.dto.ts +0 -69
  22. package/src/dto/get-client-info.dto.ts +0 -104
  23. package/src/dto/get-device-token-response.dto.ts +0 -25
  24. package/src/dto/get-events-by-type.dto.ts +0 -76
  25. package/src/dto/get-ip-address-response.dto.ts +0 -24
  26. package/src/dto/get-mfa-status.dto.ts +0 -94
  27. package/src/dto/get-risk-assessment-history.dto.ts +0 -39
  28. package/src/dto/get-session-id-response.dto.ts +0 -25
  29. package/src/dto/get-setup-data-response.dto.ts +0 -31
  30. package/src/dto/get-setup-data.dto.ts +0 -75
  31. package/src/dto/get-suspicious-activity.dto.ts +0 -42
  32. package/src/dto/get-user-agent-response.dto.ts +0 -23
  33. package/src/dto/get-user-auth-history.dto.ts +0 -95
  34. package/src/dto/get-user-by-email.dto.ts +0 -61
  35. package/src/dto/get-user-by-id.dto.ts +0 -46
  36. package/src/dto/get-user-devices.dto.ts +0 -53
  37. package/src/dto/get-user-response.dto.ts +0 -17
  38. package/src/dto/has-provider.dto.ts +0 -56
  39. package/src/dto/index.ts +0 -57
  40. package/src/dto/is-trusted-device-response.dto.ts +0 -34
  41. package/src/dto/list-providers-response.dto.ts +0 -23
  42. package/src/dto/login.dto.ts +0 -95
  43. package/src/dto/logout-all-response.dto.ts +0 -24
  44. package/src/dto/logout-all.dto.ts +0 -65
  45. package/src/dto/logout-response.dto.ts +0 -25
  46. package/src/dto/logout.dto.ts +0 -64
  47. package/src/dto/refresh-token.dto.ts +0 -36
  48. package/src/dto/remove-devices.dto.ts +0 -85
  49. package/src/dto/resend-code-response.dto.ts +0 -32
  50. package/src/dto/resend-code.dto.ts +0 -51
  51. package/src/dto/reset-password.dto.ts +0 -115
  52. package/src/dto/respond-challenge.dto.ts +0 -272
  53. package/src/dto/set-mfa-exemption.dto.ts +0 -112
  54. package/src/dto/set-must-change-password-response.dto.ts +0 -27
  55. package/src/dto/set-must-change-password.dto.ts +0 -46
  56. package/src/dto/set-preferred-method.dto.ts +0 -80
  57. package/src/dto/setup-mfa.dto.ts +0 -98
  58. package/src/dto/signup.dto.ts +0 -174
  59. package/src/dto/social-auth.dto.ts +0 -422
  60. package/src/dto/trust-device-response.dto.ts +0 -30
  61. package/src/dto/trust-device.dto.ts +0 -9
  62. package/src/dto/update-user-attributes-request.dto.ts +0 -51
  63. package/src/dto/user-response.dto.ts +0 -138
  64. package/src/dto/user-update.dto.ts +0 -222
  65. package/src/dto/verify-email.dto.ts +0 -313
  66. package/src/dto/verify-mfa-code.dto.ts +0 -103
  67. package/src/dto/verify-phone-by-sub.dto.ts +0 -78
  68. package/src/dto/verify-phone.dto.ts +0 -245
  69. package/src/entities/auth-audit.entity.ts +0 -232
  70. package/src/entities/challenge-session.entity.ts +0 -116
  71. package/src/entities/index.ts +0 -29
  72. package/src/entities/login-attempt.entity.ts +0 -64
  73. package/src/entities/mfa-device.entity.ts +0 -151
  74. package/src/entities/rate-limit.entity.ts +0 -44
  75. package/src/entities/session.entity.ts +0 -180
  76. package/src/entities/social-account.entity.ts +0 -96
  77. package/src/entities/storage-lock.entity.ts +0 -39
  78. package/src/entities/trusted-device.entity.ts +0 -112
  79. package/src/entities/user.entity.ts +0 -243
  80. package/src/entities/verification-token.entity.ts +0 -141
  81. package/src/enums/auth-audit-event-type.enum.ts +0 -360
  82. package/src/enums/error-codes.enum.ts +0 -420
  83. package/src/enums/mfa-method.enum.ts +0 -97
  84. package/src/enums/risk-factor.enum.ts +0 -111
  85. package/src/exceptions/nauth.exception.ts +0 -231
  86. package/src/handlers/auth.handler.ts +0 -260
  87. package/src/handlers/client-info.handler.ts +0 -101
  88. package/src/handlers/csrf.handler.ts +0 -156
  89. package/src/handlers/token-delivery.handler.ts +0 -118
  90. package/src/index.ts +0 -118
  91. package/src/interfaces/client-info.interface.ts +0 -85
  92. package/src/interfaces/config.interface.ts +0 -2135
  93. package/src/interfaces/entities.interface.ts +0 -226
  94. package/src/interfaces/index.ts +0 -15
  95. package/src/interfaces/logger.interface.ts +0 -283
  96. package/src/interfaces/mfa-provider.interface.ts +0 -154
  97. package/src/interfaces/oauth.interface.ts +0 -148
  98. package/src/interfaces/provider.interface.ts +0 -47
  99. package/src/interfaces/social-auth-provider.interface.ts +0 -131
  100. package/src/interfaces/storage-adapter.interface.ts +0 -82
  101. package/src/interfaces/template.interface.ts +0 -510
  102. package/src/interfaces/token-verifier.interface.ts +0 -110
  103. package/src/internal.ts +0 -178
  104. package/src/platform/interfaces.ts +0 -299
  105. package/src/schemas/auth-config.schema.ts +0 -646
  106. package/src/services/adaptive-mfa-decision.service.spec.ts +0 -1058
  107. package/src/services/adaptive-mfa-decision.service.ts +0 -457
  108. package/src/services/auth-audit.service.spec.ts +0 -675
  109. package/src/services/auth-audit.service.ts +0 -558
  110. package/src/services/auth-challenge-helper.service.spec.ts +0 -3227
  111. package/src/services/auth-challenge-helper.service.ts +0 -825
  112. package/src/services/auth-flow-context-builder.service.ts +0 -520
  113. package/src/services/auth-flow-rules.ts +0 -202
  114. package/src/services/auth-flow-state-definitions.ts +0 -190
  115. package/src/services/auth-flow-state-machine.service.ts +0 -207
  116. package/src/services/auth-flow-state-machine.types.ts +0 -316
  117. package/src/services/auth.service.spec.ts +0 -4195
  118. package/src/services/auth.service.ts +0 -3727
  119. package/src/services/challenge.service.spec.ts +0 -1363
  120. package/src/services/challenge.service.ts +0 -696
  121. package/src/services/client-info.service.spec.ts +0 -572
  122. package/src/services/client-info.service.ts +0 -374
  123. package/src/services/csrf.service.ts +0 -54
  124. package/src/services/email-verification.service.spec.ts +0 -1229
  125. package/src/services/email-verification.service.ts +0 -578
  126. package/src/services/geo-location.service.spec.ts +0 -603
  127. package/src/services/geo-location.service.ts +0 -599
  128. package/src/services/index.ts +0 -13
  129. package/src/services/jwt.service.spec.ts +0 -882
  130. package/src/services/jwt.service.ts +0 -621
  131. package/src/services/mfa-base.service.spec.ts +0 -246
  132. package/src/services/mfa-base.service.ts +0 -611
  133. package/src/services/mfa.service.spec.ts +0 -693
  134. package/src/services/mfa.service.ts +0 -960
  135. package/src/services/password.service.spec.ts +0 -166
  136. package/src/services/password.service.ts +0 -309
  137. package/src/services/phone-verification.service.spec.ts +0 -1120
  138. package/src/services/phone-verification.service.ts +0 -751
  139. package/src/services/risk-detection.service.spec.ts +0 -1292
  140. package/src/services/risk-detection.service.ts +0 -1012
  141. package/src/services/risk-scoring.service.spec.ts +0 -204
  142. package/src/services/risk-scoring.service.ts +0 -131
  143. package/src/services/session.service.spec.ts +0 -1293
  144. package/src/services/session.service.ts +0 -803
  145. package/src/services/social-account.service.spec.ts +0 -725
  146. package/src/services/social-auth-base.service.spec.ts +0 -418
  147. package/src/services/social-auth-base.service.ts +0 -581
  148. package/src/services/social-auth.service.spec.ts +0 -238
  149. package/src/services/social-auth.service.ts +0 -436
  150. package/src/services/social-provider-registry.service.spec.ts +0 -238
  151. package/src/services/social-provider-registry.service.ts +0 -122
  152. package/src/services/trusted-device.service.spec.ts +0 -505
  153. package/src/services/trusted-device.service.ts +0 -339
  154. package/src/storage/account-lockout-storage.service.spec.ts +0 -310
  155. package/src/storage/account-lockout-storage.service.ts +0 -89
  156. package/src/storage/index.ts +0 -3
  157. package/src/storage/memory-storage.adapter.ts +0 -443
  158. package/src/storage/rate-limit-storage.service.spec.ts +0 -247
  159. package/src/storage/rate-limit-storage.service.ts +0 -38
  160. package/src/templates/html-template.engine.spec.ts +0 -161
  161. package/src/templates/html-template.engine.ts +0 -688
  162. package/src/templates/index.ts +0 -7
  163. package/src/utils/common-passwords.spec.ts +0 -230
  164. package/src/utils/common-passwords.ts +0 -170
  165. package/src/utils/context-storage.ts +0 -188
  166. package/src/utils/cookie-names.util.ts +0 -67
  167. package/src/utils/cookies.util.ts +0 -94
  168. package/src/utils/index.ts +0 -12
  169. package/src/utils/ip-extractor.spec.ts +0 -330
  170. package/src/utils/ip-extractor.ts +0 -220
  171. package/src/utils/nauth-logger.spec.ts +0 -388
  172. package/src/utils/nauth-logger.ts +0 -215
  173. package/src/utils/pii-redactor.spec.ts +0 -130
  174. package/src/utils/pii-redactor.ts +0 -288
  175. package/src/utils/setup/get-repositories.ts +0 -140
  176. package/src/utils/setup/init-services.ts +0 -422
  177. package/src/utils/setup/init-social.ts +0 -189
  178. package/src/utils/setup/init-storage.ts +0 -94
  179. package/src/utils/setup/register-mfa.ts +0 -165
  180. package/src/utils/setup/run-nauth-migrations.ts +0 -61
  181. package/src/utils/token-delivery-policy.ts +0 -38
  182. package/src/validators/template.validator.ts +0 -219
  183. package/tsconfig.json +0 -37
  184. package/tsconfig.lint.json +0 -6
@@ -1,825 +0,0 @@
1
- import { Repository } from 'typeorm';
2
- import { BaseMFADevice } from '../entities';
3
- import { AuthResponseDTO } from '../dto/auth-response.dto';
4
- import { AuthChallenge } from '../dto/auth-challenge.dto';
5
- import { SendVerificationEmailDTO } from '../dto/verify-email.dto';
6
- import { SendVerificationSMSDTO } from '../dto/verify-phone.dto';
7
- import { IUser, IMFADevice } from '../interfaces/entities.interface';
8
- import { ChallengeService } from './challenge.service';
9
- import { JwtService } from './jwt.service';
10
- import { SessionService } from './session.service';
11
- import { EmailVerificationService } from './email-verification.service';
12
- import { PhoneVerificationService } from './phone-verification.service';
13
- import { ClientInfoService } from './client-info.service';
14
- import { NAuthConfig } from '../interfaces/config.interface';
15
- import { NAuthLogger } from '../utils/nauth-logger';
16
- import { NAuthException } from '../exceptions/nauth.exception';
17
- import { AuthErrorCode } from '../enums/error-codes.enum';
18
- import { MFAMethod, MFADeviceMethod, MFAVerificationMethod, MFADeviceMethods } from '../enums/mfa-method.enum';
19
- import { AuthFlowStateMachineService } from './auth-flow-state-machine.service';
20
- import { AuthFlowContextBuilder } from './auth-flow-context-builder.service';
21
- import { AuthFlowState, AuthFlowContext } from './auth-flow-state-machine.types';
22
-
23
- /**
24
- * Helper service for challenge-response authentication flows
25
- *
26
- * This service determines if a user needs to complete challenges
27
- * before full authentication can be granted, and generates appropriate
28
- * responses including MFA challenges.
29
- *
30
- * @example
31
- * ```typescript
32
- * const response = await challengeHelper.determineAuthResponse(
33
- * user,
34
- * 'login',
35
- * { ipAddress: '1.2.3.4' }
36
- * );
37
- * ```
38
- */
39
- export class AuthChallengeHelperService {
40
- constructor(
41
- private readonly challengeService: ChallengeService,
42
- private readonly jwtService: JwtService,
43
- private readonly sessionService: SessionService,
44
- private readonly mfaDeviceRepository: Repository<BaseMFADevice>,
45
- private readonly logger: NAuthLogger,
46
- private readonly stateMachine: AuthFlowStateMachineService,
47
- private readonly contextBuilder: AuthFlowContextBuilder,
48
- private readonly clientInfoService: ClientInfoService,
49
- private readonly emailVerificationService?: EmailVerificationService,
50
- private readonly phoneVerificationService?: PhoneVerificationService, // Optional - only available when SMS provider is configured
51
- ) {}
52
-
53
- // ============================================================================
54
- // OLD METHODS DELETED - Replaced by state machine
55
- // ============================================================================
56
- // determinePendingChallenges() - DELETED (replaced by state machine)
57
- // isMFASetupRequired() - DELETED (replaced by state machine)
58
- // checkMFARequirement() - DELETED (replaced by state machine)
59
- // All challenge determination is now handled by determineAuthResponse() using state machine
60
-
61
- /**
62
- * Create challenge response for authentication
63
- *
64
- * Generates a challenge session and returns challenge details to client.
65
- * Sends verification codes when challenges are created to ensure sequential flow.
66
- *
67
- * @param user - User who needs to complete challenges
68
- * @param challengeName - Type of challenge
69
- * @param config - Auth configuration
70
- * @param authMethod - Authentication method ('password' or 'social')
71
- * @param authProvider - Provider name for social auth (e.g., 'google', 'facebook')
72
- * @returns Challenge response DTO
73
- *
74
- * @example
75
- * ```typescript
76
- * const response = await challengeHelper.createChallengeResponse(
77
- * user,
78
- * AuthChallenge.VERIFY_EMAIL,
79
- * config,
80
- * 'social',
81
- * 'google'
82
- * );
83
- * ```
84
- */
85
- async createChallengeResponse(
86
- user: IUser,
87
- challengeName: AuthChallenge,
88
- config: NAuthConfig,
89
- authMethod: 'password' | 'social' = 'password',
90
- authProvider?: string,
91
- skipAutoSend?: boolean,
92
- ): Promise<AuthResponseDTO> {
93
- // Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
94
- // Note: ClientInfoService is used transparently by ChallengeService and AuditService
95
-
96
- // ============================================================================
97
- // STEP 1: Create challenge session FIRST (before sending codes)
98
- // ============================================================================
99
- // This ensures the session exists before any verification codes are sent.
100
- // Creating the session first is critical for proper audit trail and session tracking.
101
- this.logger?.debug?.(
102
- `Creating challenge with authMethod=${authMethod}, authProvider=${authProvider || 'none'} for user ${user.sub}`,
103
- );
104
-
105
- // Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
106
- const challengeSession = await this.challengeService.createChallengeSession(user, challengeName, {
107
- email: user.email,
108
- phone: user.phone,
109
- authMethod, // Store auth method for challenge completion flow
110
- authProvider, // Store provider for social auth (e.g., 'google', 'facebook')
111
- });
112
-
113
- // ============================================================================
114
- // STEP 2: Send verification codes AFTER session is created
115
- // ============================================================================
116
- // This ensures codes are sent at the right time:
117
- // - Email code sent when VERIFY_EMAIL challenge is created
118
- // - Phone code sent when VERIFY_PHONE challenge is created (after email is verified)
119
- // This prevents sending both codes at once, avoiding user confusion.
120
- // Challenges are sequential: first VERIFY_EMAIL, then VERIFY_PHONE
121
- if (challengeName === AuthChallenge.VERIFY_EMAIL && this.emailVerificationService) {
122
- this.logger?.log?.(`📧 Sending verification email to: ${user.email}`);
123
- // Fire and forget - don't block challenge response
124
- const emailDto = Object.assign(new SendVerificationEmailDTO(), {
125
- sub: user.sub,
126
- baseUrl: undefined,
127
- challengeSessionId: challengeSession.id, // Link verification token to this challenge session
128
- });
129
- this.emailVerificationService
130
- .sendVerificationEmail(emailDto)
131
- .then(() => {
132
- this.logger?.log?.(`Verification email sent successfully to: ${user.email}`);
133
- })
134
- .catch((error: unknown) => {
135
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
136
- this.logger?.error?.(`Failed to send verification email to ${user.email}: ${errorMessage}`);
137
- });
138
- }
139
-
140
- // Skip auto-send if SMS was already sent (e.g., during phone collection)
141
- if (!skipAutoSend && challengeName === AuthChallenge.VERIFY_PHONE && this.phoneVerificationService && user.phone) {
142
- this.logger?.log?.(`Sending verification SMS to: ${user.phone}`);
143
- // Fire and forget - don't block challenge response
144
- const smsDto = Object.assign(new SendVerificationSMSDTO(), {
145
- sub: user.sub,
146
- challengeSessionId: challengeSession.id, // Link verification token to this challenge session
147
- });
148
- this.phoneVerificationService
149
- .sendVerificationSMS(smsDto)
150
- .then(() => {
151
- this.logger?.log?.(`Verification SMS sent successfully to: ${user.phone}`);
152
- })
153
- .catch((error: unknown) => {
154
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
155
- this.logger?.error?.(`Failed to send verification SMS to ${user.phone}: ${errorMessage}`);
156
- });
157
- }
158
-
159
- // ============================================================================
160
- // STEP 3: Send MFA challenge code for MFA_REQUIRED (if SMS is preferred method)
161
- // ============================================================================
162
- // When user logs in and MFA verification is required, automatically send SMS code
163
- // if SMS is their preferred MFA method. This provides better UX by not requiring
164
- // a separate API call to trigger code sending.
165
- //
166
- // Note: MFA_REQUIRED challenges are handled by createMFAChallengeResponse()
167
- // which includes auto-send SMS logic
168
-
169
- // Build challenge parameters
170
- // Note: Type is Record<string, unknown> to allow arrays (e.g., allowedMethods for MFA)
171
- const challengeParameters: Record<string, unknown> = {};
172
-
173
- switch (challengeName) {
174
- case AuthChallenge.VERIFY_EMAIL:
175
- challengeParameters.email = user.email;
176
- challengeParameters.codeDeliveryDestination = this.challengeService.maskEmail(user.email);
177
- break;
178
-
179
- case AuthChallenge.VERIFY_PHONE:
180
- challengeParameters.phone = user.phone || undefined;
181
- challengeParameters.codeDeliveryDestination = user.phone
182
- ? this.challengeService.maskPhone(user.phone)
183
- : undefined;
184
- // If no phone, indicate user must provide it first
185
- if (!user.phone) {
186
- challengeParameters.requiresPhoneCollection = 'true';
187
- challengeParameters.instructions = 'You must add a phone number and verify it to continue';
188
- }
189
- break;
190
-
191
- case AuthChallenge.MFA_REQUIRED:
192
- challengeParameters.instructions = 'Multi-factor authentication is required';
193
- // Include masked phone if SMS is preferred method
194
- if (user.preferredMfaMethod === 'sms' && user.phone) {
195
- challengeParameters.codeDeliveryDestination = this.challengeService.maskPhone(user.phone);
196
- }
197
- // Include masked email if Email is preferred method
198
- if (user.preferredMfaMethod === 'email' && user.email) {
199
- challengeParameters.codeDeliveryDestination = this.challengeService.maskEmail(user.email);
200
- }
201
- break;
202
-
203
- case AuthChallenge.MFA_SETUP_REQUIRED: {
204
- const allowedMethods = config.mfa?.allowedMethods || [...MFADeviceMethods];
205
- challengeParameters.allowedMethods = allowedMethods;
206
- challengeParameters.instructions = 'Multi-factor authentication setup is required before you can login';
207
- break;
208
- }
209
-
210
- case AuthChallenge.FORCE_CHANGE_PASSWORD:
211
- challengeParameters.instructions = 'You must change your password before continuing';
212
- break;
213
- }
214
-
215
- const response: AuthResponseDTO = {
216
- challengeName,
217
- session: challengeSession.sessionToken,
218
- challengeParameters,
219
- userSub: user.sub,
220
- };
221
-
222
- return response;
223
- }
224
-
225
- // ============================================================================
226
- // MFA Challenge Support
227
- // ============================================================================
228
- // checkMFARequirement() - DELETED (replaced by state machine)
229
- // All MFA requirement checking is now handled by state machine in determineAuthResponse()
230
-
231
- /**
232
- * Create MFA setup challenge response
233
- *
234
- * Generates challenge session for MFA setup requirement.
235
- * User must set up MFA before being allowed to login.
236
- *
237
- * @param user - User requiring MFA setup
238
- * @param config - Auth configuration
239
- * @param authMethod - Authentication method ('password' or 'social')
240
- * @param authProvider - Provider name for social auth (e.g., 'google', 'facebook')
241
- * @returns MFA setup challenge response
242
- *
243
- * @example
244
- * ```typescript
245
- * const response = await challengeHelper.createMFASetupChallengeResponse(
246
- * user,
247
- * config,
248
- * 'social',
249
- * 'google'
250
- * );
251
- * // Returns: { challengeName: 'MFA_SETUP_REQUIRED', session: '...', challengeParameters: {...} }
252
- * ```
253
- */
254
- async createMFASetupChallengeResponse(
255
- user: IUser,
256
- config: NAuthConfig,
257
- authMethod: 'password' | 'social' = 'password',
258
- authProvider?: string,
259
- ): Promise<AuthResponseDTO> {
260
- // Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
261
- // Note: ClientInfoService is used transparently by ChallengeService and AuditService
262
- this.logger?.log?.(`Creating MFA setup challenge for user: ${user.sub}`);
263
-
264
- const allowedMethods = config.mfa?.allowedMethods || [...MFADeviceMethods];
265
-
266
- // Create challenge session with auth context
267
- this.logger?.debug?.(
268
- `Creating MFA setup challenge with authMethod=${authMethod}, authProvider=${authProvider || 'none'} for user ${user.sub}`,
269
- );
270
-
271
- // Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
272
- const challengeSession = await this.challengeService.createChallengeSession(
273
- user,
274
- AuthChallenge.MFA_SETUP_REQUIRED,
275
- {
276
- allowedMethods,
277
- requiresSetup: true,
278
- authMethod, // Store auth method for challenge completion flow
279
- authProvider, // Store provider for social auth
280
- },
281
- );
282
-
283
- this.logger?.log?.(`MFA setup challenge created for user: ${user.sub}`);
284
-
285
- // Return challenge response
286
- return {
287
- challengeName: AuthChallenge.MFA_SETUP_REQUIRED,
288
- session: challengeSession.sessionToken,
289
- challengeParameters: {
290
- allowedMethods,
291
- instructions: 'Multi-factor authentication setup is required before you can login',
292
- },
293
- userSub: user.sub,
294
- } as AuthResponseDTO;
295
- }
296
-
297
- /**
298
- * Create MFA challenge response
299
- *
300
- * Generates challenge session for MFA verification.
301
- * Returns available MFA methods and challenge parameters.
302
- *
303
- * @param user - User requiring MFA
304
- * @returns MFA challenge response
305
- * @remarks Client info (ipAddress, userAgent) is automatically extracted from ClientInfoService context
306
- *
307
- * @example
308
- * ```typescript
309
- * const response = await challengeHelper.createMFAChallengeResponse(
310
- * user,
311
- * '1.2.3.4',
312
- * 'Mozilla/5.0...'
313
- * );
314
- * // Returns: { challengeName: 'MFA_REQUIRED', session: '...', challengeParameters: {...} }
315
- * ```
316
- */
317
- async createMFAChallengeResponse(user: IUser): Promise<AuthResponseDTO> {
318
- // Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
319
- // Note: ClientInfoService is used transparently by ChallengeService and AuditService
320
- this.logger?.log?.(`Creating MFA challenge for user: ${user.sub}`);
321
-
322
- // Get user's active MFA devices
323
- const devices = (await this.mfaDeviceRepository.find({
324
- where: { userId: user.id, isActive: true },
325
- order: { isPrimary: 'DESC', lastUsedAt: 'DESC' },
326
- })) as unknown as IMFADevice[];
327
-
328
- if (devices.length === 0) {
329
- this.logger?.warn?.(`User has MFA enabled but no active devices: ${user.sub}`);
330
- // User has MFA enabled but no devices - should not happen
331
- // Allow login and let them set up MFA
332
- throw new NAuthException(AuthErrorCode.INTERNAL_ERROR, 'MFA enabled but no devices configured');
333
- }
334
-
335
- // Get available methods (device types only - no backup)
336
- const deviceMethods = [...new Set(devices.map((d) => d.type))] as MFADeviceMethod[];
337
-
338
- // Build full available methods list (including backup if available)
339
- const availableMethods: MFAVerificationMethod[] = [...deviceMethods];
340
- if (user.backupCodes && user.backupCodes.length > 0) {
341
- availableMethods.push(MFAMethod.BACKUP);
342
- }
343
-
344
- // Debug logging for troubleshooting
345
- this.logger?.debug?.(
346
- `MFA challenge for user ${user.sub}: preferredMfaMethod=${user.preferredMfaMethod}, deviceMethods=[${deviceMethods.join(', ')}], devices=[${devices.map((d) => `${d.type}${d.isPrimary ? '(primary)' : ''}`).join(', ')}]`,
347
- );
348
-
349
- // Determine preferred method - prioritize user.preferredMfaMethod over primaryDevice
350
- // This ensures that when user explicitly sets a preferred method, it's respected
351
- let preferredMethod: string;
352
-
353
- // Normalize preferred method to lowercase for comparison (database might store in different case)
354
- const normalizedPreferredMethod = user.preferredMfaMethod?.toLowerCase();
355
-
356
- // Check if user has a preferred method and it's available
357
- if (
358
- normalizedPreferredMethod &&
359
- (normalizedPreferredMethod === MFAMethod.TOTP ||
360
- normalizedPreferredMethod === MFAMethod.SMS ||
361
- normalizedPreferredMethod === MFAMethod.EMAIL ||
362
- normalizedPreferredMethod === MFAMethod.PASSKEY) &&
363
- deviceMethods.some((m) => m.toLowerCase() === normalizedPreferredMethod)
364
- ) {
365
- // User has explicitly set a preferred method and it's available
366
- // Find the actual method from deviceMethods to ensure case consistency
367
- preferredMethod =
368
- deviceMethods.find((m) => m.toLowerCase() === normalizedPreferredMethod) || normalizedPreferredMethod;
369
- this.logger?.debug?.(
370
- `Using user preferred MFA method: ${preferredMethod} (from user.preferredMfaMethod: ${user.preferredMfaMethod})`,
371
- );
372
- } else {
373
- // Fallback to primary device or first available method
374
- const primaryDevice = devices.find((d) => d.isPrimary);
375
- preferredMethod = primaryDevice?.type || deviceMethods[0];
376
- this.logger?.debug?.(
377
- `Using fallback MFA method: ${preferredMethod} (preferred: ${user.preferredMfaMethod}, available: ${deviceMethods.join(', ')})`,
378
- );
379
- }
380
-
381
- // Get masked phone if SMS is available
382
- let maskedPhone: string | undefined;
383
- const smsDevice = devices.find((d) => d.type === MFAMethod.SMS && d.phoneNumber);
384
- if (smsDevice?.phoneNumber) {
385
- const digits = smsDevice.phoneNumber.replace(/\D/g, '');
386
- maskedPhone = digits.length >= 4 ? `***-***-${digits.slice(-4)}` : smsDevice.phoneNumber;
387
- }
388
-
389
- // Get masked email if Email is available
390
- let maskedEmail: string | undefined;
391
- const emailDevice = devices.find((d) => d.type === MFAMethod.EMAIL && d.email);
392
- const emailToMask = emailDevice?.email || user.email; // Fallback to user.email if device doesn't have it
393
- if (emailToMask) {
394
- // Mask email: show first char and domain (e.g., u***r@example.com)
395
- const [localPart, domain] = emailToMask.split('@');
396
- if (localPart && domain) {
397
- const firstChar = localPart[0];
398
- const lastChar = localPart[localPart.length - 1];
399
- maskedEmail = localPart.length > 2 ? `${firstChar}***${lastChar}@${domain}` : `${firstChar}***@${domain}`;
400
- } else {
401
- maskedEmail = emailToMask;
402
- }
403
- }
404
-
405
- // Create challenge session
406
- // Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
407
- // Store preferred method in metadata for resend endpoint to know which method to use
408
- const challengeSession = await this.challengeService.createChallengeSession(user, AuthChallenge.MFA_REQUIRED, {
409
- availableMethods,
410
- preferredMethod,
411
- maskedPhone,
412
- maskedEmail,
413
- method: preferredMethod, // Store method in metadata for resend endpoint
414
- });
415
-
416
- this.logger?.log?.(`MFA challenge created for user: ${user.sub}`);
417
-
418
- // ============================================================================
419
- // Auto-send SMS code if SMS is the preferred method
420
- // ============================================================================
421
- // Automatically send SMS code if:
422
- // 1. SMS is user's preferred MFA method, OR
423
- // 2. SMS is the ONLY MFA method they have setup
424
- //
425
- // This provides better UX by not requiring a separate API call to trigger code sending.
426
- const smsIsPreferred = preferredMethod.toLowerCase() === 'sms';
427
- const smsIsOnly = deviceMethods.length === 1 && deviceMethods[0].toLowerCase() === 'sms';
428
-
429
- if ((smsIsPreferred || smsIsOnly) && this.phoneVerificationService && user.phone) {
430
- this.logger?.log?.(
431
- `Auto-sending MFA SMS code to user ${user.sub} (preferred=${smsIsPreferred}, only=${smsIsOnly})`,
432
- );
433
- // Fire and forget - don't block challenge response
434
- // Use PhoneVerificationService which handles SMS sending, rate limits, and token storage
435
- // skipAlreadyVerifiedCheck=true because phone is already verified but we need MFA code
436
- const smsDto = Object.assign(new SendVerificationSMSDTO(), {
437
- sub: user.sub,
438
- skipAlreadyVerifiedCheck: true,
439
- challengeSessionId: challengeSession.id, // Link MFA SMS code to this challenge session
440
- });
441
- this.phoneVerificationService
442
- .sendVerificationSMS(smsDto)
443
- .then(() => {
444
- this.logger?.log?.(`MFA SMS code sent successfully to user ${user.sub}`);
445
- })
446
- .catch((error: unknown) => {
447
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
448
- this.logger?.error?.(`Failed to send MFA SMS code to user ${user.sub}: ${errorMessage}`);
449
- });
450
- } else {
451
- this.logger?.debug?.(
452
- `Skipped auto-send MFA SMS for user ${user.sub}: ` +
453
- `phoneService=${!!this.phoneVerificationService}, ` +
454
- `preferredMethod=${preferredMethod}, ` +
455
- `smsIsPreferred=${smsIsPreferred}, ` +
456
- `smsIsOnly=${smsIsOnly}, ` +
457
- `deviceMethods=[${deviceMethods.join(', ')}], ` +
458
- `phone=${!!user.phone}`,
459
- );
460
- }
461
-
462
- // ============================================================================
463
- // Auto-send Email code if Email is the preferred method
464
- // ============================================================================
465
- // Automatically send Email code if:
466
- // 1. Email is user's preferred MFA method, OR
467
- // 2. Email is the ONLY MFA method they have setup
468
- //
469
- // This provides better UX by not requiring a separate API call to trigger code sending.
470
- const emailIsPreferred = preferredMethod.toLowerCase() === 'email';
471
- const emailIsOnly = deviceMethods.length === 1 && deviceMethods[0].toLowerCase() === 'email';
472
-
473
- if ((emailIsPreferred || emailIsOnly) && this.emailVerificationService && user.email) {
474
- this.logger?.log?.(
475
- `Auto-sending MFA Email code to user ${user.sub} (preferred=${emailIsPreferred}, only=${emailIsOnly})`,
476
- );
477
- // Fire and forget - don't block challenge response
478
- // Use EmailVerificationService which handles email sending, rate limits, and token storage
479
- // skipAlreadyVerifiedCheck=true because email is already verified but we need MFA code
480
- const emailDto = Object.assign(new SendVerificationEmailDTO(), {
481
- sub: user.sub,
482
- baseUrl: undefined,
483
- skipAlreadyVerifiedCheck: true,
484
- challengeSessionId: challengeSession.id, // Link MFA email code to this challenge session
485
- });
486
- this.emailVerificationService
487
- .sendVerificationEmail(emailDto)
488
- .then(() => {
489
- this.logger?.log?.(`MFA Email code sent successfully to user ${user.sub}`);
490
- })
491
- .catch((error: unknown) => {
492
- const errorMessage = error instanceof Error ? error.message : 'Unknown error';
493
- this.logger?.error?.(`Failed to send MFA Email code to user ${user.sub}: ${errorMessage}`);
494
- });
495
- } else {
496
- this.logger?.debug?.(
497
- `Skipped auto-send MFA Email for user ${user.sub}: ` +
498
- `emailService=${!!this.emailVerificationService}, ` +
499
- `preferredMethod=${preferredMethod}, ` +
500
- `emailIsPreferred=${emailIsPreferred}, ` +
501
- `emailIsOnly=${emailIsOnly}, ` +
502
- `deviceMethods=[${deviceMethods.join(', ')}], ` +
503
- `email=${!!user.email}`,
504
- );
505
- }
506
-
507
- // Return challenge response
508
- // Always include maskedEmail if email is preferred, even if undefined (frontend can use user.email)
509
- const challengeParams: Record<string, unknown> = {
510
- availableMethods,
511
- preferredMethod: preferredMethod as MFADeviceMethod,
512
- };
513
- if (maskedPhone) {
514
- challengeParams.maskedPhone = maskedPhone;
515
- }
516
- if (maskedEmail || preferredMethod.toLowerCase() === 'email') {
517
- // Include maskedEmail if available, or if email is preferred (frontend will handle display)
518
- challengeParams.maskedEmail = maskedEmail || user.email || '';
519
- }
520
-
521
- return {
522
- challengeName: AuthChallenge.MFA_REQUIRED,
523
- session: challengeSession.sessionToken,
524
- challengeParameters: challengeParams,
525
- } as AuthResponseDTO;
526
- }
527
-
528
- // ============================================================================
529
- // Success Response
530
- // ============================================================================
531
-
532
- /**
533
- * Create successful authentication response with tokens
534
- *
535
- * Generates tokens and session for fully authenticated user.
536
- *
537
- * @param user - Authenticated user
538
- * @param deviceToken - Device token (optional)
539
- * @param isTrusted - Whether device is trusted (optional)
540
- * @param isSocialLogin - Whether this is a social login (optional)
541
- * @param metadata - Response metadata (optional)
542
- * @returns Auth response with tokens
543
- *
544
- * @example
545
- * ```typescript
546
- * const response = await challengeHelper.createSuccessResponse(
547
- * user,
548
- * 'abc123',
549
- * true,
550
- * false
551
- * );
552
- * ```
553
- */
554
- async createSuccessResponse(
555
- user: IUser,
556
- deviceToken?: string,
557
- isTrusted?: boolean,
558
- _isSocialLogin = false, // Reserved for future use
559
- _metadata?: {
560
- // Reserved for future use
561
- gracePeriodEndsAt?: Date;
562
- riskScore?: number;
563
- riskLevel?: 'low' | 'medium' | 'high';
564
- blockedUntil?: Date;
565
- reason?: string;
566
- },
567
- ): Promise<AuthResponseDTO> {
568
- // Get client info from ClientInfoService (for deviceToken only - IP/userAgent come from context automatically)
569
- const clientInfo = this.clientInfoService.get();
570
- const finalDeviceToken = clientInfo.deviceToken || deviceToken;
571
-
572
- // ============================================================================
573
- // SECURITY: Defense-in-depth validation before token issuance
574
- // ============================================================================
575
- // Note: Challenge validation is now handled by state machine in determineAuthResponse
576
- // This method is only called when state is AUTHENTICATED, so no additional check needed
577
-
578
- // Generate token family for rotation tracking
579
- const tokenFamily = this.jwtService.generateTokenFamily();
580
-
581
- // Generate temporary tokens first (session creation requires token hashes)
582
- // Note: deviceId not included in token - session.deviceId is source of truth
583
- const tempTokens = await this.jwtService.generateTokenPair({
584
- userId: user.sub, // Use sub in JWT payload (external identifier)
585
- email: user.email,
586
- sessionId: 'temp', // Temporary - will be regenerated with real sessionId
587
- tokenFamily,
588
- });
589
-
590
- // Generate deviceId if not provided
591
- let finalDeviceId = finalDeviceToken;
592
- if (!finalDeviceId) {
593
- const crypto = await import('crypto');
594
- finalDeviceId = crypto.randomUUID();
595
- }
596
-
597
- // Create session
598
- // Client info (ipAddress, ipCountry, ipCity, userAgent) automatically extracted from ClientInfoService
599
- const session = await this.sessionService.createSession({
600
- userId: user.id, // Use internal id for foreign key
601
- accessTokenHash: this.jwtService.hashToken(tempTokens.accessToken),
602
- refreshTokenHash: this.jwtService.hashToken(tempTokens.refreshToken),
603
- tokenFamily,
604
- deviceId: finalDeviceId,
605
- expiresAt: this.sessionService.getSessionExpirationDate(),
606
- authMethod: 'password', // Default to password for challenge flows (signup, verification completion)
607
- });
608
-
609
- // Now regenerate tokens with the actual sessionId
610
- // Note: deviceId not included in token - session.deviceId is source of truth
611
- const tokens = await this.jwtService.generateTokenPair({
612
- userId: user.sub,
613
- email: user.email,
614
- sessionId: session.id.toString(),
615
- tokenFamily,
616
- });
617
-
618
- // Update session with new token hashes
619
- await this.sessionService.updateTokens(
620
- session.id,
621
- this.jwtService.hashToken(tokens.accessToken),
622
- this.jwtService.hashToken(tokens.refreshToken),
623
- );
624
-
625
- // Decode tokens to get expiry times
626
- const accessTokenValidation = await this.jwtService.validateAccessToken(tokens.accessToken);
627
- const refreshTokenValidation = await this.jwtService.validateRefreshToken(tokens.refreshToken);
628
-
629
- const response: AuthResponseDTO = {
630
- accessToken: tokens.accessToken,
631
- refreshToken: tokens.refreshToken,
632
- accessTokenExpiresAt: accessTokenValidation.payload?.exp || 0,
633
- refreshTokenExpiresAt: refreshTokenValidation.payload?.exp || 0,
634
- trusted: isTrusted,
635
- // Expose deviceToken so that:
636
- // - In cookies mode, CookieTokenInterceptor can set the httpOnly nauth_device_token cookie
637
- // - In JSON mode, mobile clients can store it securely and send via header
638
- // NOTE: finalDeviceToken is a logical device identifier derived from:
639
- // - clientInfo.deviceToken (existing trusted device), OR
640
- // - deviceToken parameter passed from AuthService / state machine
641
- deviceToken: finalDeviceToken,
642
- user: {
643
- sub: user.sub,
644
- email: user.email,
645
- firstName: user.firstName,
646
- lastName: user.lastName,
647
- phone: user.phone ?? undefined,
648
- isEmailVerified: user.isEmailVerified,
649
- isPhoneVerified: user.isPhoneVerified ?? undefined,
650
- socialProviders: user.socialProviders ?? undefined,
651
- hasPasswordHash: !!user.passwordHash,
652
- },
653
- userSub: user.sub,
654
- };
655
-
656
- return response;
657
- }
658
-
659
- /**
660
- * Determine and create appropriate auth response
661
- *
662
- * Main entry point that decides whether to return challenges or tokens.
663
- * Uses state machine to evaluate authentication flow state.
664
- *
665
- * @param params - Authentication parameters
666
- * @param params.user - User attempting authentication
667
- * @param params.config - Auth configuration
668
- * @param params.deviceToken - Device token (optional)
669
- * @param params.isSocialLogin - Whether this is a social login (OAuth) authentication (optional)
670
- * @param params.skipMFAVerification - Skip MFA verification flag (optional)
671
- * @param params.authProvider - Social auth provider name (optional)
672
- * @returns Auth response (either challenge or success)
673
- *
674
- * @example
675
- * ```typescript
676
- * const response = await challengeHelper.determineAuthResponse({
677
- * user,
678
- * config,
679
- * deviceToken: 'abc123',
680
- * isSocialLogin: false
681
- * });
682
- * ```
683
- */
684
- async determineAuthResponse(params: {
685
- user: IUser;
686
- config: NAuthConfig;
687
- deviceToken?: string;
688
- isSocialLogin?: boolean;
689
- skipMFAVerification?: boolean;
690
- authProvider?: string;
691
- }): Promise<AuthResponseDTO> {
692
- const { user, config, deviceToken, isSocialLogin = false, skipMFAVerification = false, authProvider } = params;
693
-
694
- this.logger?.debug?.(
695
- `[ChallengeHelper] determineAuthResponse called for user ${user.sub} (isSocialLogin=${isSocialLogin}, skipMFA=${skipMFAVerification}, deviceToken=${deviceToken ? 'present' : 'none'})`,
696
- );
697
-
698
- // Build context with pre-computed values
699
- const context = await this.contextBuilder.build({
700
- user,
701
- config,
702
- authMethod: isSocialLogin ? 'social' : 'password',
703
- authProvider,
704
- deviceToken,
705
- skipMFAVerification,
706
- });
707
-
708
- // Evaluate state using state machine
709
- const state = await this.stateMachine.evaluateState(context);
710
-
711
- // Get state definition
712
- const stateDefinition = this.stateMachine.getStateDefinition(state);
713
- if (!stateDefinition) {
714
- this.logger?.error?.(`No state definition found for state: ${state}`, { state, userId: user.id });
715
- throw new NAuthException(AuthErrorCode.INTERNAL_ERROR, 'Invalid authentication state');
716
- }
717
-
718
- // Build metadata if available
719
- const metadata = this.stateMachine.buildMetadata(state, context);
720
-
721
- // Convert state to response
722
- const response = await this.stateToResponse(state, stateDefinition, context, metadata);
723
-
724
- this.logger?.debug?.(
725
- `[ChallengeHelper] State ${state} → Challenge: ${response.challengeName || 'SUCCESS'} for user ${user.sub}`,
726
- );
727
-
728
- return response;
729
- }
730
-
731
- /**
732
- * Convert state to authentication response
733
- *
734
- * Maps state to appropriate response (challenge or success).
735
- * Merges state metadata into response.
736
- *
737
- * @param state - Authentication flow state
738
- * @param stateDefinition - State definition
739
- * @param context - Authentication flow context
740
- * @param metadata - Response metadata (optional)
741
- * @returns Authentication response
742
- */
743
- private async stateToResponse(
744
- state: AuthFlowState,
745
- stateDefinition: { challenge?: AuthChallenge },
746
- context: AuthFlowContext,
747
- metadata?: {
748
- gracePeriodEndsAt?: Date;
749
- riskScore?: number;
750
- riskLevel?: 'low' | 'medium' | 'high';
751
- blockedUntil?: Date;
752
- reason?: string;
753
- },
754
- ): Promise<AuthResponseDTO> {
755
- // Get client info from ClientInfoService
756
- const clientInfo = this.clientInfoService.get();
757
- const deviceToken = clientInfo.deviceToken || context.deviceToken;
758
-
759
- const authMethod = context.authMethod || 'password';
760
-
761
- // Handle challenge states
762
- if (stateDefinition.challenge) {
763
- // Handle MFA_SETUP_REQUIRED challenge specially
764
- if (stateDefinition.challenge === AuthChallenge.MFA_SETUP_REQUIRED) {
765
- return this.createMFASetupChallengeResponse(context.user, context.config, authMethod, context.authProvider);
766
- }
767
-
768
- // Handle MFA_REQUIRED challenge specially - needs preferred method logic
769
- if (stateDefinition.challenge === AuthChallenge.MFA_REQUIRED) {
770
- return this.createMFAChallengeResponse(context.user);
771
- }
772
-
773
- // Handle other challenges
774
- return this.createChallengeResponse(
775
- context.user,
776
- stateDefinition.challenge,
777
- context.config,
778
- authMethod,
779
- context.authProvider,
780
- );
781
- }
782
-
783
- // Handle special states
784
- if (state === AuthFlowState.GRACE_PERIOD_ACTIVE) {
785
- // Grace period active - return success with metadata
786
- const isTrusted = context.computed.isDeviceTrusted;
787
- const response = await this.createSuccessResponse(
788
- context.user,
789
- deviceToken,
790
- isTrusted,
791
- context.authMethod === 'social',
792
- metadata,
793
- );
794
- // Merge metadata
795
- if (metadata?.gracePeriodEndsAt) {
796
- (response as AuthResponseDTO & { gracePeriodEndsAt?: Date }).gracePeriodEndsAt = metadata.gracePeriodEndsAt;
797
- }
798
- if (metadata?.riskScore !== undefined) {
799
- (response as AuthResponseDTO & { riskScore?: number }).riskScore = metadata.riskScore;
800
- }
801
- if (metadata?.riskLevel) {
802
- (response as AuthResponseDTO & { riskLevel?: 'low' | 'medium' | 'high' }).riskLevel = metadata.riskLevel;
803
- }
804
- return response;
805
- }
806
-
807
- if (state === AuthFlowState.BLOCKED) {
808
- // User is blocked - throw exception with metadata
809
- const errorCode =
810
- (context.config.mfa?.adaptive?.blockedSignIn?.errorCode as AuthErrorCode) ||
811
- AuthErrorCode.SIGNIN_BLOCKED_HIGH_RISK;
812
- const message =
813
- metadata?.reason ||
814
- context.config.mfa?.adaptive?.blockedSignIn?.message ||
815
- 'Sign-in blocked due to suspicious activity';
816
- throw new NAuthException(errorCode, message, {
817
- expiresAt: metadata?.blockedUntil,
818
- });
819
- }
820
-
821
- // AUTHENTICATED state - return success
822
- const isTrusted = context.computed.isDeviceTrusted;
823
- return this.createSuccessResponse(context.user, deviceToken, isTrusted, context.authMethod === 'social', metadata);
824
- }
825
- }