@nauth-toolkit/core 0.1.39 → 0.1.40

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 (33) hide show
  1. package/dist/dto/get-user-sessions-response.dto.d.ts +88 -0
  2. package/dist/dto/get-user-sessions-response.dto.d.ts.map +1 -0
  3. package/dist/dto/get-user-sessions-response.dto.js +181 -0
  4. package/dist/dto/get-user-sessions-response.dto.js.map +1 -0
  5. package/dist/dto/get-user-sessions.dto.d.ts +17 -0
  6. package/dist/dto/get-user-sessions.dto.d.ts.map +1 -0
  7. package/dist/dto/get-user-sessions.dto.js +38 -0
  8. package/dist/dto/get-user-sessions.dto.js.map +1 -0
  9. package/dist/dto/index.d.ts +4 -0
  10. package/dist/dto/index.d.ts.map +1 -1
  11. package/dist/dto/index.js +4 -0
  12. package/dist/dto/index.js.map +1 -1
  13. package/dist/dto/logout-session-response.dto.d.ts +20 -0
  14. package/dist/dto/logout-session-response.dto.d.ts.map +1 -0
  15. package/dist/dto/logout-session-response.dto.js +42 -0
  16. package/dist/dto/logout-session-response.dto.js.map +1 -0
  17. package/dist/dto/logout-session.dto.d.ts +22 -0
  18. package/dist/dto/logout-session.dto.d.ts.map +1 -0
  19. package/dist/dto/logout-session.dto.js +48 -0
  20. package/dist/dto/logout-session.dto.js.map +1 -0
  21. package/dist/services/auth-service-internal-helpers.d.ts +229 -0
  22. package/dist/services/auth-service-internal-helpers.d.ts.map +1 -0
  23. package/dist/services/auth-service-internal-helpers.js +1004 -0
  24. package/dist/services/auth-service-internal-helpers.js.map +1 -0
  25. package/dist/services/auth.service.d.ts +178 -156
  26. package/dist/services/auth.service.d.ts.map +1 -1
  27. package/dist/services/auth.service.js +486 -2308
  28. package/dist/services/auth.service.js.map +1 -1
  29. package/dist/services/user.service.d.ts +274 -0
  30. package/dist/services/user.service.d.ts.map +1 -0
  31. package/dist/services/user.service.js +1327 -0
  32. package/dist/services/user.service.js.map +1 -0
  33. package/package.json +1 -1
@@ -0,0 +1,1004 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.AuthServiceInternalHelpers = void 0;
4
+ const auth_audit_event_type_enum_1 = require("../enums/auth-audit-event-type.enum");
5
+ const auth_challenge_dto_1 = require("../dto/auth-challenge.dto");
6
+ const verify_email_dto_1 = require("../dto/verify-email.dto");
7
+ const verify_phone_dto_1 = require("../dto/verify-phone.dto");
8
+ const verify_phone_by_sub_dto_1 = require("../dto/verify-phone-by-sub.dto");
9
+ const mfa_method_enum_1 = require("../enums/mfa-method.enum");
10
+ const nauth_exception_1 = require("../exceptions/nauth.exception");
11
+ const error_codes_enum_1 = require("../enums/error-codes.enum");
12
+ /**
13
+ * Internal helper service for AuthService
14
+ *
15
+ * Contains private utility methods for challenge handling, validation,
16
+ * password management, and login tracking. This class is NOT exported from
17
+ * the package and should only be used internally by AuthService.
18
+ *
19
+ * INTERNAL USE ONLY - DO NOT IMPORT DIRECTLY
20
+ *
21
+ * @internal
22
+ */
23
+ class AuthServiceInternalHelpers {
24
+ userRepository;
25
+ loginAttemptRepository;
26
+ emailVerificationService;
27
+ phoneVerificationService;
28
+ challengeService;
29
+ challengeHelper;
30
+ clientInfoService;
31
+ sessionService;
32
+ accountLockoutStorage;
33
+ config;
34
+ logger;
35
+ constructor(userRepository, loginAttemptRepository, emailVerificationService, phoneVerificationService, challengeService, challengeHelper, clientInfoService, sessionService, accountLockoutStorage, config, logger) {
36
+ this.userRepository = userRepository;
37
+ this.loginAttemptRepository = loginAttemptRepository;
38
+ this.emailVerificationService = emailVerificationService;
39
+ this.phoneVerificationService = phoneVerificationService;
40
+ this.challengeService = challengeService;
41
+ this.challengeHelper = challengeHelper;
42
+ this.clientInfoService = clientInfoService;
43
+ this.sessionService = sessionService;
44
+ this.accountLockoutStorage = accountLockoutStorage;
45
+ this.config = config;
46
+ this.logger = logger;
47
+ }
48
+ // ============================================================================
49
+ // Challenge Response Handlers
50
+ // ============================================================================
51
+ /**
52
+ * Handle VERIFY_EMAIL challenge
53
+ *
54
+ * @param challengeSession - Challenge session with user
55
+ * @param code - Email verification code
56
+ * @returns Authentication response with tokens or next challenge
57
+ */
58
+ async handleVerifyEmail(challengeSession, code) {
59
+ const user = challengeSession.user;
60
+ if (!user) {
61
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
62
+ }
63
+ this.logger?.log?.(`Verifying email for user: ${user.sub}`);
64
+ // Verify email with code, ensuring it belongs to this specific challenge session
65
+ const verifyDto = Object.assign(new verify_email_dto_1.VerifyEmailWithCodeDTO(), {
66
+ email: user.email,
67
+ code,
68
+ challengeSessionId: challengeSession.id, // Link verification to this specific session
69
+ });
70
+ const result = await this.emailVerificationService.verifyEmailWithCode(verifyDto);
71
+ const isVerified = result.message === 'Email verified successfully. Please log in to continue.';
72
+ if (!isVerified) {
73
+ // Increment attempts but don't consume session
74
+ await this.challengeService.incrementAttempts(challengeSession);
75
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
76
+ }
77
+ // Consume challenge session
78
+ await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.VERIFY_EMAIL);
79
+ // Reload user to get updated emailVerified flag
80
+ const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
81
+ if (!updatedUser) {
82
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after email verification');
83
+ }
84
+ // Get client info
85
+ const clientInfo = this.clientInfoService.get();
86
+ // Read auth context from challenge session metadata
87
+ const authMethod = challengeSession.metadata?.authMethod || 'password';
88
+ const authProvider = challengeSession.metadata?.authProvider;
89
+ const isSocialLogin = authMethod === 'social';
90
+ // Check for next challenges
91
+ const response = await this.challengeHelper.determineAuthResponse({
92
+ user: updatedUser,
93
+ config: this.config,
94
+ deviceToken: clientInfo.deviceToken,
95
+ isSocialLogin,
96
+ skipMFAVerification: false,
97
+ authProvider,
98
+ });
99
+ if (response.challengeName) {
100
+ this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
101
+ }
102
+ else {
103
+ this.logger?.log?.(`Email verified, auth completed for: ${user.email}`);
104
+ }
105
+ return response;
106
+ }
107
+ /**
108
+ * Handle VERIFY_PHONE challenge
109
+ *
110
+ * @param challengeSession - Challenge session with user
111
+ * @param data - Phone verification data (phone number or code)
112
+ * @returns Authentication response with tokens or next challenge
113
+ */
114
+ async handleVerifyPhone(challengeSession, data) {
115
+ const user = challengeSession.user;
116
+ if (!user) {
117
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
118
+ }
119
+ // Check if this is phone collection (first step) or verification (second step)
120
+ if ('phone' in data && data.phone) {
121
+ // Phone collection step
122
+ const phone = data.phone;
123
+ this.logger?.log?.(`Collecting phone number for user: ${user.sub}`);
124
+ // Validate phone format (E.164 format: +[country][number])
125
+ const phoneRegex = /^\+[1-9]\d{1,14}$/;
126
+ if (!phoneRegex.test(phone)) {
127
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INVALID_PHONE_FORMAT, 'Invalid phone number format. Use E.164 format (e.g., +1234567890)');
128
+ }
129
+ // Update user phone number
130
+ await this.userRepository.update({ sub: user.sub }, { phone });
131
+ this.logger?.log?.(`Phone number added for user ${user.sub}: ${phone}`);
132
+ // Send verification SMS to the newly added phone
133
+ let smsError;
134
+ if (this.phoneVerificationService) {
135
+ this.logger?.log?.(`Sending verification SMS to newly added phone: ${phone}`);
136
+ try {
137
+ const smsDto = Object.assign(new verify_phone_dto_1.SendVerificationSMSDTO(), {
138
+ sub: user.sub,
139
+ skipAlreadyVerifiedCheck: false, // Explicitly set to false for phone verification (not MFA)
140
+ challengeSessionId: challengeSession.id, // Link SMS code to this challenge session
141
+ });
142
+ await this.phoneVerificationService.sendVerificationSMS(smsDto);
143
+ this.logger?.log?.(`Verification SMS sent successfully to: ${phone}`);
144
+ }
145
+ catch (error) {
146
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
147
+ this.logger?.error?.(`Failed to send verification SMS to ${phone}: ${errorMessage}`);
148
+ smsError = errorMessage;
149
+ }
150
+ }
151
+ else {
152
+ this.logger?.warn?.(`Phone verification SMS not sent - PhoneVerificationService not available. ` +
153
+ 'Phone verification requires an SMS provider to be configured.');
154
+ }
155
+ // DO NOT consume the challenge session yet - user still needs to verify the code
156
+ // Preserve auth context from original challenge session
157
+ const authMethod = challengeSession.metadata?.authMethod || 'password';
158
+ const authProvider = challengeSession.metadata?.authProvider;
159
+ // Return same challenge with updated phone in parameters
160
+ // Skip auto-send since SMS was already sent above during phone collection
161
+ const challengeResponse = await this.challengeHelper.createChallengeResponse({ ...user, phone }, auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE, this.config, authMethod, authProvider, true);
162
+ // Include SMS error in challenge parameters if SMS failed
163
+ if (smsError) {
164
+ challengeResponse.challengeParameters = challengeResponse.challengeParameters || {};
165
+ challengeResponse.challengeParameters.smsError = smsError;
166
+ }
167
+ return challengeResponse;
168
+ }
169
+ else {
170
+ // Phone verification step (code provided)
171
+ const code = data.code;
172
+ this.logger?.log?.(`Verifying phone for user: ${user.sub}`);
173
+ // Check if phone is set
174
+ if (!user.phone) {
175
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Phone number not yet provided. Submit phone number first.');
176
+ }
177
+ // Verify phone with code, ensuring it belongs to this specific challenge session
178
+ const verifyDto = Object.assign(new verify_phone_by_sub_dto_1.VerifyPhoneWithCodeBySubDTO(), {
179
+ sub: user.sub,
180
+ code,
181
+ challengeSessionId: challengeSession.id, // Link verification to this specific session
182
+ });
183
+ const result = await this.phoneVerificationService.verifyPhoneWithCodeBySub(verifyDto);
184
+ const isVerified = result.message === 'Phone verified successfully. Please log in to continue.';
185
+ if (!isVerified) {
186
+ // Increment attempts but don't consume session
187
+ await this.challengeService.incrementAttempts(challengeSession);
188
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid verification code');
189
+ }
190
+ // Consume challenge session
191
+ await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.VERIFY_PHONE);
192
+ // Reload user to get updated phoneVerified flag
193
+ const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
194
+ if (!updatedUser) {
195
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after phone verification');
196
+ }
197
+ // Get client info
198
+ const clientInfo = this.clientInfoService.get();
199
+ // Read auth context from challenge session metadata
200
+ const authMethod = challengeSession.metadata?.authMethod || 'password';
201
+ const authProvider = challengeSession.metadata?.authProvider;
202
+ const isSocialLogin = authMethod === 'social';
203
+ // Check for next challenges
204
+ const response = await this.challengeHelper.determineAuthResponse({
205
+ user: updatedUser,
206
+ config: this.config,
207
+ deviceToken: clientInfo.deviceToken,
208
+ isSocialLogin,
209
+ skipMFAVerification: false,
210
+ authProvider,
211
+ });
212
+ if (response.challengeName) {
213
+ this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
214
+ }
215
+ else {
216
+ this.logger?.log?.(`Phone verified, auth completed for: ${user.email}`);
217
+ }
218
+ return response;
219
+ }
220
+ }
221
+ /**
222
+ * Handle MFA_REQUIRED challenge
223
+ *
224
+ * @param challengeSession - Challenge session with user
225
+ * @param data - MFA verification data
226
+ * @param mfaService - MFA service (passed from AuthService)
227
+ * @param trustedDeviceService - Trusted device service (optional, passed from AuthService)
228
+ * @param auditService - Audit service (optional, passed from AuthService)
229
+ * @returns Authentication response with tokens or next challenge
230
+ */
231
+ async handleMFAVerification(challengeSession, data, mfaService, trustedDeviceService, auditService) {
232
+ const user = challengeSession.user;
233
+ if (!user) {
234
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
235
+ }
236
+ const method = data.method;
237
+ this.logger?.log?.(`MFA verification attempt: method=${method}, user=${user.sub}`);
238
+ // Check if MFAService is available
239
+ if (!mfaService) {
240
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
241
+ }
242
+ // Get client info
243
+ const clientInfo = this.clientInfoService.get();
244
+ // Verify MFA based on method
245
+ let isValid = false;
246
+ if (method === 'passkey') {
247
+ const passkeyData = data;
248
+ const credential = passkeyData.credential;
249
+ // Get expected challenge from session metadata
250
+ const expectedChallenge = challengeSession.metadata?.passkeyChallenge;
251
+ if (!expectedChallenge) {
252
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'No passkey challenge found in session');
253
+ }
254
+ // Verify passkey via MFAService
255
+ const wrappedCredential = { credential, expectedChallenge };
256
+ const verifyResult = await mfaService.verifyCode({
257
+ sub: user.sub,
258
+ methodName: mfa_method_enum_1.MFAMethod.PASSKEY,
259
+ code: wrappedCredential,
260
+ });
261
+ isValid = verifyResult.valid;
262
+ }
263
+ else {
264
+ const codeData = data;
265
+ const code = codeData.code;
266
+ // Verify code via MFAService (handles totp, sms, and backup)
267
+ const verifyResult = await mfaService.verifyCode({
268
+ sub: user.sub,
269
+ methodName: method,
270
+ code,
271
+ });
272
+ isValid = verifyResult.valid;
273
+ }
274
+ if (!isValid) {
275
+ this.logger?.warn?.(`MFA verification failed for user: ${user.sub}`);
276
+ // Audit: Record MFA verification failure
277
+ if (this.config.auditLogs?.fireAndForget) {
278
+ auditService
279
+ ?.recordEvent({
280
+ userId: user.id,
281
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_FAILED,
282
+ eventStatus: 'FAILURE',
283
+ challengeSessionId: challengeSession.id,
284
+ authMethod: method,
285
+ metadata: { mfaMethod: method },
286
+ })
287
+ .catch((err) => {
288
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
289
+ this.logger?.error?.(`Failed to record MFA_VERIFICATION_FAILED audit event (fire-and-forget): ${errorMessage}`, {
290
+ error: err,
291
+ userId: user.id,
292
+ userSub: user.sub,
293
+ });
294
+ });
295
+ }
296
+ else {
297
+ try {
298
+ await auditService?.recordEvent({
299
+ userId: user.id,
300
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_FAILED,
301
+ eventStatus: 'FAILURE',
302
+ challengeSessionId: challengeSession.id,
303
+ authMethod: method,
304
+ metadata: { mfaMethod: method },
305
+ });
306
+ }
307
+ catch (auditError) {
308
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
309
+ this.logger?.error?.(`Failed to record MFA_VERIFICATION_FAILED audit event: ${errorMessage}`, {
310
+ error: auditError,
311
+ userId: user.id,
312
+ });
313
+ }
314
+ }
315
+ // Increment challenge attempts (session not consumed, so user can retry)
316
+ await this.challengeService.incrementAttempts(challengeSession);
317
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VERIFICATION_CODE_INVALID, 'Invalid MFA code');
318
+ }
319
+ this.logger?.log?.(`MFA verified successfully for user: ${user.sub}`);
320
+ // Audit: Record MFA verification success
321
+ if (this.config.auditLogs?.fireAndForget) {
322
+ auditService
323
+ ?.recordEvent({
324
+ userId: user.id,
325
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
326
+ eventStatus: 'SUCCESS',
327
+ challengeSessionId: challengeSession.id,
328
+ authMethod: method,
329
+ metadata: { mfaMethod: method },
330
+ })
331
+ .catch((err) => {
332
+ const errorMessage = err instanceof Error ? err.message : 'Unknown error';
333
+ this.logger?.error?.(`Failed to record MFA_VERIFICATION_SUCCESS audit event (fire-and-forget): ${errorMessage}`, {
334
+ error: err,
335
+ userId: user.id,
336
+ userSub: user.sub,
337
+ });
338
+ });
339
+ }
340
+ else {
341
+ try {
342
+ await auditService?.recordEvent({
343
+ userId: user.id,
344
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.MFA_VERIFICATION_SUCCESS,
345
+ eventStatus: 'SUCCESS',
346
+ challengeSessionId: challengeSession.id,
347
+ authMethod: method,
348
+ metadata: { mfaMethod: method },
349
+ });
350
+ }
351
+ catch (auditError) {
352
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
353
+ this.logger?.error?.(`Failed to record MFA_VERIFICATION_SUCCESS audit event: ${errorMessage}`, {
354
+ error: auditError,
355
+ userId: user.id,
356
+ });
357
+ }
358
+ }
359
+ // Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
360
+ await this.challengeService.updateMetadata(challengeSession.sessionToken, {
361
+ mfaMethod: method,
362
+ });
363
+ // Only consume the session AFTER successful verification
364
+ await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.MFA_REQUIRED);
365
+ // Read auth context from challenge session metadata
366
+ const authMethod = challengeSession.metadata?.authMethod || 'password';
367
+ const authProvider = challengeSession.metadata?.authProvider;
368
+ const isSocialLogin = authMethod === 'social';
369
+ // ============================================================================
370
+ // Trusted Device Token Management (Remember Device Feature)
371
+ // ============================================================================
372
+ // NOTE:
373
+ // - We only create / update trusted device tokens AFTER MFA has been successfully
374
+ // verified to avoid trusting devices that haven't completed full auth.
375
+ // - For 'always' mode, this mirrors the behavior in the primary login flow.
376
+ let deviceToken = clientInfo.deviceToken;
377
+ let isTrustedDevice = false;
378
+ if (trustedDeviceService && this.config.mfa?.rememberDevices && this.config.mfa.rememberDevices !== 'never') {
379
+ const rememberMode = this.config.mfa.rememberDevices;
380
+ // If a device token is already present, check if it's trusted
381
+ if (deviceToken) {
382
+ try {
383
+ isTrustedDevice = await trustedDeviceService.isDeviceTrusted(deviceToken, user.id);
384
+ if (isTrustedDevice) {
385
+ this.logger?.debug?.(`MFA flow: existing trusted device token detected for user ${user.sub} (token reused)`);
386
+ }
387
+ }
388
+ catch (error) {
389
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
390
+ this.logger?.warn?.(`MFA flow: failed to validate existing trusted device token for user ${user.sub}: ${errorMessage}`, { error });
391
+ }
392
+ }
393
+ // Auto-trust mode: create device token automatically if not already trusted
394
+ if (rememberMode === 'always' && !isTrustedDevice) {
395
+ try {
396
+ deviceToken = await trustedDeviceService.createTrustedDevice(user.id, clientInfo.deviceName, clientInfo.deviceType, clientInfo.ipAddress, clientInfo.userAgent, clientInfo.platform, clientInfo.browser);
397
+ isTrustedDevice = true;
398
+ this.logger?.debug?.(`MFA flow: auto-created trusted device token for user ${user.sub} (rememberDevices='always')`);
399
+ }
400
+ catch (error) {
401
+ const errorMessage = error instanceof Error ? error.message : 'Unknown error';
402
+ this.logger?.warn?.(`MFA flow: failed to create trusted device token for user ${user.sub}: ${errorMessage}`, {
403
+ error,
404
+ });
405
+ }
406
+ }
407
+ }
408
+ // Check for next challenges (MFA is usually the last challenge)
409
+ const response = await this.challengeHelper.determineAuthResponse({
410
+ user,
411
+ config: this.config,
412
+ deviceToken,
413
+ isSocialLogin,
414
+ skipMFAVerification: true, // Already verified
415
+ authProvider,
416
+ });
417
+ // Propagate trusted device metadata into response so that:
418
+ // - CookieTokenInterceptor can set the nauth_device_token cookie (cookies mode)
419
+ // - Mobile clients in JSON mode can store the device token securely
420
+ if (isTrustedDevice) {
421
+ response.trusted = response.trusted ?? true;
422
+ }
423
+ if (deviceToken && !response.deviceToken) {
424
+ response.deviceToken = deviceToken;
425
+ }
426
+ if (response.challengeName) {
427
+ this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
428
+ }
429
+ else {
430
+ this.logger?.log?.(`MFA verified, auth completed for: ${user.email}`);
431
+ }
432
+ return response;
433
+ }
434
+ /**
435
+ * Handle FORCE_CHANGE_PASSWORD challenge
436
+ *
437
+ * @param challengeSession - Challenge session with user
438
+ * @param newPassword - New password
439
+ * @param passwordService - Password service (passed from AuthService)
440
+ * @param auditService - Audit service (optional, passed from AuthService)
441
+ * @returns Authentication response with tokens or next challenge
442
+ */
443
+ async handleForceChangePassword(challengeSession, newPassword, passwordService, auditService) {
444
+ const user = challengeSession.user;
445
+ if (!user) {
446
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
447
+ }
448
+ this.logger?.log?.(`Changing password for user: ${user.sub}`);
449
+ await this.updateUserPassword({
450
+ user,
451
+ newPassword,
452
+ mustChangePassword: false,
453
+ revokeSessions: true,
454
+ revokeReason: 'Password changed (force change password)',
455
+ audit: {
456
+ eventType: auth_audit_event_type_enum_1.AuthAuditEventType.PASSWORD_CHANGED,
457
+ eventStatus: 'SUCCESS',
458
+ reason: 'force_change_password',
459
+ description: 'Password changed due to FORCE_CHANGE_PASSWORD challenge',
460
+ },
461
+ }, passwordService, auditService);
462
+ // Consume challenge session
463
+ await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.FORCE_CHANGE_PASSWORD);
464
+ // Reload user from database to get updated mustChangePassword flag
465
+ const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
466
+ if (!updatedUser) {
467
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after password update');
468
+ }
469
+ // Get client info
470
+ const clientInfo = this.clientInfoService.get();
471
+ // Read auth context from challenge session metadata
472
+ const authMethod = challengeSession.metadata?.authMethod || 'password';
473
+ const authProvider = challengeSession.metadata?.authProvider;
474
+ const isSocialLogin = authMethod === 'social';
475
+ // Check for next challenges
476
+ const response = await this.challengeHelper.determineAuthResponse({
477
+ user: updatedUser,
478
+ config: this.config,
479
+ deviceToken: clientInfo.deviceToken,
480
+ isSocialLogin,
481
+ skipMFAVerification: false,
482
+ authProvider,
483
+ });
484
+ if (response.challengeName) {
485
+ this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
486
+ }
487
+ else {
488
+ this.logger?.log?.(`Password changed, auth completed for: ${user.email}`);
489
+ }
490
+ return response;
491
+ }
492
+ /**
493
+ * Handle MFA_SETUP_REQUIRED challenge
494
+ *
495
+ * @param challengeSession - Challenge session with user
496
+ * @param data - MFA setup data
497
+ * @param mfaService - MFA service (passed from AuthService)
498
+ * @param auditService - Audit service (optional, passed from AuthService)
499
+ * @returns Authentication response with tokens or next challenge
500
+ */
501
+ async handleMFASetup(challengeSession, data, mfaService, _auditService) {
502
+ const user = challengeSession.user;
503
+ if (!user) {
504
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.CHALLENGE_INVALID, 'User not found in challenge session');
505
+ }
506
+ const method = data.method;
507
+ const setupData = data.setupData;
508
+ const requestTrace = `${Date.now()}-${Math.random().toString(36).substring(7)}`;
509
+ this.logger?.log?.(`[${requestTrace}] MFA setup attempt: method=${method}, user=${user.sub}`);
510
+ // Check if MFAService is available
511
+ if (!mfaService) {
512
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.INTERNAL_ERROR, 'MFA service is not available');
513
+ }
514
+ // Get provider
515
+ const provider = mfaService.getProvider(method);
516
+ // Verify setup based on method
517
+ let deviceId;
518
+ try {
519
+ deviceId = await provider.verifySetup(user, setupData);
520
+ this.logger?.log?.(`MFA device setup completed: method=${method}, deviceId=${deviceId}`);
521
+ }
522
+ catch (error) {
523
+ this.logger?.warn?.(`MFA setup verification failed: method=${method}, user=${user.sub}`);
524
+ // Increment attempts but don't consume session
525
+ await this.challengeService.incrementAttempts(challengeSession);
526
+ // Re-throw the error
527
+ throw error;
528
+ }
529
+ // Store MFA method in challenge session metadata for CHALLENGE_COMPLETED audit event
530
+ await this.challengeService.updateMetadata(challengeSession.sessionToken, {
531
+ mfaMethod: method,
532
+ });
533
+ // Consume challenge session
534
+ await this.challengeService.validateAndConsumeSession(challengeSession.sessionToken, auth_challenge_dto_1.AuthChallenge.MFA_SETUP_REQUIRED);
535
+ // Reload user from database to get updated mfaEnabled flag
536
+ const updatedUser = await this.userRepository.findOne({ where: { sub: user.sub } });
537
+ if (!updatedUser) {
538
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found after MFA setup');
539
+ }
540
+ // Get client info
541
+ const clientInfo = this.clientInfoService.get();
542
+ // Check for next challenges with updated user data
543
+ // Skip MFA verification because device was already verified during setup
544
+ const response = await this.challengeHelper.determineAuthResponse({
545
+ user: updatedUser,
546
+ config: this.config,
547
+ deviceToken: clientInfo.deviceToken,
548
+ isSocialLogin: false,
549
+ skipMFAVerification: true, // Device already verified during setup
550
+ });
551
+ if (response.challengeName) {
552
+ this.logger?.log?.(`Additional challenge required: ${response.challengeName}`);
553
+ }
554
+ else {
555
+ this.logger?.log?.(`MFA setup completed, auth completed for: ${user.email}`);
556
+ }
557
+ return response;
558
+ }
559
+ // ============================================================================
560
+ // Validation Helpers
561
+ // ============================================================================
562
+ /**
563
+ * Validate that response type matches expected challenge type
564
+ *
565
+ * @param expected - Expected challenge type
566
+ * @param provided - Provided challenge type
567
+ * @throws {NAuthException} If types don't match
568
+ */
569
+ validateChallengeTypeMatch(expected, provided) {
570
+ if (expected !== provided) {
571
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, `Challenge type mismatch: expected ${expected}, got ${provided}`);
572
+ }
573
+ }
574
+ /**
575
+ * Validate parameters for challenge type
576
+ *
577
+ * Service-level validation ensures Express/other frameworks get same validation as NestJS.
578
+ * This is critical for non-DTO-based applications.
579
+ *
580
+ * @param type - Challenge type
581
+ * @param data - Challenge response data
582
+ * @throws {NAuthException} If validation fails
583
+ */
584
+ validateChallengeParams(type, data) {
585
+ switch (type) {
586
+ case 'VERIFY_EMAIL': {
587
+ const response = data;
588
+ if (!response.code || typeof response.code !== 'string') {
589
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Verification code is required', { field: 'code' });
590
+ }
591
+ break;
592
+ }
593
+ case 'VERIFY_PHONE': {
594
+ const response = data;
595
+ const hasCode = 'code' in response && response.code;
596
+ const hasPhone = 'phone' in response && response.phone;
597
+ if (!hasCode && !hasPhone) {
598
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Either phone number or verification code is required', { fields: ['phone', 'code'] });
599
+ }
600
+ break;
601
+ }
602
+ case 'MFA_REQUIRED': {
603
+ const response = data;
604
+ if (!response.method) {
605
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA method is required', { field: 'method' });
606
+ }
607
+ if (response.method === 'passkey') {
608
+ const passkeyResponse = response;
609
+ if (!passkeyResponse.credential) {
610
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'Passkey credential is required', {
611
+ field: 'credential',
612
+ });
613
+ }
614
+ }
615
+ else {
616
+ const codeResponse = response;
617
+ if (!codeResponse.code || typeof codeResponse.code !== 'string') {
618
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA code is required', { field: 'code' });
619
+ }
620
+ }
621
+ break;
622
+ }
623
+ case 'FORCE_CHANGE_PASSWORD': {
624
+ const response = data;
625
+ if (!response.newPassword || typeof response.newPassword !== 'string') {
626
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'New password is required', {
627
+ field: 'newPassword',
628
+ });
629
+ }
630
+ break;
631
+ }
632
+ case 'MFA_SETUP_REQUIRED': {
633
+ const response = data;
634
+ if (!response.method) {
635
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA setup method is required', {
636
+ field: 'method',
637
+ });
638
+ }
639
+ if (!response.setupData || typeof response.setupData !== 'object') {
640
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, 'MFA setup data is required', {
641
+ field: 'setupData',
642
+ });
643
+ }
644
+ break;
645
+ }
646
+ }
647
+ }
648
+ /**
649
+ * Checks if the login identifier matches the specified allowed type.
650
+ *
651
+ * Determines if the given identifier is a valid email, username, phone, or allowed hybrid,
652
+ * according to the configured identifier type restriction.
653
+ *
654
+ * @param identifier - The login identifier to check (email, username, or phone)
655
+ * @param allowedType - The permitted identifier type ('email', 'username', 'phone', or 'email_or_username')
656
+ * @returns True if the identifier conforms to the allowed type, otherwise false
657
+ */
658
+ validateIdentifierType(identifier, allowedType) {
659
+ // Check if identifier is an email (contains @)
660
+ const isEmail = identifier.includes('@');
661
+ // Check if identifier looks like a phone (starts with + and contains digits)
662
+ const isPhone = /^\+[1-9]\d{1,14}$/.test(identifier.trim());
663
+ // If not email or phone, assume it's a username
664
+ const isUsername = !isEmail && !isPhone;
665
+ switch (allowedType) {
666
+ case 'email':
667
+ return isEmail;
668
+ case 'username':
669
+ return isUsername;
670
+ case 'phone':
671
+ return isPhone;
672
+ case 'email_or_username':
673
+ return isEmail || isUsername;
674
+ default:
675
+ return true; // No restriction
676
+ }
677
+ }
678
+ /**
679
+ * Ensures email, phone, and username are unique for other users before update.
680
+ *
681
+ * Throws if another user already has the specified email, phone, or username.
682
+ *
683
+ * @param userId - Internal numeric user ID (excluded from check)
684
+ * @param updateData - User fields to check for uniqueness
685
+ * @throws {NAuthException} If a unique constraint is violated for email, phone, or username
686
+ */
687
+ async validateUniquenessConstraints(userId, updateData) {
688
+ const conflicts = [];
689
+ // Check email uniqueness
690
+ if (updateData.email) {
691
+ const existingUser = await this.userRepository.findOne({
692
+ where: { email: updateData.email },
693
+ });
694
+ if (existingUser && existingUser.id !== userId) {
695
+ conflicts.push('Email already exists');
696
+ }
697
+ }
698
+ // Check phone uniqueness
699
+ if (updateData.phone) {
700
+ const existingUser = await this.userRepository.findOne({
701
+ where: { phone: updateData.phone },
702
+ });
703
+ if (existingUser && existingUser.id !== userId) {
704
+ conflicts.push('Phone number already exists');
705
+ }
706
+ }
707
+ // Check username uniqueness
708
+ if (updateData.username) {
709
+ const existingUser = await this.userRepository.findOne({
710
+ where: { username: updateData.username },
711
+ });
712
+ if (existingUser && existingUser.id !== userId) {
713
+ conflicts.push('Username already exists');
714
+ }
715
+ }
716
+ if (conflicts.length > 0) {
717
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.VALIDATION_FAILED, conflicts.join(', '), {
718
+ conflicts,
719
+ });
720
+ }
721
+ }
722
+ // ============================================================================
723
+ // User Lookup Helpers
724
+ // ============================================================================
725
+ /**
726
+ * Retrieves a user entity by login identifier.
727
+ *
728
+ * Performs a lookup for a user by email, username, or phone number.
729
+ * The search respects the identifierType restriction when provided, limiting which fields are queried.
730
+ *
731
+ * @param identifier - Login credential (email, username, or phone)
732
+ * @param identifierType - Restricts search to a specific identifier type ('email', 'username', 'phone', or 'email_or_username')
733
+ * @returns The user entity if found, otherwise null
734
+ */
735
+ async findUserByIdentifier(identifier, identifierType) {
736
+ const queryBuilder = this.userRepository.createQueryBuilder('user');
737
+ // Build query based on identifier type restriction
738
+ if (!identifierType) {
739
+ // No restriction - search all fields
740
+ queryBuilder
741
+ .where('user.email = :identifier', { identifier })
742
+ .orWhere('user.username = :identifier', { identifier })
743
+ .orWhere('user.phone = :identifier', { identifier });
744
+ }
745
+ else {
746
+ // Apply restriction based on identifier type
747
+ switch (identifierType) {
748
+ case 'email':
749
+ queryBuilder.where('user.email = :identifier', { identifier });
750
+ break;
751
+ case 'username':
752
+ queryBuilder.where('user.username = :identifier', { identifier });
753
+ break;
754
+ case 'phone':
755
+ queryBuilder.where('user.phone = :identifier', { identifier });
756
+ break;
757
+ case 'email_or_username':
758
+ queryBuilder
759
+ .where('user.email = :identifier', { identifier })
760
+ .orWhere('user.username = :identifier', { identifier });
761
+ break;
762
+ }
763
+ }
764
+ // Select only columns required for login checks and response shaping to reduce row size
765
+ queryBuilder.select([
766
+ 'user.id',
767
+ 'user.sub',
768
+ 'user.email',
769
+ 'user.firstName',
770
+ 'user.lastName',
771
+ 'user.username',
772
+ 'user.phone',
773
+ 'user.passwordHash',
774
+ 'user.passwordChangedAt',
775
+ 'user.mustChangePassword',
776
+ 'user.isActive',
777
+ 'user.mfaEnabled',
778
+ 'user.preferredMfaMethod',
779
+ 'user.isEmailVerified',
780
+ 'user.isPhoneVerified',
781
+ 'user.mfaExempt', // Required for MFA exemption check in challenge flow
782
+ // Lock fields - required for account lock check in login flow
783
+ 'user.isLocked',
784
+ 'user.lockReason',
785
+ 'user.lockedAt',
786
+ 'user.lockedUntil',
787
+ // The following are used for messaging/challenge determination when needed
788
+ 'user.socialProviders',
789
+ 'user.backupCodes',
790
+ ]);
791
+ return (await queryBuilder.getOne());
792
+ }
793
+ // ============================================================================
794
+ // Password Management Helpers
795
+ // ============================================================================
796
+ /**
797
+ * Centralized password update flow used by:
798
+ * - changePassword()
799
+ * - confirmForgotPassword()
800
+ * - adminSetPassword()
801
+ * - FORCE_CHANGE_PASSWORD challenge handler
802
+ *
803
+ * WHY:
804
+ * - Prevent logic drift between different password-changing entrypoints
805
+ * - Ensure consistent validation, history enforcement, persistence, session revocation, and audit trails
806
+ *
807
+ * @param params - Password update parameters
808
+ * @param passwordService - Password service (passed from AuthService)
809
+ * @param auditService - Audit service (optional, passed from AuthService)
810
+ * @returns Sessions revoked count (0 when not revoked)
811
+ * @throws {NAuthException} WEAK_PASSWORD | PASSWORD_REUSED | NOT_FOUND
812
+ */
813
+ async updateUserPassword(params, passwordService, auditService) {
814
+ const { user, newPassword, mustChangePassword, revokeSessions, revokeReason, beforePersist, audit } = params;
815
+ // ============================================================================
816
+ // Load full user entity (important for passwordHistory serialization + reuse checks)
817
+ // ============================================================================
818
+ // WHY: Some call sites use a slim projection (e.g., findUserByIdentifier) which may omit passwordHistory.
819
+ const userEntity = (await this.userRepository.findOne({ where: { id: user.id } }));
820
+ if (!userEntity) {
821
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.NOT_FOUND, 'User not found');
822
+ }
823
+ // ============================================================================
824
+ // Validate new password + history
825
+ // ============================================================================
826
+ const validation = await passwordService.validatePassword(newPassword, {
827
+ email: userEntity.email,
828
+ username: userEntity.username || undefined,
829
+ });
830
+ if (!validation.valid) {
831
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.WEAK_PASSWORD, validation.errors.join(', '), {
832
+ errors: validation.errors,
833
+ });
834
+ }
835
+ if (this.config.password?.historyCount) {
836
+ const historyToCheck = userEntity.passwordHistory || [];
837
+ const allPreviousPasswords = userEntity.passwordHash
838
+ ? [userEntity.passwordHash, ...historyToCheck]
839
+ : historyToCheck;
840
+ const isReused = await passwordService.isPasswordInHistory(newPassword, allPreviousPasswords);
841
+ if (isReused) {
842
+ throw new nauth_exception_1.NAuthException(error_codes_enum_1.AuthErrorCode.PASSWORD_REUSED, 'Cannot reuse a recent password');
843
+ }
844
+ }
845
+ // Hook point for flows that must prove possession of a reset code before persisting (forgot-password confirm)
846
+ if (beforePersist) {
847
+ await beforePersist();
848
+ }
849
+ // ============================================================================
850
+ // Persist password update
851
+ // ============================================================================
852
+ const newHash = await passwordService.hashPassword(newPassword);
853
+ const newHistory = userEntity.passwordHash
854
+ ? passwordService.addToHistory(userEntity.passwordHistory || [], userEntity.passwordHash)
855
+ : userEntity.passwordHistory || [];
856
+ userEntity.passwordHash = newHash;
857
+ userEntity.passwordChangedAt = new Date();
858
+ userEntity.passwordHistory = newHistory;
859
+ userEntity.mustChangePassword = mustChangePassword;
860
+ await this.userRepository.save(userEntity);
861
+ // ============================================================================
862
+ // Session revocation
863
+ // ============================================================================
864
+ let sessionsRevoked = 0;
865
+ if (revokeSessions) {
866
+ sessionsRevoked = await this.sessionService.revokeAllUserSessions(userEntity.id, revokeReason);
867
+ }
868
+ // ============================================================================
869
+ // Audit
870
+ // ============================================================================
871
+ if (audit) {
872
+ try {
873
+ await auditService?.recordEvent({
874
+ userId: userEntity.id,
875
+ eventType: audit.eventType,
876
+ eventStatus: audit.eventStatus,
877
+ reason: audit.reason,
878
+ description: audit.description,
879
+ authMethod: audit.authMethod,
880
+ metadata: {
881
+ ...audit.metadata,
882
+ mustChangePassword,
883
+ sessionsRevoked,
884
+ },
885
+ });
886
+ }
887
+ catch (auditError) {
888
+ const errorMessage = auditError instanceof Error ? auditError.message : 'Unknown error';
889
+ this.logger?.error?.(`Failed to record ${audit.eventType} audit event: ${errorMessage}`, {
890
+ error: auditError,
891
+ userId: userEntity.id,
892
+ });
893
+ }
894
+ }
895
+ return { sessionsRevoked };
896
+ }
897
+ // ============================================================================
898
+ // Login Tracking Helpers
899
+ // ============================================================================
900
+ /**
901
+ * Handles a failed login by recording the attempt, applying IP-based lockout policy,
902
+ * and invoking relevant hooks.
903
+ *
904
+ * @param identifier - User identifier (email/username/phone)
905
+ * @param reason - Optional reason for failure
906
+ */
907
+ async handleFailedLogin(identifier, reason) {
908
+ // Get client IP address for lockout tracking
909
+ const clientInfo = this.clientInfoService.get();
910
+ const ipAddress = clientInfo.ipAddress;
911
+ // Record failed attempt
912
+ await this.recordLoginAttempt(identifier, false, reason);
913
+ // Increment IP-based lockout counter if enabled
914
+ if (this.config.lockout?.enabled && ipAddress) {
915
+ const attempts = await this.accountLockoutStorage.recordFailedAttempt(ipAddress);
916
+ // Lock IP if max attempts reached
917
+ if (attempts >= (this.config.lockout.maxAttempts || 5)) {
918
+ await this.accountLockoutStorage.lockIpAddress(ipAddress, this.config.lockout.duration || 900, // 15 minutes default
919
+ 'Too many failed login attempts from this IP');
920
+ }
921
+ }
922
+ }
923
+ /**
924
+ * Records a login attempt with client context.
925
+ *
926
+ * @param email - User's email address
927
+ * @param success - True if login succeeded, false if failed
928
+ * @param failureReason - Optional reason for failure
929
+ * @param userId - Optional internal user ID (only for successful logins)
930
+ */
931
+ async recordLoginAttempt(email, success, failureReason, userId) {
932
+ // Get client info from context
933
+ const clientInfo = this.clientInfoService.get();
934
+ const attempt = this.loginAttemptRepository.create({
935
+ email,
936
+ userId, // Internal user ID (integer)
937
+ ipAddress: clientInfo.ipAddress,
938
+ userAgent: clientInfo.userAgent,
939
+ success,
940
+ failureReason,
941
+ });
942
+ await this.loginAttemptRepository.save(attempt);
943
+ }
944
+ // ============================================================================
945
+ // Cookie Management Helpers
946
+ // ============================================================================
947
+ /**
948
+ * Clear authentication cookies from response
949
+ *
950
+ * @param response - HTTP response object with clearCookie method
951
+ * @param forgetDevice - Whether to also clear device token cookie
952
+ */
953
+ clearAuthCookies(response, forgetDevice) {
954
+ if (!response.clearCookie) {
955
+ return; // Response doesn't support cookie clearing (shouldn't happen)
956
+ }
957
+ const cookieOptions = this.config.tokenDelivery?.cookieOptions || {};
958
+ const prefix = this.config.tokenDelivery?.cookieNamePrefix || 'nauth';
959
+ // Clear access and refresh tokens
960
+ response.clearCookie(`${prefix}_access_token`, cookieOptions);
961
+ response.clearCookie(`${prefix}_refresh_token`, cookieOptions);
962
+ // Clear CSRF token cookie (httpOnly: false, so it can be cleared)
963
+ // Use the same cookie options but with httpOnly: false to match how it was set
964
+ const csrfCookieOptions = {
965
+ ...cookieOptions,
966
+ httpOnly: false, // CSRF token cookie is not httpOnly
967
+ };
968
+ const csrfCookieName = this.config.security?.csrf?.cookieName || `${prefix}_csrf_token`;
969
+ response.clearCookie(csrfCookieName, csrfCookieOptions);
970
+ // Clear device token if forgetting device
971
+ if (forgetDevice) {
972
+ response.clearCookie(`${prefix}_device_token`, cookieOptions);
973
+ }
974
+ }
975
+ // ============================================================================
976
+ // Formatting Helpers
977
+ // ============================================================================
978
+ /**
979
+ * Mask email address for privacy (show first char and domain)
980
+ *
981
+ * @param email - Email address to mask
982
+ * @returns Masked email (e.g., 'u***r@example.com')
983
+ */
984
+ maskEmail(email) {
985
+ const [localPart, domain] = email.split('@');
986
+ if (localPart.length <= 2) {
987
+ return `${localPart[0]}***@${domain}`;
988
+ }
989
+ return `${localPart[0]}***${localPart[localPart.length - 1]}@${domain}`;
990
+ }
991
+ /**
992
+ * Mask phone number for privacy (show last 4 digits)
993
+ *
994
+ * @param phone - Phone number to mask
995
+ * @returns Masked phone (e.g., '***-***-1234')
996
+ */
997
+ maskPhone(phone) {
998
+ const digits = phone.replace(/\D/g, '');
999
+ const lastFour = digits.slice(-4);
1000
+ return `***-***-${lastFour}`;
1001
+ }
1002
+ }
1003
+ exports.AuthServiceInternalHelpers = AuthServiceInternalHelpers;
1004
+ //# sourceMappingURL=auth-service-internal-helpers.js.map