@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,696 +0,0 @@
1
- import { IUser, IChallengeSession } from '../interfaces/entities.interface';
2
- import { Repository, LessThan } from 'typeorm';
3
- import { BaseChallengeSession } from '../entities';
4
- import { randomUUID } from 'crypto';
5
- import { AuthChallenge } from '../dto/auth-challenge.dto';
6
- import { NAuthLogger } from '../utils/nauth-logger';
7
- import { InternalAuthAuditService as AuthAuditService } from './auth-audit.service';
8
- import { AuthAuditEventType } from '../enums/auth-audit-event-type.enum';
9
- import { NAuthException } from '../exceptions/nauth.exception';
10
- import { AuthErrorCode } from '../enums/error-codes.enum';
11
- import { ClientInfoService } from './client-info.service';
12
- import { NAuthConfig } from '../interfaces/config.interface';
13
-
14
- /**
15
- * Challenge Session Service
16
- *
17
- * Manages authentication challenge sessions for the challenge-response flow.
18
- * Challenge sessions are temporary, short-lived sessions (typically 15 minutes)
19
- * that track pending authentication challenges similar to AWS Cognito.
20
- *
21
- * Handles:
22
- * - Challenge session creation and validation
23
- * - Session expiration and cleanup
24
- * - Attempt tracking and rate limiting
25
- * - Secure session token generation
26
- *
27
- * @example
28
- * ```typescript
29
- * // Create a challenge session
30
- * const session = await challengeService.createChallengeSession(
31
- * user,
32
- * AuthChallenge.VERIFY_EMAIL,
33
- * { email: user.email }
34
- * );
35
- *
36
- * // Validate and consume a challenge session
37
- * const validSession = await challengeService.validateAndConsumeSession(
38
- * sessionToken,
39
- * AuthChallenge.VERIFY_EMAIL
40
- * );
41
- * ```
42
- */
43
- export class ChallengeService {
44
- /**
45
- * Default challenge session expiration time (15 minutes)
46
- */
47
- private readonly DEFAULT_EXPIRATION_MINUTES = 15;
48
-
49
- /**
50
- * Default maximum attempts per challenge session
51
- */
52
- private readonly DEFAULT_MAX_ATTEMPTS = 3;
53
-
54
- constructor(
55
- private readonly challengeSessionRepository: Repository<BaseChallengeSession>,
56
- private readonly clientInfoService: ClientInfoService,
57
- private readonly logger: NAuthLogger,
58
- private readonly auditService?: AuthAuditService, // Optional - audit trail service (enabled via config.auditLogs.enabled)
59
- private readonly config?: NAuthConfig, // Optional - config for maxAttempts
60
- ) {}
61
-
62
- /**
63
- * Per-user cleanup throttle map to avoid frequent cleanup writes
64
- */
65
- private readonly lastCleanupByUserId: Map<number, number> = new Map();
66
-
67
- // ============================================================================
68
- // Challenge Session Creation
69
- // ============================================================================
70
-
71
- /**
72
- * Create a new challenge session
73
- *
74
- * Generates a unique session token and stores challenge metadata.
75
- * The session token is returned to the client and must be submitted
76
- * when responding to the challenge.
77
- *
78
- * **Deduplication:**
79
- * If an active (non-completed, non-expired) session already exists for the same user
80
- * and challenge type, this method returns the existing session instead of creating
81
- * a duplicate. This prevents:
82
- * - Excessive `CHALLENGE_CREATED` audit events
83
- * - Database bloat from duplicate sessions
84
- * - User confusion from multiple active sessions for the same challenge
85
- *
86
- * @param user - User the challenge session belongs to
87
- * @param challengeName - Type of challenge (VERIFY_EMAIL, VERIFY_PHONE, etc.)
88
- * @param metadata - Challenge-specific data
89
- * @returns Challenge session with session token (new or existing)
90
- * @remarks Client info (ipAddress, userAgent) is automatically extracted from ClientInfoService context
91
- *
92
- * @example
93
- * ```typescript
94
- * const session = await challengeService.createChallengeSession(
95
- * user,
96
- * AuthChallenge.VERIFY_EMAIL,
97
- * { email: user.email, verificationTokenId: tokenId }
98
- * );
99
- * // Returns: { sessionToken: 'uuid-here', expiresAt: Date, ... }
100
- * // If called again before completion, returns same session (no duplicate audit event)
101
- * ```
102
- */
103
- async createChallengeSession(
104
- user: IUser,
105
- challengeName: AuthChallenge,
106
- metadata?: Record<string, unknown>,
107
- ): Promise<IChallengeSession> {
108
- // Get client info from context (transparent access)
109
- const clientInfo = this.clientInfoService.get();
110
-
111
- // ============================================================================
112
- // DEDUPLICATION: Check for existing active challenge session
113
- // ============================================================================
114
- // If an active (non-completed, non-expired) session already exists for this
115
- // user and challenge type, return it instead of creating a duplicate.
116
- // This prevents excessive audit logging and database bloat.
117
- const existingSession = await this.challengeSessionRepository.findOne({
118
- where: {
119
- userId: user.id,
120
- challengeName,
121
- isCompleted: false,
122
- },
123
- order: { createdAt: 'DESC' },
124
- relations: ['user'],
125
- });
126
-
127
- if (existingSession) {
128
- const session = existingSession as unknown as IChallengeSession;
129
- // Get current maxAttempts from config
130
- const currentMaxAttempts = this.config?.challenge?.maxAttempts ?? this.DEFAULT_MAX_ATTEMPTS;
131
-
132
- // Check if session is still valid (not expired and not max attempts exceeded)
133
- const isExpired = session.expiresAt <= new Date();
134
- const isMaxAttemptsExceeded = session.attempts >= session.maxAttempts;
135
-
136
- if (!isExpired && !isMaxAttemptsExceeded) {
137
- // Update maxAttempts to match current config if different
138
- // This ensures sessions created with old config values are updated
139
- if (session.maxAttempts !== currentMaxAttempts) {
140
- session.maxAttempts = currentMaxAttempts;
141
- await this.challengeSessionRepository.save(session);
142
- this.logger?.debug?.(
143
- `Updated maxAttempts for existing session: user=${user.sub}, old=${session.maxAttempts}, new=${currentMaxAttempts}`,
144
- );
145
- }
146
- this.logger?.debug?.(
147
- `Reusing existing challenge session: user=${user.sub}, challenge=${challengeName}, session=${session.sessionToken}`,
148
- );
149
-
150
- // ============================================================================
151
- // Audit: Record challenge reuse for complete audit trail
152
- // ============================================================================
153
- try {
154
- await this.auditService?.recordEvent({
155
- userId: user.id,
156
- eventType: AuthAuditEventType.CHALLENGE_CREATED,
157
- eventStatus: 'INFO',
158
- challengeSessionId: session.id,
159
- metadata: {
160
- challengeName,
161
- sessionToken: session.sessionToken,
162
- reused: true, // Indicate this was an existing session
163
- },
164
- });
165
- } catch (auditError) {
166
- // Non-blocking: Log but continue
167
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
168
- this.logger?.error?.(`Failed to record CHALLENGE_CREATED (reused) audit event: ${errorMessage}`, {
169
- error: auditError,
170
- userId: user.id,
171
- challengeName,
172
- });
173
- }
174
-
175
- return session;
176
- }
177
- // If expired or max attempts exceeded, delete it and create a new one
178
- const reason = isExpired ? 'expired' : 'max attempts exceeded';
179
- this.logger?.debug?.(
180
- `Existing challenge session ${reason}, creating new one: user=${user.sub}, challenge=${challengeName}`,
181
- );
182
- await this.challengeSessionRepository.delete({ id: session.id });
183
- }
184
-
185
- // Clean up any expired or completed sessions for this user (throttled)
186
- const now = Date.now();
187
- const lastCleanup = this.lastCleanupByUserId.get(user.id) || 0;
188
- // Run at most once per 5 minutes per user to reduce write load
189
- if (now - lastCleanup > 5 * 60 * 1000) {
190
- await this.cleanupExpiredSessions(user.id);
191
- this.lastCleanupByUserId.set(user.id, now);
192
- }
193
-
194
- const sessionToken = randomUUID();
195
- const expiresAt = new Date(Date.now() + this.DEFAULT_EXPIRATION_MINUTES * 60 * 1000);
196
-
197
- // Get maxAttempts from config or use default
198
- const maxAttempts = this.config?.challenge?.maxAttempts ?? this.DEFAULT_MAX_ATTEMPTS;
199
-
200
- const challengeSession = this.challengeSessionRepository.create({
201
- userId: user.id,
202
- challengeName,
203
- sessionToken,
204
- expiresAt,
205
- metadata,
206
- // Client info automatically extracted from ClientInfoService (transparent access)
207
- ipAddress: clientInfo.ipAddress || null,
208
- userAgent: clientInfo.userAgent || null,
209
- attempts: 0, // Explicitly initialize attempts to 0
210
- maxAttempts,
211
- });
212
-
213
- await this.challengeSessionRepository.save(challengeSession);
214
-
215
- this.logger?.log?.(
216
- `Challenge session created: user=${user.sub}, challenge=${challengeName}, maxAttempts=${maxAttempts}`,
217
- );
218
-
219
- // ============================================================================
220
- // Audit: Record challenge creation
221
- // ============================================================================
222
- try {
223
- await this.auditService?.recordEvent({
224
- userId: user.id,
225
- eventType: AuthAuditEventType.CHALLENGE_CREATED,
226
- eventStatus: 'INFO',
227
- challengeSessionId: (challengeSession as unknown as IChallengeSession).id,
228
- // Client info automatically included from context (no need to pass explicitly)
229
- metadata: {
230
- challengeName,
231
- sessionToken: (challengeSession as unknown as IChallengeSession).sessionToken,
232
- },
233
- });
234
- } catch (auditError) {
235
- // Non-blocking: Log but continue
236
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
237
- this.logger?.error?.(`Failed to record CHALLENGE_CREATED audit event: ${errorMessage}`, {
238
- error: auditError,
239
- userId: user.id,
240
- challengeName,
241
- });
242
- }
243
-
244
- return challengeSession as unknown as IChallengeSession;
245
- }
246
-
247
- // ============================================================================
248
- // Challenge Session Validation
249
- // ============================================================================
250
-
251
- /**
252
- * Validate a challenge session token for code requests
253
- *
254
- * Validates session for requesting new verification codes (SMS, email, etc.).
255
- * Skips max attempts check since requesting a new code is not a verification attempt.
256
- * This method is used internally by nauth when sending verification codes.
257
- *
258
- * @param sessionToken - Session token to validate
259
- * @param expectedChallenge - Expected challenge type (optional, for additional verification)
260
- * @returns Valid challenge session
261
- * @throws {UnauthorizedException} If session is invalid, expired, or already completed
262
- *
263
- * @example
264
- * ```typescript
265
- * // Used internally by nauth when sending verification codes
266
- * const session = await challengeService.validateSessionForCodeRequest(
267
- * 'session-token-123',
268
- * AuthChallenge.MFA_REQUIRED
269
- * );
270
- * ```
271
- */
272
- async validateSessionForCodeRequest(
273
- sessionToken: string,
274
- expectedChallenge?: AuthChallenge,
275
- ): Promise<IChallengeSession> {
276
- return this.validateSessionInternal(sessionToken, expectedChallenge, true);
277
- }
278
-
279
- /**
280
- * Validate a challenge session token
281
- *
282
- * Checks if the session token is valid, not expired, not completed,
283
- * and matches the expected challenge type. Does NOT consume the session.
284
- * Enforces max attempts check for verification attempts.
285
- *
286
- * @param sessionToken - Session token to validate
287
- * @param expectedChallenge - Expected challenge type (optional, for additional verification)
288
- * @returns Valid challenge session
289
- * @throws {UnauthorizedException} If session is invalid, expired, or already completed
290
- *
291
- * @example
292
- * ```typescript
293
- * try {
294
- * const session = await challengeService.validateSession(
295
- * 'session-token-123',
296
- * AuthChallenge.VERIFY_EMAIL
297
- * );
298
- * // Session is valid, proceed with verification
299
- * } catch (error) {
300
- * // Session is invalid
301
- * }
302
- * ```
303
- */
304
- async validateSession(sessionToken: string, expectedChallenge?: AuthChallenge): Promise<IChallengeSession> {
305
- return this.validateSessionInternal(sessionToken, expectedChallenge, false);
306
- }
307
-
308
- /**
309
- * Internal method to validate challenge session
310
- *
311
- * @param sessionToken - Session token to validate
312
- * @param expectedChallenge - Expected challenge type (optional)
313
- * @param skipMaxAttemptsCheck - If true, skip max attempts check (for code requests)
314
- * @returns Valid challenge session
315
- * @private
316
- */
317
- private async validateSessionInternal(
318
- sessionToken: string,
319
- expectedChallenge?: AuthChallenge,
320
- skipMaxAttemptsCheck = false,
321
- ): Promise<IChallengeSession> {
322
- // Load session with ALL user fields (needed for challenge determination and MFA setup)
323
- const session = await this.challengeSessionRepository.findOne({
324
- where: { sessionToken },
325
- relations: ['user'],
326
- });
327
-
328
- if (!session) {
329
- this.logger?.warn?.('Invalid challenge session token');
330
- throw new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'Invalid or expired challenge session');
331
- }
332
-
333
- // Check expiration
334
- const challengeSession = session as unknown as IChallengeSession;
335
- if (challengeSession.expiresAt < new Date()) {
336
- this.logger?.warn?.(`Expired challenge session: user=${challengeSession.user?.sub}`);
337
- throw new NAuthException(AuthErrorCode.CHALLENGE_EXPIRED, 'Challenge session has expired');
338
- }
339
-
340
- // Check if already completed
341
- if (session.isCompleted) {
342
- this.logger?.warn?.(`Already completed challenge session: user=${challengeSession.user?.sub}`);
343
- throw new NAuthException(AuthErrorCode.CHALLENGE_ALREADY_COMPLETED, 'Challenge has already been completed');
344
- }
345
-
346
- // Check max attempts (skip if requesting new code, but enforce for verification attempts)
347
- // Ensure attempts is initialized (should be 0, but handle edge cases)
348
- const currentAttempts = challengeSession.attempts ?? 0;
349
- if (!skipMaxAttemptsCheck && currentAttempts >= challengeSession.maxAttempts) {
350
- this.logger?.warn?.(
351
- `Max attempts exceeded for challenge session: user=${challengeSession.user?.sub}, attempts=${currentAttempts}/${challengeSession.maxAttempts}`,
352
- );
353
- throw new NAuthException(AuthErrorCode.CHALLENGE_MAX_ATTEMPTS, 'Maximum challenge attempts exceeded');
354
- }
355
-
356
- // Verify challenge type if specified
357
- if (expectedChallenge && session.challengeName !== expectedChallenge) {
358
- this.logger?.warn?.(`Challenge type mismatch: expected=${expectedChallenge}, actual=${session.challengeName}`);
359
- throw new NAuthException(AuthErrorCode.CHALLENGE_TYPE_MISMATCH, 'Invalid challenge type');
360
- }
361
-
362
- return session as unknown as IChallengeSession;
363
- }
364
-
365
- /**
366
- * Increment attempt counter for a challenge session
367
- *
368
- * Tracks failed attempts to complete a challenge.
369
- * Used to prevent brute-force attacks on verification codes.
370
- *
371
- * @param session - Challenge session to increment
372
- * @returns Updated session
373
- *
374
- * @example
375
- * ```typescript
376
- * await challengeService.incrementAttempts(session);
377
- * ```
378
- */
379
- async incrementAttempts(session: IChallengeSession): Promise<IChallengeSession> {
380
- // ============================================================================
381
- // CRITICAL: Atomic Increment to Prevent Race Conditions
382
- // ============================================================================
383
- // Use TypeORM's increment() for atomic UPDATE attempts = attempts + 1
384
- // This is concurrency-safe even under high load:
385
- // - Database executes: UPDATE challenge_session SET attempts = attempts + 1 WHERE id = ?
386
- // - No read-modify-write race condition
387
- // - Single database round-trip (better performance than SELECT + UPDATE)
388
- // - Works across all databases (MySQL, PostgreSQL, SQLite)
389
- await this.challengeSessionRepository.increment({ id: session.id }, 'attempts', 1);
390
-
391
- // Reload session to get updated attempts count and user for audit logging
392
- const freshSession = await this.challengeSessionRepository.findOne({
393
- where: { id: session.id },
394
- relations: ['user'],
395
- });
396
-
397
- if (!freshSession) {
398
- this.logger?.warn?.(`Session not found after increment: id=${session.id}`);
399
- throw new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'Challenge session not found');
400
- }
401
-
402
- const freshChallengeSession = freshSession as unknown as IChallengeSession;
403
-
404
- this.logger?.debug?.(
405
- `Challenge attempt incremented: session=${freshChallengeSession.sessionToken}, attempts=${freshChallengeSession.attempts}/${freshChallengeSession.maxAttempts}`,
406
- );
407
-
408
- // ============================================================================
409
- // Audit: Record challenge attempt failure if max attempts exceeded
410
- // ============================================================================
411
- if (freshChallengeSession.attempts >= freshChallengeSession.maxAttempts) {
412
- try {
413
- const user = freshChallengeSession.user;
414
- if (user) {
415
- await this.auditService?.recordEvent({
416
- userId: user.id,
417
- eventType: AuthAuditEventType.CHALLENGE_ATTEMPT_FAILED,
418
- eventStatus: 'FAILURE',
419
- challengeSessionId: freshChallengeSession.id,
420
- reason: 'max_attempts_exceeded',
421
- // Client info (ipAddress, userAgent, etc.) automatically included from context
422
- description: `Challenge attempt failed - maximum attempts (${freshChallengeSession.maxAttempts}) exceeded`,
423
- metadata: {
424
- challengeName: freshChallengeSession.challengeName,
425
- attempts: freshChallengeSession.attempts,
426
- maxAttempts: freshChallengeSession.maxAttempts,
427
- },
428
- });
429
- }
430
- } catch (auditError) {
431
- // Non-blocking: Log but continue
432
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
433
- const sessionUser = freshChallengeSession.user;
434
- this.logger?.error?.(`Failed to record CHALLENGE_ATTEMPT_FAILED audit event: ${errorMessage}`, {
435
- error: auditError,
436
- userId: sessionUser?.id,
437
- challengeName: freshChallengeSession.challengeName,
438
- });
439
- }
440
- }
441
-
442
- return freshChallengeSession;
443
- }
444
-
445
- /**
446
- * Validate and consume a challenge session
447
- *
448
- * Validates the session and marks it as completed if validation succeeds.
449
- * This method should be called only after successful challenge completion.
450
- *
451
- * @param sessionToken - Session token to validate and consume
452
- * @param expectedChallenge - Expected challenge type
453
- * @returns Valid, completed challenge session with user
454
- * @throws {UnauthorizedException} If session is invalid
455
- *
456
- * @example
457
- * ```typescript
458
- * const session = await challengeService.validateAndConsumeSession(
459
- * 'session-token-123',
460
- * AuthChallenge.VERIFY_EMAIL
461
- * );
462
- * // Session is now marked complete and cannot be reused
463
- * ```
464
- */
465
- async validateAndConsumeSession(sessionToken: string, expectedChallenge: AuthChallenge): Promise<IChallengeSession> {
466
- const session = await this.validateSession(sessionToken, expectedChallenge);
467
-
468
- // Mark session as completed
469
- session.isCompleted = true;
470
- session.completedAt = new Date();
471
- await this.challengeSessionRepository.save(session);
472
-
473
- const user = session.user;
474
- if (!user) {
475
- throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found in challenge session');
476
- }
477
- this.logger?.log?.(`Challenge session completed: user=${user.sub}, challenge=${session.challengeName}`);
478
-
479
- // ============================================================================
480
- // Audit: Record challenge completion
481
- // ============================================================================
482
- try {
483
- // Build metadata with challenge name and MFA method (if applicable)
484
- const auditMetadata: Record<string, unknown> = {
485
- // Client info automatically included from context
486
- challengeName: session.challengeName,
487
- };
488
-
489
- // For MFA challenges, include the MFA method used
490
- if (
491
- (session.challengeName === AuthChallenge.MFA_REQUIRED ||
492
- session.challengeName === AuthChallenge.MFA_SETUP_REQUIRED) &&
493
- session.metadata?.mfaMethod
494
- ) {
495
- auditMetadata.mfaMethod = session.metadata.mfaMethod;
496
- }
497
-
498
- await this.auditService?.recordEvent({
499
- userId: user.id,
500
- eventType: AuthAuditEventType.CHALLENGE_COMPLETED,
501
- eventStatus: 'SUCCESS',
502
- challengeSessionId: session.id,
503
- // Client info (ipAddress, userAgent, etc.) automatically included from context
504
- metadata: auditMetadata,
505
- });
506
- } catch (auditError) {
507
- // Non-blocking: Log but continue
508
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
509
- this.logger?.error?.(`Failed to record CHALLENGE_COMPLETED audit event: ${errorMessage}`, {
510
- error: auditError,
511
- userId: user.id,
512
- challengeName: session.challengeName,
513
- });
514
- }
515
-
516
- return session;
517
- }
518
-
519
- /**
520
- * Update challenge session metadata
521
- *
522
- * Updates the metadata field of an existing challenge session.
523
- * Used to store additional challenge-specific data (e.g., passkey challenge).
524
- *
525
- * @param sessionToken - Session token to update
526
- * @param metadata - Metadata to merge into existing metadata
527
- * @returns Updated challenge session
528
- * @throws {NAuthException} If session not found or invalid
529
- *
530
- * @example
531
- * ```typescript
532
- * await challengeService.updateMetadata('session-token-123', {
533
- * passkeyChallenge: 'base64-challenge-string'
534
- * });
535
- * ```
536
- */
537
- async updateMetadata(sessionToken: string, metadata: Record<string, unknown>): Promise<IChallengeSession> {
538
- const session = await this.validateSession(sessionToken);
539
-
540
- // Merge new metadata with existing metadata
541
- const existingMetadata = session.metadata || {};
542
- const updatedMetadata = { ...existingMetadata, ...metadata };
543
-
544
- // Update session metadata
545
- await this.challengeSessionRepository.update({ sessionToken }, { metadata: updatedMetadata } as Record<
546
- string,
547
- unknown
548
- >);
549
-
550
- // Return updated session
551
- const updatedSession = await this.challengeSessionRepository.findOne({
552
- where: { sessionToken },
553
- relations: ['user'],
554
- });
555
-
556
- if (!updatedSession) {
557
- throw new NAuthException(AuthErrorCode.CHALLENGE_INVALID, 'Failed to update challenge session metadata');
558
- }
559
-
560
- return updatedSession as unknown as IChallengeSession;
561
- }
562
-
563
- // ============================================================================
564
- // Challenge Session Cleanup
565
- // ============================================================================
566
-
567
- /**
568
- * Clean up expired or completed challenge sessions for a user
569
- *
570
- * Removes old sessions to prevent database bloat.
571
- * Called automatically when creating new challenge sessions.
572
- *
573
- * @param userId - User ID to clean up sessions for
574
- *
575
- * @example
576
- * ```typescript
577
- * await challengeService.cleanupExpiredSessions(user.id);
578
- * ```
579
- */
580
- async cleanupExpiredSessions(userId: number): Promise<void> {
581
- await this.challengeSessionRepository.delete({
582
- userId,
583
- expiresAt: LessThan(new Date()),
584
- });
585
-
586
- await this.challengeSessionRepository.delete({
587
- userId,
588
- isCompleted: true,
589
- });
590
- }
591
-
592
- /**
593
- * Clean up all expired challenge sessions (for all users)
594
- *
595
- * Should be called periodically (e.g., via cron job) to maintain
596
- * database health.
597
- *
598
- * @returns Number of sessions deleted
599
- *
600
- * @example
601
- * ```typescript
602
- * // In a scheduled job
603
- * const deleted = await challengeService.cleanupAllExpiredSessions();
604
- * logger.log(`Cleaned up ${deleted} expired challenge sessions`);
605
- * ```
606
- */
607
- async cleanupAllExpiredSessions(): Promise<number> {
608
- const result = await this.challengeSessionRepository.delete({
609
- expiresAt: LessThan(new Date()),
610
- });
611
-
612
- const deletedCount = result.affected || 0;
613
- this.logger?.log?.(`Cleaned up ${deletedCount} expired challenge sessions`);
614
-
615
- return deletedCount;
616
- }
617
-
618
- /**
619
- * Delete challenge sessions by challenge name for a user
620
- *
621
- * Removes all active (not completed, not expired) challenge sessions
622
- * of the specified type for a user. Used to clean up phantom challenges
623
- * when user completes the requirement (e.g., sets up MFA).
624
- *
625
- * @param userId - Internal user ID
626
- * @param challengeName - Challenge type to delete
627
- * @returns Number of sessions deleted
628
- *
629
- * @example
630
- * ```typescript
631
- * // Clear MFA_SETUP_REQUIRED challenge when user sets up MFA
632
- * const deleted = await challengeService.deleteUserChallengeSessions(
633
- * user.id,
634
- * AuthChallenge.MFA_SETUP_REQUIRED
635
- * );
636
- * ```
637
- */
638
- async deleteUserChallengeSessions(userId: number, challengeName: AuthChallenge): Promise<number> {
639
- const result = await this.challengeSessionRepository.delete({
640
- userId,
641
- challengeName,
642
- isCompleted: false,
643
- });
644
-
645
- const deletedCount = result.affected || 0;
646
- if (deletedCount > 0) {
647
- this.logger?.log?.(`Deleted ${deletedCount} ${challengeName} challenge session(s) for user ID ${userId}`);
648
- }
649
-
650
- return deletedCount;
651
- }
652
-
653
- // ============================================================================
654
- // Helper Methods
655
- // ============================================================================
656
-
657
- /**
658
- * Mask email address for display in challenge parameters
659
- *
660
- * Shows first character and domain, hides the rest.
661
- *
662
- * @param email - Email to mask
663
- * @returns Masked email
664
- *
665
- * @example
666
- * ```typescript
667
- * maskEmail('john.doe@example.com')
668
- * // Returns: 'j***@example.com'
669
- * ```
670
- */
671
- maskEmail(email: string): string {
672
- const [localPart, domain] = email.split('@');
673
- if (!domain) return email;
674
- return `${localPart[0]}***@${domain}`;
675
- }
676
-
677
- /**
678
- * Mask phone number for display in challenge parameters
679
- *
680
- * Shows last 4 digits, hides the rest.
681
- *
682
- * @param phone - Phone to mask
683
- * @returns Masked phone
684
- *
685
- * @example
686
- * ```typescript
687
- * maskPhone('+1234567890')
688
- * // Returns: '***-***-7890'
689
- * ```
690
- */
691
- maskPhone(phone: string): string {
692
- const digits = phone.replace(/\D/g, '');
693
- if (digits.length < 4) return phone;
694
- return `***-***-${digits.slice(-4)}`;
695
- }
696
- }