@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.
- package/LICENSE +90 -0
- package/README.md +30 -0
- package/package.json +7 -2
- package/jest.config.js +0 -15
- package/jest.setup.ts +0 -6
- package/src/adapters/database-columns.ts +0 -165
- package/src/adapters/express.adapter.ts +0 -385
- package/src/adapters/fastify.adapter.ts +0 -416
- package/src/adapters/index.ts +0 -16
- package/src/adapters/storage.factory.ts +0 -143
- package/src/bootstrap.ts +0 -374
- package/src/dto/auth-challenge.dto.ts +0 -231
- package/src/dto/auth-response.dto.ts +0 -253
- package/src/dto/challenge-response.dto.ts +0 -234
- package/src/dto/change-password-request.dto.ts +0 -50
- package/src/dto/change-password-response.dto.ts +0 -29
- package/src/dto/change-password.dto.ts +0 -57
- package/src/dto/error-response.dto.ts +0 -136
- package/src/dto/get-available-methods.dto.ts +0 -55
- package/src/dto/get-challenge-data-response.dto.ts +0 -28
- package/src/dto/get-challenge-data.dto.ts +0 -69
- package/src/dto/get-client-info.dto.ts +0 -104
- package/src/dto/get-device-token-response.dto.ts +0 -25
- package/src/dto/get-events-by-type.dto.ts +0 -76
- package/src/dto/get-ip-address-response.dto.ts +0 -24
- package/src/dto/get-mfa-status.dto.ts +0 -94
- package/src/dto/get-risk-assessment-history.dto.ts +0 -39
- package/src/dto/get-session-id-response.dto.ts +0 -25
- package/src/dto/get-setup-data-response.dto.ts +0 -31
- package/src/dto/get-setup-data.dto.ts +0 -75
- package/src/dto/get-suspicious-activity.dto.ts +0 -42
- package/src/dto/get-user-agent-response.dto.ts +0 -23
- package/src/dto/get-user-auth-history.dto.ts +0 -95
- package/src/dto/get-user-by-email.dto.ts +0 -61
- package/src/dto/get-user-by-id.dto.ts +0 -46
- package/src/dto/get-user-devices.dto.ts +0 -53
- package/src/dto/get-user-response.dto.ts +0 -17
- package/src/dto/has-provider.dto.ts +0 -56
- package/src/dto/index.ts +0 -57
- package/src/dto/is-trusted-device-response.dto.ts +0 -34
- package/src/dto/list-providers-response.dto.ts +0 -23
- package/src/dto/login.dto.ts +0 -95
- package/src/dto/logout-all-response.dto.ts +0 -24
- package/src/dto/logout-all.dto.ts +0 -65
- package/src/dto/logout-response.dto.ts +0 -25
- package/src/dto/logout.dto.ts +0 -64
- package/src/dto/refresh-token.dto.ts +0 -36
- package/src/dto/remove-devices.dto.ts +0 -85
- package/src/dto/resend-code-response.dto.ts +0 -32
- package/src/dto/resend-code.dto.ts +0 -51
- package/src/dto/reset-password.dto.ts +0 -115
- package/src/dto/respond-challenge.dto.ts +0 -272
- package/src/dto/set-mfa-exemption.dto.ts +0 -112
- package/src/dto/set-must-change-password-response.dto.ts +0 -27
- package/src/dto/set-must-change-password.dto.ts +0 -46
- package/src/dto/set-preferred-method.dto.ts +0 -80
- package/src/dto/setup-mfa.dto.ts +0 -98
- package/src/dto/signup.dto.ts +0 -174
- package/src/dto/social-auth.dto.ts +0 -422
- package/src/dto/trust-device-response.dto.ts +0 -30
- package/src/dto/trust-device.dto.ts +0 -9
- package/src/dto/update-user-attributes-request.dto.ts +0 -51
- package/src/dto/user-response.dto.ts +0 -138
- package/src/dto/user-update.dto.ts +0 -222
- package/src/dto/verify-email.dto.ts +0 -313
- package/src/dto/verify-mfa-code.dto.ts +0 -103
- package/src/dto/verify-phone-by-sub.dto.ts +0 -78
- package/src/dto/verify-phone.dto.ts +0 -245
- package/src/entities/auth-audit.entity.ts +0 -232
- package/src/entities/challenge-session.entity.ts +0 -116
- package/src/entities/index.ts +0 -29
- package/src/entities/login-attempt.entity.ts +0 -64
- package/src/entities/mfa-device.entity.ts +0 -151
- package/src/entities/rate-limit.entity.ts +0 -44
- package/src/entities/session.entity.ts +0 -180
- package/src/entities/social-account.entity.ts +0 -96
- package/src/entities/storage-lock.entity.ts +0 -39
- package/src/entities/trusted-device.entity.ts +0 -112
- package/src/entities/user.entity.ts +0 -243
- package/src/entities/verification-token.entity.ts +0 -141
- package/src/enums/auth-audit-event-type.enum.ts +0 -360
- package/src/enums/error-codes.enum.ts +0 -420
- package/src/enums/mfa-method.enum.ts +0 -97
- package/src/enums/risk-factor.enum.ts +0 -111
- package/src/exceptions/nauth.exception.ts +0 -231
- package/src/handlers/auth.handler.ts +0 -260
- package/src/handlers/client-info.handler.ts +0 -101
- package/src/handlers/csrf.handler.ts +0 -156
- package/src/handlers/token-delivery.handler.ts +0 -118
- package/src/index.ts +0 -118
- package/src/interfaces/client-info.interface.ts +0 -85
- package/src/interfaces/config.interface.ts +0 -2135
- package/src/interfaces/entities.interface.ts +0 -226
- package/src/interfaces/index.ts +0 -15
- package/src/interfaces/logger.interface.ts +0 -283
- package/src/interfaces/mfa-provider.interface.ts +0 -154
- package/src/interfaces/oauth.interface.ts +0 -148
- package/src/interfaces/provider.interface.ts +0 -47
- package/src/interfaces/social-auth-provider.interface.ts +0 -131
- package/src/interfaces/storage-adapter.interface.ts +0 -82
- package/src/interfaces/template.interface.ts +0 -510
- package/src/interfaces/token-verifier.interface.ts +0 -110
- package/src/internal.ts +0 -178
- package/src/platform/interfaces.ts +0 -299
- package/src/schemas/auth-config.schema.ts +0 -646
- package/src/services/adaptive-mfa-decision.service.spec.ts +0 -1058
- package/src/services/adaptive-mfa-decision.service.ts +0 -457
- package/src/services/auth-audit.service.spec.ts +0 -675
- package/src/services/auth-audit.service.ts +0 -558
- package/src/services/auth-challenge-helper.service.spec.ts +0 -3227
- package/src/services/auth-challenge-helper.service.ts +0 -825
- package/src/services/auth-flow-context-builder.service.ts +0 -520
- package/src/services/auth-flow-rules.ts +0 -202
- package/src/services/auth-flow-state-definitions.ts +0 -190
- package/src/services/auth-flow-state-machine.service.ts +0 -207
- package/src/services/auth-flow-state-machine.types.ts +0 -316
- package/src/services/auth.service.spec.ts +0 -4195
- package/src/services/auth.service.ts +0 -3727
- package/src/services/challenge.service.spec.ts +0 -1363
- package/src/services/challenge.service.ts +0 -696
- package/src/services/client-info.service.spec.ts +0 -572
- package/src/services/client-info.service.ts +0 -374
- package/src/services/csrf.service.ts +0 -54
- package/src/services/email-verification.service.spec.ts +0 -1229
- package/src/services/email-verification.service.ts +0 -578
- package/src/services/geo-location.service.spec.ts +0 -603
- package/src/services/geo-location.service.ts +0 -599
- package/src/services/index.ts +0 -13
- package/src/services/jwt.service.spec.ts +0 -882
- package/src/services/jwt.service.ts +0 -621
- package/src/services/mfa-base.service.spec.ts +0 -246
- package/src/services/mfa-base.service.ts +0 -611
- package/src/services/mfa.service.spec.ts +0 -693
- package/src/services/mfa.service.ts +0 -960
- package/src/services/password.service.spec.ts +0 -166
- package/src/services/password.service.ts +0 -309
- package/src/services/phone-verification.service.spec.ts +0 -1120
- package/src/services/phone-verification.service.ts +0 -751
- package/src/services/risk-detection.service.spec.ts +0 -1292
- package/src/services/risk-detection.service.ts +0 -1012
- package/src/services/risk-scoring.service.spec.ts +0 -204
- package/src/services/risk-scoring.service.ts +0 -131
- package/src/services/session.service.spec.ts +0 -1293
- package/src/services/session.service.ts +0 -803
- package/src/services/social-account.service.spec.ts +0 -725
- package/src/services/social-auth-base.service.spec.ts +0 -418
- package/src/services/social-auth-base.service.ts +0 -581
- package/src/services/social-auth.service.spec.ts +0 -238
- package/src/services/social-auth.service.ts +0 -436
- package/src/services/social-provider-registry.service.spec.ts +0 -238
- package/src/services/social-provider-registry.service.ts +0 -122
- package/src/services/trusted-device.service.spec.ts +0 -505
- package/src/services/trusted-device.service.ts +0 -339
- package/src/storage/account-lockout-storage.service.spec.ts +0 -310
- package/src/storage/account-lockout-storage.service.ts +0 -89
- package/src/storage/index.ts +0 -3
- package/src/storage/memory-storage.adapter.ts +0 -443
- package/src/storage/rate-limit-storage.service.spec.ts +0 -247
- package/src/storage/rate-limit-storage.service.ts +0 -38
- package/src/templates/html-template.engine.spec.ts +0 -161
- package/src/templates/html-template.engine.ts +0 -688
- package/src/templates/index.ts +0 -7
- package/src/utils/common-passwords.spec.ts +0 -230
- package/src/utils/common-passwords.ts +0 -170
- package/src/utils/context-storage.ts +0 -188
- package/src/utils/cookie-names.util.ts +0 -67
- package/src/utils/cookies.util.ts +0 -94
- package/src/utils/index.ts +0 -12
- package/src/utils/ip-extractor.spec.ts +0 -330
- package/src/utils/ip-extractor.ts +0 -220
- package/src/utils/nauth-logger.spec.ts +0 -388
- package/src/utils/nauth-logger.ts +0 -215
- package/src/utils/pii-redactor.spec.ts +0 -130
- package/src/utils/pii-redactor.ts +0 -288
- package/src/utils/setup/get-repositories.ts +0 -140
- package/src/utils/setup/init-services.ts +0 -422
- package/src/utils/setup/init-social.ts +0 -189
- package/src/utils/setup/init-storage.ts +0 -94
- package/src/utils/setup/register-mfa.ts +0 -165
- package/src/utils/setup/run-nauth-migrations.ts +0 -61
- package/src/utils/token-delivery-policy.ts +0 -38
- package/src/validators/template.validator.ts +0 -219
- package/tsconfig.json +0 -37
- 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
|
-
}
|