@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,751 +0,0 @@
1
- import { Repository, IsNull } from 'typeorm';
2
- import { IUser, IVerificationToken } from '../interfaces/entities.interface';
3
- import { BaseVerificationToken, BaseUser } from '../entities';
4
- import { SMSProvider } from '../interfaces/provider.interface';
5
- import { StorageAdapter } from '../interfaces/storage-adapter.interface';
6
- import { NAuthConfig } from '../interfaces/config.interface';
7
- import { ClientInfoService } from './client-info.service';
8
- import { InternalAuthAuditService as AuthAuditService } from './auth-audit.service';
9
- import { AuthAuditEventType } from '../enums/auth-audit-event-type.enum';
10
- import { NAuthException } from '../exceptions/nauth.exception';
11
- import { AuthErrorCode } from '../enums/error-codes.enum';
12
- import { NAuthLogger } from '../utils/nauth-logger';
13
- import {
14
- SendVerificationSMSDTO,
15
- SendVerificationSMSResponseDTO,
16
- VerifyPhoneWithCodeDTO,
17
- VerifyPhoneResponseDTO,
18
- ResendVerificationSMSDTO,
19
- ResendVerificationSMSResponseDTO,
20
- } from '../dto/verify-phone.dto';
21
- import { VerifyPhoneWithCodeBySubDTO } from '../dto/verify-phone-by-sub.dto';
22
- import * as crypto from 'crypto';
23
-
24
- /**
25
- * Phone Verification Service (Core)
26
- *
27
- * Database-agnostic phone verification workflow with provider-driven SMS delivery.
28
- *
29
- * WHY: Keeps core business logic independent of database and SMS vendors. Databases are
30
- * injected via repository tokens and SMS via an `SMSProvider` adapter so consumers
31
- * can plug in Postgres, MySQL, or any SMS provider without code changes.
32
- *
33
- * @example
34
- * ```typescript
35
- * // Send OTP
36
- * const tokenId = await phoneVerificationService.sendVerificationSMS('user-sub');
37
- *
38
- * // Verify by sub
39
- * await phoneVerificationService.verifyPhoneWithCodeBySub('user-sub', '123456');
40
- *
41
- * // Resend
42
- * await phoneVerificationService.resendVerificationSMS('user-sub');
43
- * ```
44
- */
45
- export class PhoneVerificationService {
46
- constructor(
47
- private readonly verificationTokenRepo: Repository<BaseVerificationToken>,
48
- private readonly userRepo: Repository<BaseUser>,
49
- private readonly smsProvider: SMSProvider,
50
- private readonly storageAdapter: StorageAdapter,
51
- private readonly config: NAuthConfig,
52
- private readonly clientInfoService: ClientInfoService,
53
- private readonly logger: NAuthLogger,
54
- private readonly auditService?: AuthAuditService, // Optional - audit trail service (enabled via config.auditLogs.enabled)
55
- ) {}
56
-
57
- /**
58
- * Send verification SMS to user identified by `sub`.
59
- * Applies rate limits and resend delay, stores hashed token + OTP, and sends via SMS provider.
60
- *
61
- * @param dto - Request DTO containing sub and skipAlreadyVerifiedCheck
62
- * @returns Response DTO with verification token ID
63
- * @throws {NAuthException} RATE_LIMIT_SMS | NOT_FOUND | PHONE_REQUIRED | ALREADY_VERIFIED | RATE_LIMIT_RESEND
64
- */
65
- async sendVerificationSMS(dto: SendVerificationSMSDTO): Promise<SendVerificationSMSResponseDTO> {
66
- const { sub, skipAlreadyVerifiedCheck = true, challengeSessionId } = dto;
67
- const rateLimitKey = `phone-verification:${sub}`;
68
-
69
- // Get rate limit configuration from config (moved to signup.phoneVerification)
70
- const rateLimitMax = this.config.signup?.phoneVerification?.rateLimitMax || 3;
71
- const rateLimitWindow = this.config.signup?.phoneVerification?.rateLimitWindow || 3600; // 1 hour in seconds
72
-
73
- // Check if key exists and has valid TTL (not expired)
74
- const ttlBefore = await this.storageAdapter.ttl(rateLimitKey);
75
- // Window is expired if: key doesn't exist (-1), expired (<0), or TTL is longer than configured window (config changed)
76
- const isWindowExpired = ttlBefore === -1 || ttlBefore < 0 || ttlBefore > rateLimitWindow;
77
-
78
- // If TTL is longer than configured window (config changed), delete the old key to reset it
79
- // This ensures the new window uses the current rateLimitWindow instead of preserving old expiry
80
- if (ttlBefore > rateLimitWindow) {
81
- await this.storageAdapter.del(rateLimitKey);
82
- }
83
-
84
- // Increment counter (will reset to 1 if key expired or doesn't exist)
85
- // Pass TTL so new records are created with correct expiry immediately
86
- const currentCount = await this.storageAdapter.incr(rateLimitKey, isWindowExpired ? rateLimitWindow : undefined);
87
-
88
- // If we created a new window, log it
89
- if (isWindowExpired && currentCount === 1) {
90
- this.logger?.debug?.(
91
- `Rate limit window reset for phone verification: sub=${sub}, window=${rateLimitWindow}s, max=${rateLimitMax}`,
92
- );
93
- }
94
-
95
- // Get actual TTL after setting expiry (for error message)
96
- const actualTtl = await this.storageAdapter.ttl(rateLimitKey);
97
-
98
- this.logger?.debug?.(
99
- `Phone verification rate limit check: sub=${sub}, count=${currentCount}/${rateLimitMax}, ttl=${actualTtl}s`,
100
- );
101
-
102
- if (currentCount > rateLimitMax) {
103
- this.logger?.warn?.(
104
- `SMS rate limit exceeded: sub=${sub}, count=${currentCount}, max=${rateLimitMax}, retryAfter=${actualTtl}s`,
105
- );
106
- throw new NAuthException(
107
- AuthErrorCode.RATE_LIMIT_SMS,
108
- `Too many verification SMS sent. Please try again in ${actualTtl > 0 ? actualTtl : rateLimitWindow} seconds`,
109
- {
110
- retryAfter: actualTtl > 0 ? actualTtl : rateLimitWindow,
111
- currentCount,
112
- maxAttempts: rateLimitMax,
113
- },
114
- );
115
- }
116
-
117
- // Load user by external identifier
118
- const user = (await this.userRepo.findOne({ where: { sub } })) as IUser | null;
119
- if (!user) {
120
- throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
121
- }
122
- if (!user.phone) {
123
- throw new NAuthException(AuthErrorCode.PHONE_REQUIRED, 'No phone number associated with this account');
124
- }
125
- // Only check "already verified" if not skipping (skip for MFA contexts where codes are needed even if phone is verified)
126
- if (!skipAlreadyVerifiedCheck && user.isPhoneVerified) {
127
- throw new NAuthException(AuthErrorCode.ALREADY_VERIFIED, 'Phone already verified');
128
- }
129
-
130
- // Enforce resend delay to prevent abuse
131
- const resendDelay = this.config.signup?.phoneVerification?.resendDelay ?? 60;
132
- const lastToken = (await this.verificationTokenRepo.findOne({
133
- where: { userId: user.id, type: 'phone' },
134
- order: { createdAt: 'DESC' },
135
- })) as IVerificationToken | null;
136
- if (lastToken) {
137
- const secondsSinceLastSend = (Date.now() - lastToken.createdAt.getTime()) / 1000;
138
- if (secondsSinceLastSend < resendDelay) {
139
- const waitSeconds = Math.ceil(resendDelay - secondsSinceLastSend);
140
- throw new NAuthException(
141
- AuthErrorCode.RATE_LIMIT_RESEND,
142
- `Please wait ${waitSeconds} seconds before requesting another code`,
143
- {
144
- retryAfter: waitSeconds,
145
- resendDelay,
146
- },
147
- );
148
- }
149
- }
150
-
151
- // Invalidate existing unused tokens for this user
152
- await this.verificationTokenRepo.update(
153
- { userId: user.id, type: 'phone', usedAt: IsNull() },
154
- { usedAt: new Date() },
155
- );
156
-
157
- // Generate OTP and hashed token
158
- const code = this.generateCode();
159
- const token = this.generateToken();
160
- const tokenHash = this.hashToken(token);
161
-
162
- // Capture client info for auditability
163
- const clientInfo = this.clientInfoService.get();
164
- const { ipAddress, userAgent } = clientInfo;
165
-
166
- const verificationToken = this.verificationTokenRepo.create({
167
- userId: user.id,
168
- challengeSessionId: challengeSessionId ?? null, // Link to challenge session if provided
169
- type: 'phone',
170
- token: tokenHash,
171
- code,
172
- expiresAt: new Date(Date.now() + (this.config.signup?.phoneVerification?.expiresIn || 300) * 1000),
173
- attempts: 0,
174
- ipAddress,
175
- userAgent,
176
- });
177
-
178
- const saved = (await this.verificationTokenRepo.save(verificationToken)) as unknown as IVerificationToken;
179
-
180
- this.logger?.log?.(
181
- `SMS token created: sub=${sub}, tokenId=${saved.id}, code=${code}, codeType=${typeof code}, userId=${user.id}, usedAt=${saved.usedAt || 'null'}`,
182
- );
183
-
184
- await this.smsProvider.sendOTP(user.phone, code);
185
- this.logger?.log?.(
186
- `SMS verification code sent: sub=${sub}, tokenId=${saved.id}, phone=${this.maskPhone(user.phone)}`,
187
- );
188
-
189
- // ============================================================================
190
- // Audit: Record phone verification request
191
- // ============================================================================
192
- try {
193
- await this.auditService?.recordEvent({
194
- userId: user.id,
195
- eventType: AuthAuditEventType.PHONE_VERIFICATION_REQUESTED,
196
- eventStatus: 'INFO',
197
- metadata: {
198
- // Client info automatically included from context
199
- verificationTokenId: saved.id,
200
- phone: this.maskPhone(user.phone),
201
- },
202
- });
203
- } catch (auditError) {
204
- // Non-blocking: Log but continue
205
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
206
- this.logger?.error?.(`Failed to record PHONE_VERIFICATION_REQUESTED audit event: ${errorMessage}`, {
207
- error: auditError,
208
- userId: user.id,
209
- });
210
- }
211
-
212
- return { tokenId: saved.id };
213
- }
214
-
215
- /**
216
- * Verify phone by phone number and code.
217
- * Handles duplicate phone numbers by selecting the token whose user matches the phone provided.
218
- *
219
- * @param dto - Request DTO containing phone and code
220
- * @returns Response DTO with success message
221
- * @throws {NAuthException} VERIFICATION_CODE_INVALID | VERIFICATION_CODE_EXPIRED | VERIFICATION_TOO_MANY_ATTEMPTS
222
- */
223
- async verifyPhoneWithCode(dto: VerifyPhoneWithCodeDTO): Promise<VerifyPhoneResponseDTO> {
224
- const { phone, code, challengeSessionId } = dto;
225
- // Find all unused tokens matching the code and type
226
- // If challengeSessionId is provided, ensure token belongs to specific session
227
- const whereClause = {
228
- type: 'phone' as const,
229
- code,
230
- usedAt: IsNull(),
231
- ...(challengeSessionId !== undefined && { challengeSessionId }), // Include if provided
232
- };
233
-
234
- const candidateTokens = (await this.verificationTokenRepo.find({
235
- where: whereClause,
236
- order: { createdAt: 'DESC' },
237
- })) as unknown as IVerificationToken[];
238
-
239
- // Resolve the token whose user has the given phone
240
- let matched: { token: IVerificationToken; user: IUser } | null = null;
241
- for (const token of candidateTokens) {
242
- const user = (await this.userRepo.findOne({ where: { id: token.userId } })) as IUser | null;
243
- if (user && user.phone === phone) {
244
- matched = { token, user };
245
- break;
246
- }
247
- }
248
-
249
- if (!matched) {
250
- throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid or expired verification code');
251
- }
252
-
253
- const { token, user } = matched;
254
-
255
- // Get verification attempt rate limit configuration from config
256
- const maxAttemptsPerUser = this.config.signup?.phoneVerification?.maxAttemptsPerUser ?? 10;
257
- const maxAttemptsPerIP = this.config.signup?.phoneVerification?.maxAttemptsPerIP ?? 20;
258
- const attemptWindow = this.config.signup?.phoneVerification?.attemptWindow ?? 3600; // 1 hour
259
-
260
- // Rate limit verification attempts per user (not just per token)
261
- const userRateLimitKey = `verify-attempts:user:${user.id}`;
262
- const userAttempts = await this.storageAdapter.incr(userRateLimitKey);
263
-
264
- if (userAttempts === 1) {
265
- await this.storageAdapter.expire(userRateLimitKey, attemptWindow);
266
- }
267
-
268
- if (userAttempts > maxAttemptsPerUser) {
269
- throw new NAuthException(
270
- AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS,
271
- 'Too many verification attempts. Please try again later.',
272
- );
273
- }
274
-
275
- // Also rate limit by IP
276
- const clientInfo = this.clientInfoService.get();
277
- if (clientInfo.ipAddress) {
278
- const ipRateLimitKey = `verify-attempts:ip:${clientInfo.ipAddress}`;
279
- const ipAttempts = await this.storageAdapter.incr(ipRateLimitKey);
280
-
281
- if (ipAttempts === 1) {
282
- await this.storageAdapter.expire(ipRateLimitKey, attemptWindow);
283
- }
284
-
285
- if (ipAttempts > maxAttemptsPerIP) {
286
- throw new NAuthException(
287
- AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS,
288
- 'Too many verification attempts from this IP. Please try again later.',
289
- );
290
- }
291
- }
292
-
293
- // Expiry check (use entity method if provided)
294
- const isExpired =
295
- typeof token.isExpired === 'function' ? !!token.isExpired() : token.expiresAt.getTime() <= Date.now();
296
- if (isExpired) {
297
- throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_EXPIRED, 'Verification code has expired');
298
- }
299
-
300
- // Attempts check (use entity method if provided)
301
- const maxAttempts = this.config.signup?.phoneVerification?.maxAttempts ?? 3;
302
- const tooManyAttempts =
303
- typeof token.maxAttemptsExceeded === 'function'
304
- ? !!token.maxAttemptsExceeded(maxAttempts)
305
- : token.attempts >= maxAttempts;
306
- if (tooManyAttempts) {
307
- throw new NAuthException(
308
- AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS,
309
- 'Too many failed attempts. Request a new code.',
310
- {
311
- maxAttempts,
312
- currentAttempts: token.attempts,
313
- },
314
- );
315
- }
316
-
317
- // Increment attempts even on success to prevent reuse by race
318
- token.attempts += 1;
319
- if (token.code !== code) {
320
- await this.verificationTokenRepo.save(token);
321
- this.logger?.debug?.(
322
- `Phone verification failed: phone=${this.maskPhone(phone)}, attempts=${token.attempts}/${maxAttempts}`,
323
- );
324
-
325
- // ============================================================================
326
- // Audit: Record phone verification failure
327
- // ============================================================================
328
- try {
329
- await this.auditService?.recordEvent({
330
- userId: user.id,
331
- eventType: AuthAuditEventType.PHONE_VERIFICATION_FAILED,
332
- eventStatus: 'FAILURE',
333
- reason: 'invalid_code',
334
- // Client info automatically included from context
335
- description: 'Invalid verification code provided',
336
- metadata: {
337
- verificationTokenId: token.id,
338
- attempts: token.attempts,
339
- phone: this.maskPhone(phone),
340
- },
341
- });
342
- } catch (auditError) {
343
- // Non-blocking: Log but continue
344
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
345
- this.logger?.error?.(`Failed to record PHONE_VERIFICATION_FAILED audit event: ${errorMessage}`, {
346
- error: auditError,
347
- userId: user.id,
348
- });
349
- }
350
-
351
- throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code', {
352
- attemptsRemaining: Math.max(0, maxAttempts - token.attempts),
353
- });
354
- }
355
-
356
- // Mark token used
357
- token.usedAt = new Date();
358
- await this.verificationTokenRepo.save(token);
359
-
360
- // Update user flags
361
- await this.userRepo.update(user.id, {
362
- isPhoneVerified: true,
363
- isActive: true,
364
- });
365
-
366
- this.logger?.log?.(`Phone verification successful: userId=${user.id}, phone=${this.maskPhone(phone)}`);
367
-
368
- // ============================================================================
369
- // Audit: Record phone verification success
370
- // ============================================================================
371
- try {
372
- // Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
373
- // Note: ClientInfoService is used transparently by AuditService
374
- await this.auditService?.recordEvent({
375
- userId: user.id,
376
- eventType: AuthAuditEventType.PHONE_VERIFIED,
377
- eventStatus: 'SUCCESS',
378
- metadata: {
379
- // Client info automatically included from context
380
- verificationTokenId: token.id,
381
- verificationMethod: 'code',
382
- phone: this.maskPhone(phone),
383
- },
384
- });
385
- } catch (auditError) {
386
- // Non-blocking: Log but continue
387
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
388
- this.logger?.error?.(`Failed to record PHONE_VERIFIED audit event: ${errorMessage}`, {
389
- error: auditError,
390
- userId: user.id,
391
- });
392
- }
393
-
394
- return { message: 'Phone verified successfully. Please log in to continue.' };
395
- }
396
-
397
- /**
398
- * Verify phone by user sub and code.
399
- *
400
- * @param dto - Request DTO containing sub and code
401
- * @returns Response DTO with success message
402
- */
403
- async verifyPhoneWithCodeBySub(dto: VerifyPhoneWithCodeBySubDTO): Promise<VerifyPhoneResponseDTO> {
404
- const { sub, code, challengeSessionId } = dto;
405
- // Load user to get current phone verification status
406
- // This ensures we have the latest state from the database
407
- const user = (await this.userRepo.findOne({ where: { sub } })) as IUser | null;
408
- if (!user) {
409
- throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
410
- }
411
- if (!user.phone) {
412
- throw new NAuthException(AuthErrorCode.PHONE_REQUIRED, 'No phone number associated with this account');
413
- }
414
-
415
- // Store initial verification status to avoid unnecessary updates
416
- const wasPhoneVerified = Boolean(user.isPhoneVerified);
417
-
418
- // ============================================================================
419
- // Security: Rate limit configuration
420
- // IP-based rate limiting applies only to INVALID attempts to prevent brute force
421
- // Valid codes should not be blocked by IP rate limits
422
- // ============================================================================
423
- const maxAttemptsPerIP = this.config.signup?.phoneVerification?.maxAttemptsPerIP ?? 20;
424
- const attemptWindow = this.config.signup?.phoneVerification?.attemptWindow ?? 3600; // 1 hour
425
- const clientInfo = this.clientInfoService.get();
426
-
427
- // Find verification token
428
- // Query for unused tokens matching the code (order by newest first)
429
- // Ensure code is a string (database stores as varchar/string)
430
- // TypeORM may receive code as number from JSON, so convert to string for query
431
- const codeString = String(code);
432
- this.logger?.log?.(
433
- `Looking for verification token: sub=${sub}, code=${codeString}, codeType=${typeof code}, userId=${user.id}`,
434
- );
435
- // If challengeSessionId is provided, ensure token belongs to specific session
436
- const whereClause = {
437
- userId: user.id,
438
- type: 'phone' as const,
439
- code: codeString,
440
- usedAt: IsNull(),
441
- ...(challengeSessionId !== undefined && { challengeSessionId }), // Include if provided
442
- };
443
-
444
- const verificationToken = (await this.verificationTokenRepo.findOne({
445
- where: whereClause,
446
- order: { createdAt: 'DESC' },
447
- })) as IVerificationToken | null;
448
-
449
- // ============================================================================
450
- // Security: Increment IP rate limit for invalid attempts only
451
- // This prevents brute force attacks while allowing valid codes through
452
- // ============================================================================
453
- const incrementIPRateLimit = async (): Promise<void> => {
454
- if (clientInfo.ipAddress) {
455
- const ipRateLimitKey = `verify-attempts:ip:${clientInfo.ipAddress}`;
456
- const ipAttempts = await this.storageAdapter.incr(ipRateLimitKey);
457
-
458
- if (ipAttempts === 1) {
459
- await this.storageAdapter.expire(ipRateLimitKey, attemptWindow);
460
- }
461
-
462
- if (ipAttempts > maxAttemptsPerIP) {
463
- throw new NAuthException(
464
- AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS,
465
- 'Too many verification attempts from this IP. Please try again later.',
466
- );
467
- }
468
- }
469
- };
470
-
471
- if (!verificationToken) {
472
- this.logger?.warn?.(
473
- `Phone verification token not found: sub=${sub}, code=${codeString}, originalCodeType=${typeof code}, userId=${user.id}`,
474
- );
475
- // Invalid attempt - increment IP rate limit
476
- await incrementIPRateLimit();
477
- throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid or expired verification code');
478
- }
479
-
480
- const isExpired =
481
- typeof verificationToken.isExpired === 'function'
482
- ? !!verificationToken.isExpired()
483
- : verificationToken.expiresAt.getTime() <= Date.now();
484
- if (isExpired) {
485
- // Expired token - increment IP rate limit
486
- await incrementIPRateLimit();
487
- throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_EXPIRED, 'Verification code has expired');
488
- }
489
-
490
- const maxAttempts = this.config.signup?.phoneVerification?.maxAttempts ?? 3;
491
- const tooManyAttempts =
492
- typeof verificationToken.maxAttemptsExceeded === 'function'
493
- ? !!verificationToken.maxAttemptsExceeded(maxAttempts)
494
- : verificationToken.attempts >= maxAttempts;
495
- if (tooManyAttempts) {
496
- // Token exceeded max attempts - increment IP rate limit
497
- await incrementIPRateLimit();
498
- throw new NAuthException(
499
- AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS,
500
- 'Too many failed attempts. Request a new code.',
501
- {
502
- maxAttempts,
503
- currentAttempts: verificationToken.attempts,
504
- },
505
- );
506
- }
507
-
508
- // ============================================================================
509
- // Security: Rate limit verification attempts per user (for invalid attempts only)
510
- // This prevents users from exhausting their own tokens through repeated attempts
511
- // Valid codes should not be blocked by user rate limits
512
- // ============================================================================
513
- const maxAttemptsPerUser = this.config.signup?.phoneVerification?.maxAttemptsPerUser ?? 10;
514
-
515
- // Helper to increment user rate limit (only for invalid attempts)
516
- const incrementUserRateLimit = async (): Promise<void> => {
517
- const userRateLimitKey = `verify-attempts:user:${user.id}`;
518
- const userAttempts = await this.storageAdapter.incr(userRateLimitKey);
519
-
520
- if (userAttempts === 1) {
521
- await this.storageAdapter.expire(userRateLimitKey, attemptWindow);
522
- }
523
-
524
- if (userAttempts > maxAttemptsPerUser) {
525
- throw new NAuthException(
526
- AuthErrorCode.VERIFICATION_TOO_MANY_ATTEMPTS,
527
- 'Too many verification attempts. Please try again later.',
528
- );
529
- }
530
- };
531
-
532
- verificationToken.attempts += 1;
533
- // Compare normalized codes (trim whitespace for comparison)
534
- const storedCode = String(verificationToken.code).trim();
535
- const providedCode = String(code).trim();
536
- if (storedCode !== providedCode) {
537
- // Invalid code - increment both IP and user rate limits
538
- await incrementIPRateLimit();
539
- await incrementUserRateLimit();
540
-
541
- await this.verificationTokenRepo.save(verificationToken);
542
- this.logger?.debug?.(
543
- `Phone verification failed: sub=${sub}, attempts=${verificationToken.attempts}/${maxAttempts}`,
544
- );
545
-
546
- // ============================================================================
547
- // Audit: Record phone verification failure (by sub)
548
- // ============================================================================
549
- try {
550
- await this.auditService?.recordEvent({
551
- userId: user.id,
552
- eventType: AuthAuditEventType.PHONE_VERIFICATION_FAILED,
553
- eventStatus: 'FAILURE',
554
- reason: 'invalid_code',
555
- // Client info automatically included from context
556
- description: 'Invalid verification code provided',
557
- metadata: {
558
- verificationTokenId: verificationToken.id,
559
- attempts: verificationToken.attempts,
560
- phone: this.maskPhone(user.phone || ''),
561
- },
562
- });
563
- } catch (auditError) {
564
- // Non-blocking: Log but continue
565
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
566
- this.logger?.error?.(`Failed to record PHONE_VERIFICATION_FAILED audit event (by sub): ${errorMessage}`, {
567
- error: auditError,
568
- userId: user.id,
569
- });
570
- }
571
-
572
- throw new NAuthException(AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code', {
573
- attemptsRemaining: Math.max(0, maxAttempts - verificationToken.attempts),
574
- });
575
- }
576
-
577
- // ============================================================================
578
- // Code is valid - proceed without rate limit checks
579
- // Valid codes should always be allowed through
580
- // ============================================================================
581
-
582
- verificationToken.usedAt = new Date();
583
- await this.verificationTokenRepo.save(verificationToken);
584
-
585
- // ============================================================================
586
- // Only update user if phone is not already verified to avoid unnecessary DB write
587
- // This prevents updating updatedAt timestamp when phone is already verified
588
- // We use the verification status from when user was loaded at the start
589
- // ============================================================================
590
- if (!wasPhoneVerified) {
591
- // Use update() with explicit WHERE clause to ensure the change is persisted
592
- // This bypasses entity tracking issues and ensures the update is committed
593
- await this.userRepo.update(
594
- { sub },
595
- {
596
- isPhoneVerified: true,
597
- isActive: true,
598
- },
599
- );
600
- this.logger?.log?.(`Phone verification successful: sub=${sub}, userId=${user.id} - phone marked as verified`);
601
- } else {
602
- // Phone already verified - just mark token as used, no user update needed
603
- this.logger?.log?.(`Phone verification code validated: sub=${sub}, userId=${user.id} - phone already verified`);
604
- }
605
-
606
- // ============================================================================
607
- // Audit: Record phone verification success (by sub)
608
- // ============================================================================
609
- try {
610
- // Client info (ipAddress, userAgent) automatically extracted from ClientInfoService
611
- // Note: ClientInfoService is used transparently by AuditService
612
- await this.auditService?.recordEvent({
613
- userId: user.id,
614
- eventType: AuthAuditEventType.PHONE_VERIFIED,
615
- eventStatus: 'SUCCESS',
616
- metadata: {
617
- // Client info automatically included from context
618
- verificationTokenId: verificationToken.id,
619
- verificationMethod: 'code',
620
- phone: this.maskPhone(user.phone || ''),
621
- },
622
- });
623
- } catch (auditError) {
624
- // Non-blocking: Log but continue
625
- const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
626
- this.logger?.error?.(`Failed to record PHONE_VERIFIED audit event (by sub): ${errorMessage}`, {
627
- error: auditError,
628
- userId: user.id,
629
- });
630
- }
631
-
632
- return { message: 'Phone verified successfully. Please log in to continue.' };
633
- }
634
-
635
- /**
636
- * Resend verification SMS
637
- * Supports both sub and phone-based resend
638
- *
639
- * @param dto - Request DTO containing sub or phone
640
- * @returns Response DTO with verification token ID
641
- */
642
- async resendVerificationSMS(dto: ResendVerificationSMSDTO): Promise<ResendVerificationSMSResponseDTO> {
643
- // Validate that either sub or phone is provided
644
- if (!dto.sub && !dto.phone) {
645
- throw new NAuthException(AuthErrorCode.VALIDATION_FAILED, 'Either sub or phone must be provided');
646
- }
647
-
648
- if (dto.sub) {
649
- return this.resendVerificationSMSBySub(dto.sub);
650
- }
651
-
652
- return this.resendVerificationSMSForPhone(dto.phone!);
653
- }
654
-
655
- /**
656
- * Resend verification SMS by user sub (private helper)
657
- *
658
- * @param sub - External user identifier
659
- * @returns New verification token id
660
- */
661
- private async resendVerificationSMSBySub(sub: string): Promise<ResendVerificationSMSResponseDTO> {
662
- const user = (await this.userRepo.findOne({ where: { sub } })) as IUser | null;
663
- if (!user) {
664
- throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
665
- }
666
-
667
- const resendDelay = this.config.signup?.phoneVerification?.resendDelay ?? 60;
668
- const lastToken = (await this.verificationTokenRepo.findOne({
669
- where: { userId: user.id, type: 'phone' },
670
- order: { createdAt: 'DESC' },
671
- })) as IVerificationToken | null;
672
- if (lastToken) {
673
- const secondsSinceLastSend = (Date.now() - lastToken.createdAt.getTime()) / 1000;
674
- if (secondsSinceLastSend < resendDelay) {
675
- const waitSeconds = Math.ceil(resendDelay - secondsSinceLastSend);
676
- this.logger?.debug?.(`Resend rate limit: sub=${sub}, wait=${waitSeconds}s, delay=${resendDelay}s`);
677
- throw new NAuthException(
678
- AuthErrorCode.RATE_LIMIT_RESEND,
679
- `Please wait ${waitSeconds} seconds before requesting another code`,
680
- {
681
- retryAfter: waitSeconds,
682
- resendDelay,
683
- },
684
- );
685
- }
686
- }
687
-
688
- this.logger?.debug?.(`Resending SMS verification code: sub=${sub}`);
689
- // Preserve challengeSessionId from the last token to ensure verification succeeds
690
- const sendDto = Object.assign(new SendVerificationSMSDTO(), {
691
- sub,
692
- challengeSessionId: lastToken?.challengeSessionId ?? undefined,
693
- });
694
- const result = await this.sendVerificationSMS(sendDto);
695
- return { tokenId: result.tokenId };
696
- }
697
-
698
- /**
699
- * Resend verification SMS by phone number (private helper)
700
- *
701
- * @param phone - Phone number
702
- * @returns New verification token id
703
- */
704
- private async resendVerificationSMSForPhone(phone: string): Promise<ResendVerificationSMSResponseDTO> {
705
- const user = (await this.userRepo.findOne({ where: { phone } })) as IUser | null;
706
- if (!user) {
707
- throw new NAuthException(AuthErrorCode.NOT_FOUND, 'User not found');
708
- }
709
- if (user.isPhoneVerified) {
710
- throw new NAuthException(AuthErrorCode.ALREADY_VERIFIED, 'Phone number is already verified');
711
- }
712
- return this.resendVerificationSMSBySub(user.sub);
713
- }
714
-
715
- // ============================================================================
716
- // Helpers
717
- // ============================================================================
718
-
719
- /**
720
- * Generate N-digit OTP code (default 6)
721
- */
722
- private generateCode(): string {
723
- const codeLength = this.config.signup?.phoneVerification?.codeLength || 6;
724
- const min = Math.pow(10, codeLength - 1);
725
- const max = Math.pow(10, codeLength) - 1;
726
- return Math.floor(min + Math.random() * (max - min + 1)).toString();
727
- }
728
-
729
- /**
730
- * Generate secure random token
731
- */
732
- private generateToken(): string {
733
- return crypto.randomBytes(32).toString('hex');
734
- }
735
-
736
- /**
737
- * Hash token with SHA-256
738
- */
739
- private hashToken(token: string): string {
740
- return crypto.createHash('sha256').update(token).digest('hex');
741
- }
742
-
743
- /**
744
- * Mask phone number for logging (preserves last 4 digits)
745
- * @private
746
- */
747
- private maskPhone(phone: string): string {
748
- if (!phone || phone.length < 4) return '***';
749
- return `***${phone.slice(-4)}`;
750
- }
751
- }