@nauth-toolkit/core 0.1.0 → 0.1.5

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 +9 -0
  3. package/package.json +8 -3
  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,3227 +0,0 @@
1
- import { AuthChallengeHelperService } from './auth-challenge-helper.service';
2
- import { ChallengeService } from './challenge.service';
3
- import { JwtService } from './jwt.service';
4
- import { SessionService } from './session.service';
5
- import { EmailVerificationService } from './email-verification.service';
6
- import { PhoneVerificationService } from './phone-verification.service';
7
- import { TrustedDeviceService } from './trusted-device.service';
8
- import { AuthAuditService } from './auth-audit.service';
9
- import { ClientInfoService } from './client-info.service';
10
- import { AdaptiveMFADecisionService } from './adaptive-mfa-decision.service';
11
- import { AuthFlowStateMachineService } from './auth-flow-state-machine.service';
12
- import { AuthFlowContextBuilder } from './auth-flow-context-builder.service';
13
- import { IUser, IMFADevice, IChallengeSession } from '../interfaces/entities.interface';
14
- import { AuthChallenge } from '../dto/auth-challenge.dto';
15
- import { NAuthConfig } from '../interfaces/config.interface';
16
- import { NAuthLogger } from '../utils/nauth-logger';
17
- import { NAuthException } from '../exceptions/nauth.exception';
18
- import { MFAMethod, MFADeviceMethod, MFADeviceMethods } from '../enums/mfa-method.enum';
19
- import { AuthFlowState, AuthFlowContext, ResponseMetadata } from './auth-flow-state-machine.types';
20
- import { AuthErrorCode } from '../enums/error-codes.enum';
21
-
22
- /**
23
- * Helper function to create mock challenge session
24
- */
25
- function createMockChallengeSession(sessionToken: string, challengeName: AuthChallenge): IChallengeSession {
26
- return {
27
- id: 1,
28
- userId: 1,
29
- sessionToken,
30
- challengeName,
31
- challengeParameters: {},
32
- attempts: 0,
33
- maxAttempts: 3,
34
- expiresAt: new Date(),
35
- ipAddress: '1.2.3.4',
36
- userAgent: 'test-agent',
37
- createdAt: new Date(),
38
- };
39
- }
40
-
41
- /**
42
- * Auth Challenge Helper Service Unit Tests
43
- *
44
- * Tests challenge-response authentication flow orchestration.
45
- * Covers all challenge types, MFA requirements, and response creation.
46
- *
47
- * Platform-agnostic: Uses direct instantiation, no NestJS dependencies.
48
- */
49
- describe('AuthChallengeHelperService', () => {
50
- let service: AuthChallengeHelperService;
51
- let mockChallengeService: jest.Mocked<ChallengeService>;
52
- let mockJwtService: jest.Mocked<JwtService>;
53
- let mockSessionService: jest.Mocked<SessionService>;
54
- let mockEmailVerificationService: jest.Mocked<EmailVerificationService>;
55
- let mockPhoneVerificationService: jest.Mocked<PhoneVerificationService>;
56
- let mockTrustedDeviceService: jest.Mocked<TrustedDeviceService>;
57
- let mockAuditService: jest.Mocked<AuthAuditService>;
58
- let mockClientInfoService: jest.Mocked<ClientInfoService>;
59
- let mockAdaptiveMFADecisionService: jest.Mocked<AdaptiveMFADecisionService>;
60
- let mockStateMachine: jest.Mocked<AuthFlowStateMachineService>;
61
- let mockContextBuilder: jest.Mocked<AuthFlowContextBuilder>;
62
- let mockMFADeviceRepository: any;
63
- let mockLogger: jest.Mocked<NAuthLogger>;
64
- let mockConfig: NAuthConfig;
65
-
66
- const mockUser: Partial<IUser> = {
67
- id: 1,
68
- sub: 'user-uuid-123',
69
- email: 'test@example.com',
70
- phone: '+1234567890',
71
- firstName: 'John',
72
- lastName: 'Doe',
73
- isEmailVerified: false,
74
- isPhoneVerified: false,
75
- isActive: true,
76
- mustChangePassword: false,
77
- mfaEnabled: false,
78
- mfaExempt: false,
79
- createdAt: new Date('2024-01-01'),
80
- };
81
-
82
- beforeEach(() => {
83
- // Create mock services
84
- mockChallengeService = {
85
- createChallengeSession: jest.fn(),
86
- maskEmail: jest.fn((email: string) => `${email[0]}***@example.com`),
87
- maskPhone: jest.fn((phone: string) => '***-***-7890'),
88
- } as any;
89
-
90
- mockJwtService = {
91
- generateTokenPair: jest.fn(),
92
- hashToken: jest.fn((token: string) => `hash-${token}`),
93
- generateTokenFamily: jest.fn(() => 'family-xyz'),
94
- validateAccessToken: jest.fn(),
95
- validateRefreshToken: jest.fn(),
96
- } as any;
97
-
98
- mockSessionService = {
99
- createSession: jest.fn(),
100
- updateTokens: jest.fn(),
101
- revokeAllUserSessions: jest.fn(),
102
- } as any;
103
-
104
- mockEmailVerificationService = {
105
- sendVerificationEmail: jest.fn(),
106
- } as any;
107
-
108
- mockPhoneVerificationService = {
109
- sendVerificationSMS: jest.fn(),
110
- } as any;
111
-
112
- mockTrustedDeviceService = {
113
- validateDeviceToken: jest.fn(),
114
- isDeviceTrusted: jest.fn(),
115
- } as any;
116
-
117
- mockAuditService = {
118
- recordEvent: jest.fn(),
119
- } as any;
120
-
121
- mockClientInfoService = {
122
- get: jest.fn(),
123
- } as any;
124
-
125
- mockAdaptiveMFADecisionService = {
126
- evaluateAdaptiveMFA: jest.fn(),
127
- isUserBlocked: jest.fn(),
128
- clearUserBlock: jest.fn(),
129
- blockUserSignIn: jest.fn(),
130
- } as any;
131
-
132
- mockContextBuilder = {
133
- build: jest.fn(),
134
- } as any;
135
-
136
- mockStateMachine = {
137
- evaluateState: jest.fn(),
138
- getStateDefinition: jest.fn(),
139
- buildMetadata: jest.fn(),
140
- } as any;
141
-
142
- mockMFADeviceRepository = {
143
- find: jest.fn(),
144
- findOne: jest.fn(),
145
- save: jest.fn(),
146
- create: jest.fn(),
147
- };
148
-
149
- mockLogger = {
150
- log: jest.fn(),
151
- warn: jest.fn(),
152
- error: jest.fn(),
153
- debug: jest.fn(),
154
- } as any;
155
-
156
- mockConfig = {
157
- jwt: {
158
- accessToken: { secret: 'test-secret', expiresIn: '15m' },
159
- refreshToken: { secret: 'test-refresh-secret', expiresIn: '30d' },
160
- },
161
- signup: {
162
- verificationMethod: 'email',
163
- },
164
- };
165
-
166
- // Instantiate service directly
167
- service = new AuthChallengeHelperService(
168
- mockChallengeService,
169
- mockJwtService,
170
- mockSessionService,
171
- mockMFADeviceRepository,
172
- mockLogger,
173
- mockStateMachine,
174
- mockContextBuilder,
175
- mockClientInfoService,
176
- mockEmailVerificationService,
177
- mockPhoneVerificationService,
178
- );
179
- });
180
-
181
- afterEach(() => {
182
- jest.clearAllMocks();
183
- });
184
-
185
- // ============================================================================
186
- // Service Initialization
187
- // ============================================================================
188
-
189
- it('should be defined', () => {
190
- expect(service).toBeDefined();
191
- });
192
-
193
- // ============================================================================
194
- // OLD TESTS - Methods deleted, replaced by state machine
195
- // ============================================================================
196
- // These test suites are commented out because the methods have been deleted
197
- // and replaced by the state machine architecture. New tests should be written
198
- // for determineAuthResponse() which uses the state machine.
199
-
200
- // ============================================================================
201
- // determinePendingChallenges() Method - DELETED
202
- // ============================================================================
203
- // This method has been replaced by the state machine in determineAuthResponse()
204
- // All scenarios are now covered by comprehensive scenario tests below
205
- // Old tests removed - see "determineAuthResponse - Comprehensive Scenarios" section
206
-
207
- // ============================================================================
208
- // isMFASetupRequired() Method - DELETED
209
- // ============================================================================
210
- // This method has been replaced by the state machine in determineAuthResponse()
211
- // All scenarios are now covered by comprehensive scenario tests below
212
- // Old tests removed - see "determineAuthResponse - Comprehensive Scenarios" section
213
-
214
- // ============================================================================
215
- // checkMFARequirement() Method - DELETED
216
- // ============================================================================
217
- // This method has been replaced by the state machine in determineAuthResponse()
218
- // All scenarios are now covered by comprehensive scenario tests below
219
- // Old tests removed - see "determineAuthResponse - Comprehensive Scenarios" section
220
-
221
- // ============================================================================
222
- // createChallengeResponse() Method
223
- // ============================================================================
224
-
225
- describe('createChallengeResponse', () => {
226
- beforeEach(() => {
227
- // Setup ClientInfoService mock for all createChallengeResponse tests
228
- mockClientInfoService.get.mockReturnValue({
229
- ipAddress: '1.2.3.4',
230
- userAgent: 'test-agent',
231
- deviceToken: undefined,
232
- } as any);
233
- });
234
- it('should create challenge response for VERIFY_EMAIL and send email', async () => {
235
- const mockChallengeSession = {
236
- id: 1,
237
- userId: 1,
238
- sessionToken: 'session-token-123',
239
- challengeName: AuthChallenge.VERIFY_EMAIL,
240
- challengeParameters: {},
241
- attempts: 0,
242
- maxAttempts: 3,
243
- expiresAt: new Date(),
244
- ipAddress: '1.2.3.4',
245
- userAgent: 'test-agent',
246
- createdAt: new Date(),
247
- };
248
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
249
- mockEmailVerificationService.sendVerificationEmail.mockResolvedValue(1);
250
-
251
- const result = await service.createChallengeResponse(mockUser as IUser, AuthChallenge.VERIFY_EMAIL, mockConfig);
252
-
253
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_EMAIL);
254
- expect(result.session).toBe('session-token-123');
255
- expect(result.challengeParameters?.email).toBe('test@example.com');
256
- expect(result.challengeParameters?.codeDeliveryDestination).toBeDefined();
257
- expect(result.userSub).toBe('user-uuid-123');
258
- expect(mockEmailVerificationService.sendVerificationEmail).toHaveBeenCalledWith('user-uuid-123', undefined);
259
- });
260
-
261
- it('should create challenge response for VERIFY_PHONE and send SMS', async () => {
262
- const mockChallengeSession = {
263
- id: 1,
264
- userId: 1,
265
- sessionToken: 'session-token-456',
266
- challengeName: AuthChallenge.VERIFY_PHONE,
267
- challengeParameters: {},
268
- attempts: 0,
269
- maxAttempts: 3,
270
- expiresAt: new Date(),
271
- ipAddress: '1.2.3.4',
272
- userAgent: 'test-agent',
273
- createdAt: new Date(),
274
- };
275
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
276
- mockPhoneVerificationService.sendVerificationSMS.mockResolvedValue(123456);
277
-
278
- const result = await service.createChallengeResponse(mockUser as IUser, AuthChallenge.VERIFY_PHONE, mockConfig);
279
-
280
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_PHONE);
281
- expect(result.challengeParameters?.phone).toBe('+1234567890');
282
- expect(result.challengeParameters?.codeDeliveryDestination).toBeDefined();
283
- expect(mockPhoneVerificationService.sendVerificationSMS).toHaveBeenCalledWith('user-uuid-123');
284
- });
285
-
286
- it('should handle VERIFY_PHONE when phone is not provided', async () => {
287
- const userWithoutPhone = { ...mockUser, phone: null } as IUser;
288
- const mockChallengeSession = createMockChallengeSession('session-token-789', AuthChallenge.VERIFY_PHONE);
289
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
290
-
291
- const result = await service.createChallengeResponse(userWithoutPhone, AuthChallenge.VERIFY_PHONE, mockConfig);
292
-
293
- expect(result.challengeParameters?.requiresPhoneCollection).toBe('true');
294
- expect(result.challengeParameters?.instructions).toBeDefined();
295
- expect(mockPhoneVerificationService.sendVerificationSMS).not.toHaveBeenCalled();
296
- });
297
-
298
- // VERIFY_EMAIL_AND_PHONE removed - challenges are sequential (VERIFY_EMAIL first, then VERIFY_PHONE)
299
- // This test is no longer needed as the challenge system works sequentially
300
-
301
- it('should create challenge response for FORCE_CHANGE_PASSWORD', async () => {
302
- const mockChallengeSession = createMockChallengeSession(
303
- 'session-token-forced',
304
- AuthChallenge.FORCE_CHANGE_PASSWORD,
305
- );
306
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
307
-
308
- const result = await service.createChallengeResponse(
309
- mockUser as IUser,
310
- AuthChallenge.FORCE_CHANGE_PASSWORD,
311
- mockConfig,
312
- );
313
-
314
- expect(result.challengeName).toBe(AuthChallenge.FORCE_CHANGE_PASSWORD);
315
- expect(result.challengeParameters?.instructions).toBe('You must change your password before continuing');
316
- expect(result.session).toBe('session-token-forced');
317
- });
318
-
319
- it('should create challenge response for MFA_REQUIRED', async () => {
320
- const mockChallengeSession = createMockChallengeSession('session-token-mfa', AuthChallenge.MFA_REQUIRED);
321
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
322
-
323
- const result = await service.createChallengeResponse(mockUser as IUser, AuthChallenge.MFA_REQUIRED, mockConfig);
324
-
325
- expect(result.challengeName).toBe(AuthChallenge.MFA_REQUIRED);
326
- expect(result.challengeParameters?.instructions).toBe('Multi-factor authentication is required');
327
- });
328
-
329
- it('should create challenge response for MFA_SETUP_REQUIRED with allowedMethods', async () => {
330
- const mockChallengeSession = createMockChallengeSession('session-token-setup', AuthChallenge.MFA_SETUP_REQUIRED);
331
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
332
- const configWithMFA: NAuthConfig = {
333
- ...mockConfig,
334
- mfa: {
335
- enabled: true,
336
- allowedMethods: [MFAMethod.TOTP, MFAMethod.SMS] as MFADeviceMethod[],
337
- },
338
- };
339
-
340
- const result = await service.createChallengeResponse(
341
- mockUser as IUser,
342
- AuthChallenge.MFA_SETUP_REQUIRED,
343
- configWithMFA,
344
- );
345
-
346
- expect(result.challengeName).toBe(AuthChallenge.MFA_SETUP_REQUIRED);
347
- expect(result.challengeParameters?.allowedMethods).toEqual([MFAMethod.TOTP, MFAMethod.SMS]);
348
- expect(result.challengeParameters?.instructions).toBe(
349
- 'Multi-factor authentication setup is required before you can login',
350
- );
351
- });
352
-
353
- it('should use default allowedMethods when not specified', async () => {
354
- const mockChallengeSession = createMockChallengeSession('session-token-setup', AuthChallenge.MFA_SETUP_REQUIRED);
355
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
356
- const configWithMFA = {
357
- ...mockConfig,
358
- mfa: {
359
- enabled: true,
360
- },
361
- };
362
-
363
- const result = await service.createChallengeResponse(
364
- mockUser as IUser,
365
- AuthChallenge.MFA_SETUP_REQUIRED,
366
- configWithMFA,
367
- );
368
-
369
- expect(result.challengeParameters?.allowedMethods).toEqual([...MFADeviceMethods]);
370
- });
371
-
372
- it('should handle email verification service errors gracefully', async () => {
373
- const mockChallengeSession = createMockChallengeSession('session-token-123', AuthChallenge.VERIFY_EMAIL);
374
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
375
- mockEmailVerificationService.sendVerificationEmail.mockRejectedValue(new Error('Email service error'));
376
-
377
- // Should not throw - fire and forget
378
- const result = await service.createChallengeResponse(mockUser as IUser, AuthChallenge.VERIFY_EMAIL, mockConfig);
379
-
380
- expect(result).toBeDefined();
381
- // Wait for promise to resolve
382
- await new Promise((resolve) => setTimeout(resolve, 10));
383
- expect(mockLogger.error).toHaveBeenCalled();
384
- });
385
-
386
- it('should handle phone verification service errors gracefully', async () => {
387
- const mockChallengeSession = createMockChallengeSession('session-token-456', AuthChallenge.VERIFY_PHONE);
388
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
389
- mockPhoneVerificationService.sendVerificationSMS.mockRejectedValue(new Error('SMS service error'));
390
-
391
- // Should not throw - fire and forget
392
- const result = await service.createChallengeResponse(mockUser as IUser, AuthChallenge.VERIFY_PHONE, mockConfig);
393
-
394
- expect(result).toBeDefined();
395
- // Wait for promise to resolve
396
- await new Promise((resolve) => setTimeout(resolve, 10));
397
- expect(mockLogger.error).toHaveBeenCalled();
398
- });
399
- });
400
-
401
- // ============================================================================
402
- // checkMFARequirement() Method - DELETED
403
- // ============================================================================
404
- // This method has been replaced by the state machine in determineAuthResponse()
405
- // All scenarios are now covered by comprehensive scenario tests below
406
- // Old tests removed - see "determineAuthResponse - Comprehensive Scenarios" section
407
-
408
- // ============================================================================
409
- // createMFASetupChallengeResponse() Method
410
- // ============================================================================
411
-
412
- describe('createMFASetupChallengeResponse', () => {
413
- beforeEach(() => {
414
- mockClientInfoService.get.mockReturnValue({
415
- ipAddress: '1.2.3.4',
416
- userAgent: 'test-agent',
417
- deviceToken: undefined,
418
- } as any);
419
- });
420
- it('should create MFA setup challenge response', async () => {
421
- const mockChallengeSession = createMockChallengeSession('session-token-setup', AuthChallenge.MFA_SETUP_REQUIRED);
422
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
423
- const config: NAuthConfig = {
424
- ...mockConfig,
425
- mfa: {
426
- enabled: true,
427
- allowedMethods: [MFAMethod.TOTP] as MFADeviceMethod[],
428
- },
429
- };
430
-
431
- const result = await service.createMFASetupChallengeResponse(mockUser as IUser, config);
432
-
433
- expect(result.challengeName).toBe(AuthChallenge.MFA_SETUP_REQUIRED);
434
- expect(result.session).toBe('session-token-setup');
435
- expect(result.challengeParameters?.allowedMethods).toEqual([MFAMethod.TOTP]);
436
- expect(result.challengeParameters?.instructions).toBeDefined();
437
- expect(result.userSub).toBe('user-uuid-123');
438
- });
439
-
440
- it('should use default allowedMethods when not specified', async () => {
441
- const mockChallengeSession = createMockChallengeSession('session-token-setup', AuthChallenge.MFA_SETUP_REQUIRED);
442
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
443
- const config = {
444
- ...mockConfig,
445
- mfa: {
446
- enabled: true,
447
- },
448
- };
449
-
450
- const result = await service.createMFASetupChallengeResponse(mockUser as IUser, config);
451
-
452
- expect(result.challengeParameters?.allowedMethods).toEqual([...MFADeviceMethods]);
453
- });
454
- });
455
-
456
- // ============================================================================
457
- // createMFAChallengeResponse() Method
458
- // ============================================================================
459
-
460
- describe('createMFAChallengeResponse', () => {
461
- beforeEach(() => {
462
- mockClientInfoService.get.mockReturnValue({
463
- ipAddress: '1.2.3.4',
464
- userAgent: 'test-agent',
465
- deviceToken: undefined,
466
- } as any);
467
- });
468
- it('should create MFA setup challenge response', async () => {
469
- const mockChallengeSession = createMockChallengeSession('session-token-setup', AuthChallenge.MFA_SETUP_REQUIRED);
470
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
471
- const config: NAuthConfig = {
472
- ...mockConfig,
473
- mfa: {
474
- enabled: true,
475
- allowedMethods: [MFAMethod.TOTP] as MFADeviceMethod[],
476
- },
477
- };
478
-
479
- const result = await service.createMFASetupChallengeResponse(mockUser as IUser, config);
480
-
481
- expect(result.challengeName).toBe(AuthChallenge.MFA_SETUP_REQUIRED);
482
- expect(result.session).toBe('session-token-setup');
483
- expect(result.challengeParameters?.allowedMethods).toEqual([MFAMethod.TOTP]);
484
- expect(result.challengeParameters?.instructions).toBeDefined();
485
- expect(result.userSub).toBe('user-uuid-123');
486
- });
487
-
488
- it('should use default allowedMethods when not specified', async () => {
489
- const mockChallengeSession = createMockChallengeSession('session-token-setup', AuthChallenge.MFA_SETUP_REQUIRED);
490
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
491
- const config = {
492
- ...mockConfig,
493
- mfa: {
494
- enabled: true,
495
- },
496
- };
497
-
498
- const result = await service.createMFASetupChallengeResponse(mockUser as IUser, config);
499
-
500
- expect(result.challengeParameters?.allowedMethods).toEqual([...MFADeviceMethods]);
501
- });
502
- });
503
-
504
- // ============================================================================
505
- // createMFAChallengeResponse() Method
506
- // ============================================================================
507
-
508
- describe('createMFAChallengeResponse', () => {
509
- beforeEach(() => {
510
- mockClientInfoService.get.mockReturnValue({
511
- ipAddress: '1.2.3.4',
512
- userAgent: 'test-agent',
513
- deviceToken: undefined,
514
- } as any);
515
- });
516
- it('should create MFA challenge response with available devices', async () => {
517
- const mockDevices: IMFADevice[] = [
518
- {
519
- id: 1,
520
- userId: 1,
521
- type: MFAMethod.TOTP,
522
- isActive: true,
523
- isPrimary: true,
524
- name: 'Authenticator',
525
- } as IMFADevice,
526
- {
527
- id: 2,
528
- userId: 1,
529
- type: MFAMethod.SMS,
530
- isActive: true,
531
- isPrimary: false,
532
- phoneNumber: '+1234567890',
533
- } as IMFADevice,
534
- ];
535
- mockMFADeviceRepository.find.mockResolvedValue(mockDevices);
536
- const user = { ...mockUser, mfaEnabled: true, backupCodes: ['code1', 'code2'] } as IUser;
537
- const mockChallengeSession = createMockChallengeSession('session-token-mfa', AuthChallenge.MFA_REQUIRED);
538
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
539
-
540
- const result = await service.createMFAChallengeResponse(user);
541
-
542
- expect(result.challengeName).toBe(AuthChallenge.MFA_REQUIRED);
543
- expect(result.session).toBe('session-token-mfa');
544
- expect(result.challengeParameters?.availableMethods).toContain(MFAMethod.TOTP);
545
- expect(result.challengeParameters?.availableMethods).toContain(MFAMethod.SMS);
546
- expect(result.challengeParameters?.availableMethods).toContain(MFAMethod.BACKUP);
547
- expect(result.challengeParameters?.preferredMethod).toBe(MFAMethod.TOTP); // Primary device
548
- });
549
-
550
- it('should use preferredMfaMethod when set', async () => {
551
- const mockDevices: IMFADevice[] = [
552
- {
553
- id: 1,
554
- userId: 1,
555
- type: MFAMethod.TOTP,
556
- isActive: true,
557
- isPrimary: true,
558
- } as IMFADevice,
559
- {
560
- id: 2,
561
- userId: 1,
562
- type: MFAMethod.SMS,
563
- isActive: true,
564
- isPrimary: false,
565
- phoneNumber: '+1234567890',
566
- } as IMFADevice,
567
- ];
568
- mockMFADeviceRepository.find.mockResolvedValue(mockDevices);
569
- const user = {
570
- ...mockUser,
571
- mfaEnabled: true,
572
- preferredMfaMethod: MFAMethod.SMS,
573
- } as IUser;
574
- const mockChallengeSession = createMockChallengeSession('session-token-mfa', AuthChallenge.MFA_REQUIRED);
575
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
576
-
577
- const result = await service.createMFAChallengeResponse(user);
578
-
579
- expect(result.challengeParameters?.preferredMethod).toBe(MFAMethod.SMS);
580
- });
581
-
582
- it('should throw when user has no MFA devices', async () => {
583
- mockMFADeviceRepository.find.mockResolvedValue([]);
584
- const user = { ...mockUser, mfaEnabled: true } as IUser;
585
-
586
- try {
587
- await service.createMFAChallengeResponse(user);
588
- fail('Should have thrown NAuthException');
589
- } catch (error) {
590
- expect(error).toBeInstanceOf(NAuthException);
591
- }
592
- expect(mockLogger.warn).toHaveBeenCalled();
593
- });
594
-
595
- it('should include masked phone when SMS device available', async () => {
596
- const mockDevices: IMFADevice[] = [
597
- {
598
- id: 2,
599
- userId: 1,
600
- type: MFAMethod.SMS,
601
- isActive: true,
602
- isPrimary: true,
603
- phoneNumber: '+1234567890',
604
- } as IMFADevice,
605
- ];
606
- mockMFADeviceRepository.find.mockResolvedValue(mockDevices);
607
- const user = { ...mockUser, mfaEnabled: true } as IUser;
608
- const mockChallengeSession = createMockChallengeSession('session-token-mfa', AuthChallenge.MFA_REQUIRED);
609
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
610
-
611
- const result = await service.createMFAChallengeResponse(user);
612
-
613
- expect(result.challengeParameters?.maskedPhone).toBeDefined();
614
- expect(result.challengeParameters?.maskedPhone).toContain('7890');
615
- });
616
-
617
- it('should not include backup codes when user has none', async () => {
618
- const mockDevices: IMFADevice[] = [
619
- {
620
- id: 1,
621
- userId: 1,
622
- type: MFAMethod.TOTP,
623
- isActive: true,
624
- isPrimary: true,
625
- } as IMFADevice,
626
- ];
627
- mockMFADeviceRepository.find.mockResolvedValue(mockDevices);
628
- const user = { ...mockUser, mfaEnabled: true, backupCodes: null } as IUser;
629
- const mockChallengeSession = createMockChallengeSession('session-token-mfa', AuthChallenge.MFA_REQUIRED);
630
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
631
-
632
- const result = await service.createMFAChallengeResponse(user);
633
-
634
- expect(result.challengeParameters?.availableMethods).not.toContain(MFAMethod.BACKUP);
635
- });
636
- });
637
-
638
- // ============================================================================
639
- // createSuccessResponse() Method
640
- // ============================================================================
641
-
642
- describe('createSuccessResponse', () => {
643
- beforeEach(() => {
644
- mockClientInfoService.get.mockReturnValue({
645
- ipAddress: '1.2.3.4',
646
- userAgent: 'test-agent',
647
- deviceToken: undefined,
648
- } as any);
649
- });
650
- it('should create success response with tokens', async () => {
651
- const verifiedUser = { ...mockUser, isEmailVerified: true, isPhoneVerified: true } as IUser;
652
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
653
- mockJwtService.generateTokenPair
654
- .mockResolvedValueOnce({
655
- accessToken: 'temp-access-token',
656
- refreshToken: 'temp-refresh-token',
657
- expiresIn: 900,
658
- })
659
- .mockResolvedValueOnce({
660
- accessToken: 'access-token',
661
- refreshToken: 'refresh-token',
662
- expiresIn: 900,
663
- });
664
- mockJwtService.hashToken.mockReturnValue('token-hash');
665
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
666
- mockJwtService.validateAccessToken.mockResolvedValue({
667
- valid: true,
668
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
669
- });
670
- mockJwtService.validateRefreshToken.mockResolvedValue({
671
- valid: true,
672
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
673
- });
674
- mockSessionService.updateTokens.mockResolvedValue(undefined);
675
-
676
- const result = await service.createSuccessResponse(verifiedUser);
677
-
678
- expect(result.accessToken).toBe('access-token');
679
- expect(result.refreshToken).toBe('refresh-token');
680
- expect(result.user).toBeDefined();
681
- expect(result.user?.sub).toBe('user-uuid-123');
682
- expect(result.user?.email).toBe('test@example.com');
683
- expect(result.challengeName).toBeUndefined();
684
- expect(mockSessionService.createSession).toHaveBeenCalled();
685
- expect(mockSessionService.updateTokens).toHaveBeenCalled();
686
- });
687
-
688
- it('should not throw when user has pending challenges (validation handled by state machine)', async () => {
689
- // NOTE: createSuccessResponse no longer validates challenges
690
- // Challenge validation is handled by state machine in determineAuthResponse()
691
- // This method is only called when state is AUTHENTICATED, so no validation needed
692
- const userWithPending = {
693
- ...mockUser,
694
- isEmailVerified: false,
695
- isPhoneVerified: false,
696
- } as IUser;
697
- const serviceWithConfig = new AuthChallengeHelperService(
698
- mockChallengeService,
699
- mockJwtService,
700
- mockSessionService,
701
- mockMFADeviceRepository,
702
- mockLogger,
703
- mockStateMachine,
704
- mockContextBuilder,
705
- mockClientInfoService,
706
- );
707
-
708
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
709
- mockJwtService.generateTokenPair
710
- .mockResolvedValueOnce({
711
- accessToken: 'temp-access',
712
- refreshToken: 'temp-refresh',
713
- expiresIn: 900,
714
- })
715
- .mockResolvedValueOnce({
716
- accessToken: 'access-token',
717
- refreshToken: 'refresh-token',
718
- expiresIn: 900,
719
- });
720
- mockJwtService.hashToken.mockReturnValue('token-hash');
721
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
722
- mockJwtService.validateAccessToken.mockResolvedValue({
723
- valid: true,
724
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
725
- });
726
- mockJwtService.validateRefreshToken.mockResolvedValue({
727
- valid: true,
728
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
729
- });
730
-
731
- // Should not throw - validation is handled by state machine, not this method
732
- const result = await serviceWithConfig.createSuccessResponse(userWithPending);
733
- expect(result.accessToken).toBe('access-token');
734
- });
735
-
736
- it('should use clientInfo when ipAddress/userAgent not provided', async () => {
737
- const verifiedUser = { ...mockUser, isEmailVerified: true } as IUser;
738
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
739
- mockJwtService.generateTokenPair
740
- .mockResolvedValueOnce({
741
- accessToken: 'temp-access',
742
- refreshToken: 'temp-refresh',
743
- expiresIn: 900,
744
- })
745
- .mockResolvedValueOnce({
746
- accessToken: 'access-token',
747
- refreshToken: 'refresh-token',
748
- expiresIn: 900,
749
- });
750
- mockJwtService.hashToken.mockReturnValue('token-hash');
751
- mockClientInfoService.get.mockReturnValue({
752
- ipAddress: 'client-ip',
753
- userAgent: 'client-agent',
754
- ipCountry: 'US',
755
- ipCity: 'New York',
756
- });
757
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
758
- mockJwtService.validateAccessToken.mockResolvedValue({
759
- valid: true,
760
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
761
- });
762
- mockJwtService.validateRefreshToken.mockResolvedValue({
763
- valid: true,
764
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
765
- });
766
-
767
- const serviceWithClientInfo = new AuthChallengeHelperService(
768
- mockChallengeService,
769
- mockJwtService,
770
- mockSessionService,
771
- mockMFADeviceRepository,
772
- mockLogger,
773
- mockStateMachine,
774
- mockContextBuilder,
775
- mockClientInfoService,
776
- );
777
-
778
- await serviceWithClientInfo.createSuccessResponse(verifiedUser);
779
-
780
- // Client info is automatically extracted from ClientInfoService, so we verify the call was made
781
- // The actual ipAddress/userAgent come from the mockClientInfoService.get() call
782
- expect(mockSessionService.createSession).toHaveBeenCalled();
783
- });
784
-
785
- it('should generate deviceId when not provided', async () => {
786
- const verifiedUser = { ...mockUser, isEmailVerified: true } as IUser;
787
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
788
- mockJwtService.generateTokenPair
789
- .mockResolvedValueOnce({
790
- accessToken: 'temp-access',
791
- refreshToken: 'temp-refresh',
792
- expiresIn: 900,
793
- })
794
- .mockResolvedValueOnce({
795
- accessToken: 'access-token',
796
- refreshToken: 'refresh-token',
797
- expiresIn: 900,
798
- });
799
- mockJwtService.hashToken.mockReturnValue('token-hash');
800
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
801
- mockJwtService.validateAccessToken.mockResolvedValue({
802
- valid: true,
803
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
804
- });
805
- mockJwtService.validateRefreshToken.mockResolvedValue({
806
- valid: true,
807
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
808
- });
809
-
810
- await service.createSuccessResponse(verifiedUser);
811
-
812
- expect(mockSessionService.createSession).toHaveBeenCalledWith(
813
- (expect as any).objectContaining({
814
- deviceId: (expect as any).any(String),
815
- }),
816
- );
817
- });
818
-
819
- it('should include trusted flag when provided', async () => {
820
- const verifiedUser = { ...mockUser, isEmailVerified: true } as IUser;
821
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
822
- mockJwtService.generateTokenPair
823
- .mockResolvedValueOnce({
824
- accessToken: 'temp-access',
825
- refreshToken: 'temp-refresh',
826
- expiresIn: 900,
827
- })
828
- .mockResolvedValueOnce({
829
- accessToken: 'access-token',
830
- refreshToken: 'refresh-token',
831
- expiresIn: 900,
832
- });
833
- mockJwtService.hashToken.mockReturnValue('token-hash');
834
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
835
- mockJwtService.validateAccessToken.mockResolvedValue({
836
- valid: true,
837
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
838
- });
839
- mockJwtService.validateRefreshToken.mockResolvedValue({
840
- valid: true,
841
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
842
- });
843
-
844
- const result = await service.createSuccessResponse(verifiedUser, undefined, true);
845
-
846
- expect(result.trusted).toBe(true);
847
- });
848
- });
849
-
850
- // ============================================================================
851
- // determineAuthResponse() Method
852
- // ============================================================================
853
-
854
- describe('determineAuthResponse', () => {
855
- beforeEach(() => {
856
- // Reset mocks before each test
857
- jest.clearAllMocks();
858
- mockClientInfoService.get.mockReturnValue({
859
- ipAddress: '1.2.3.4',
860
- userAgent: 'test-agent',
861
- deviceToken: undefined,
862
- } as any);
863
- mockEmailVerificationService.sendVerificationEmail.mockResolvedValue(1);
864
- mockPhoneVerificationService.sendVerificationSMS.mockResolvedValue(123456);
865
- });
866
-
867
- it('should return challenge when verification pending', async () => {
868
- const user = { ...mockUser, isEmailVerified: false } as IUser;
869
- const config = { ...mockConfig, signup: { verificationMethod: 'email' as const } };
870
- const serviceWithConfig = new AuthChallengeHelperService(
871
- mockChallengeService,
872
- mockJwtService,
873
- mockSessionService,
874
- mockMFADeviceRepository,
875
- mockLogger,
876
- mockStateMachine,
877
- mockContextBuilder,
878
- mockClientInfoService,
879
- mockEmailVerificationService,
880
- );
881
-
882
- // Mock context builder
883
- mockContextBuilder.build.mockResolvedValue({
884
- user,
885
- config,
886
- authMethod: 'password',
887
- computed: {
888
- isEmailVerificationRequired: true,
889
- isPhoneVerificationRequired: false,
890
- isPhoneCollectionNeeded: false,
891
- isMFAExempt: false,
892
- isMFASetupRequired: false,
893
- isMFAVerificationRequired: false,
894
- isDeviceTrusted: false,
895
- isGracePeriodActive: false,
896
- riskScore: 0,
897
- riskLevel: 'low',
898
- isBlocked: false,
899
- },
900
- } as AuthFlowContext);
901
-
902
- // Mock state machine
903
- mockStateMachine.evaluateState.mockResolvedValue(AuthFlowState.PENDING_EMAIL_VERIFICATION);
904
- mockStateMachine.getStateDefinition.mockReturnValue({
905
- state: AuthFlowState.PENDING_EMAIL_VERIFICATION,
906
- priority: 2,
907
- condition: () => true,
908
- challenge: AuthChallenge.VERIFY_EMAIL,
909
- });
910
- mockStateMachine.buildMetadata.mockReturnValue({});
911
-
912
- const mockChallengeSession = createMockChallengeSession('session-token-123', AuthChallenge.VERIFY_EMAIL);
913
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
914
-
915
- const result = await serviceWithConfig.determineAuthResponse({ user, config });
916
-
917
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_EMAIL);
918
- expect(result.accessToken).toBeUndefined();
919
- });
920
-
921
- it('should return MFA setup challenge when required', async () => {
922
- const user = {
923
- ...mockUser,
924
- isEmailVerified: true,
925
- isPhoneVerified: true,
926
- mfaEnabled: false,
927
- createdAt: new Date('2024-01-01'),
928
- } as IUser;
929
- const config = {
930
- ...mockConfig,
931
- signup: { verificationMethod: 'none' as const },
932
- mfa: {
933
- enabled: true,
934
- enforcement: 'REQUIRED' as const,
935
- gracePeriod: 0,
936
- },
937
- };
938
- const serviceWithConfig = new AuthChallengeHelperService(
939
- mockChallengeService,
940
- mockJwtService,
941
- mockSessionService,
942
- mockMFADeviceRepository,
943
- mockLogger,
944
- mockStateMachine,
945
- mockContextBuilder,
946
- mockClientInfoService,
947
- );
948
-
949
- // Mock context builder
950
- mockContextBuilder.build.mockResolvedValue({
951
- user,
952
- config,
953
- authMethod: 'password',
954
- computed: {
955
- isEmailVerificationRequired: false,
956
- isPhoneVerificationRequired: false,
957
- isPhoneCollectionNeeded: false,
958
- isMFAExempt: false,
959
- isMFASetupRequired: true,
960
- isMFAVerificationRequired: false,
961
- isDeviceTrusted: false,
962
- isGracePeriodActive: false,
963
- riskScore: 0,
964
- riskLevel: 'low',
965
- isBlocked: false,
966
- },
967
- } as AuthFlowContext);
968
-
969
- // Mock state machine
970
- mockStateMachine.evaluateState.mockResolvedValue(AuthFlowState.PENDING_MFA_SETUP);
971
- mockStateMachine.getStateDefinition.mockReturnValue({
972
- state: AuthFlowState.PENDING_MFA_SETUP,
973
- priority: 5,
974
- condition: () => true,
975
- challenge: AuthChallenge.MFA_SETUP_REQUIRED,
976
- });
977
- mockStateMachine.buildMetadata.mockReturnValue({});
978
-
979
- const mockChallengeSession = createMockChallengeSession('session-token-setup', AuthChallenge.MFA_SETUP_REQUIRED);
980
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
981
-
982
- const result = await serviceWithConfig.determineAuthResponse({ user, config });
983
-
984
- expect(result.challengeName).toBe(AuthChallenge.MFA_SETUP_REQUIRED);
985
- });
986
-
987
- it('should return MFA challenge when MFA verification required', async () => {
988
- const user = { ...mockUser, isEmailVerified: true, mfaEnabled: true } as IUser;
989
- const config = {
990
- ...mockConfig,
991
- signup: { verificationMethod: 'none' as const },
992
- mfa: {
993
- enabled: true,
994
- enforcement: 'REQUIRED' as const,
995
- },
996
- };
997
- const serviceWithConfig = new AuthChallengeHelperService(
998
- mockChallengeService,
999
- mockJwtService,
1000
- mockSessionService,
1001
- mockMFADeviceRepository,
1002
- mockLogger,
1003
- mockStateMachine,
1004
- mockContextBuilder,
1005
- mockClientInfoService,
1006
- );
1007
-
1008
- // Mock context builder
1009
- mockContextBuilder.build.mockResolvedValue({
1010
- user,
1011
- config,
1012
- authMethod: 'password',
1013
- computed: {
1014
- isEmailVerificationRequired: false,
1015
- isPhoneVerificationRequired: false,
1016
- isPhoneCollectionNeeded: false,
1017
- isMFAExempt: false,
1018
- isMFASetupRequired: false,
1019
- isMFAVerificationRequired: true,
1020
- isDeviceTrusted: false,
1021
- isGracePeriodActive: false,
1022
- riskScore: 0,
1023
- riskLevel: 'low',
1024
- isBlocked: false,
1025
- },
1026
- } as AuthFlowContext);
1027
-
1028
- // Mock state machine
1029
- mockStateMachine.evaluateState.mockResolvedValue(AuthFlowState.PENDING_MFA_VERIFICATION);
1030
- mockStateMachine.getStateDefinition.mockReturnValue({
1031
- state: AuthFlowState.PENDING_MFA_VERIFICATION,
1032
- priority: 6,
1033
- condition: () => true,
1034
- challenge: AuthChallenge.MFA_REQUIRED,
1035
- });
1036
- mockStateMachine.buildMetadata.mockReturnValue({});
1037
-
1038
- const mockDevices: IMFADevice[] = [
1039
- {
1040
- id: 1,
1041
- userId: 1,
1042
- type: MFAMethod.TOTP,
1043
- isActive: true,
1044
- isPrimary: true,
1045
- } as IMFADevice,
1046
- ];
1047
- mockMFADeviceRepository.find.mockResolvedValue(mockDevices);
1048
- const mockChallengeSession = createMockChallengeSession('session-token-mfa', AuthChallenge.MFA_REQUIRED);
1049
- mockChallengeService.createChallengeSession.mockResolvedValue(mockChallengeSession);
1050
-
1051
- const result = await serviceWithConfig.determineAuthResponse({ user, config });
1052
-
1053
- expect(result.challengeName).toBe(AuthChallenge.MFA_REQUIRED);
1054
- });
1055
-
1056
- it('should return success response when no challenges', async () => {
1057
- const user = { ...mockUser, isEmailVerified: true } as IUser;
1058
- const config = { ...mockConfig, signup: { verificationMethod: 'email' as const } };
1059
- const serviceWithConfig = new AuthChallengeHelperService(
1060
- mockChallengeService,
1061
- mockJwtService,
1062
- mockSessionService,
1063
- mockMFADeviceRepository,
1064
- mockLogger,
1065
- mockStateMachine,
1066
- mockContextBuilder,
1067
- mockClientInfoService,
1068
- );
1069
-
1070
- // Mock context builder
1071
- mockContextBuilder.build.mockResolvedValue({
1072
- user,
1073
- config,
1074
- authMethod: 'password',
1075
- computed: {
1076
- isEmailVerificationRequired: false,
1077
- isPhoneVerificationRequired: false,
1078
- isPhoneCollectionNeeded: false,
1079
- isMFAExempt: false,
1080
- isMFASetupRequired: false,
1081
- isMFAVerificationRequired: false,
1082
- isDeviceTrusted: false,
1083
- isGracePeriodActive: false,
1084
- riskScore: 0,
1085
- riskLevel: 'low',
1086
- isBlocked: false,
1087
- },
1088
- } as AuthFlowContext);
1089
-
1090
- // Mock state machine
1091
- mockStateMachine.evaluateState.mockResolvedValue(AuthFlowState.AUTHENTICATED);
1092
- mockStateMachine.getStateDefinition.mockReturnValue({
1093
- state: AuthFlowState.AUTHENTICATED,
1094
- priority: 9,
1095
- condition: () => true,
1096
- });
1097
- mockStateMachine.buildMetadata.mockReturnValue({});
1098
-
1099
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
1100
- mockJwtService.generateTokenPair
1101
- .mockResolvedValueOnce({
1102
- accessToken: 'temp-access',
1103
- refreshToken: 'temp-refresh',
1104
- expiresIn: 900,
1105
- })
1106
- .mockResolvedValueOnce({
1107
- accessToken: 'access-token',
1108
- refreshToken: 'refresh-token',
1109
- expiresIn: 900,
1110
- });
1111
- mockJwtService.hashToken.mockReturnValue('token-hash');
1112
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
1113
- mockJwtService.validateAccessToken.mockResolvedValue({
1114
- valid: true,
1115
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
1116
- });
1117
- mockJwtService.validateRefreshToken.mockResolvedValue({
1118
- valid: true,
1119
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
1120
- });
1121
-
1122
- const result = await serviceWithConfig.determineAuthResponse({ user, config });
1123
-
1124
- expect(result.challengeName).toBeUndefined();
1125
- expect(result.accessToken).toBe('access-token');
1126
- expect(result.refreshToken).toBe('refresh-token');
1127
- });
1128
-
1129
- it('should skip MFA verification when flag is set', async () => {
1130
- const user = { ...mockUser, isEmailVerified: true, mfaEnabled: true } as IUser;
1131
- const config = {
1132
- ...mockConfig,
1133
- signup: { verificationMethod: 'none' as const },
1134
- mfa: {
1135
- enabled: true,
1136
- enforcement: 'REQUIRED' as const,
1137
- },
1138
- };
1139
- const serviceWithConfig = new AuthChallengeHelperService(
1140
- mockChallengeService,
1141
- mockJwtService,
1142
- mockSessionService,
1143
- mockMFADeviceRepository,
1144
- mockLogger,
1145
- mockStateMachine,
1146
- mockContextBuilder,
1147
- mockClientInfoService,
1148
- );
1149
-
1150
- // Mock context builder with skipMFAVerification
1151
- mockContextBuilder.build.mockResolvedValue({
1152
- user,
1153
- config,
1154
- authMethod: 'password',
1155
- skipMFAVerification: true,
1156
- computed: {
1157
- isEmailVerificationRequired: false,
1158
- isPhoneVerificationRequired: false,
1159
- isPhoneCollectionNeeded: false,
1160
- isMFAExempt: false,
1161
- isMFASetupRequired: false,
1162
- isMFAVerificationRequired: false, // Skipped due to flag
1163
- isDeviceTrusted: false,
1164
- isGracePeriodActive: false,
1165
- riskScore: 0,
1166
- riskLevel: 'low',
1167
- isBlocked: false,
1168
- },
1169
- } as AuthFlowContext);
1170
-
1171
- // Mock state machine
1172
- mockStateMachine.evaluateState.mockResolvedValue(AuthFlowState.AUTHENTICATED);
1173
- mockStateMachine.getStateDefinition.mockReturnValue({
1174
- state: AuthFlowState.AUTHENTICATED,
1175
- priority: 9,
1176
- condition: () => true,
1177
- });
1178
- mockStateMachine.buildMetadata.mockReturnValue({});
1179
-
1180
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
1181
- mockJwtService.generateTokenPair
1182
- .mockResolvedValueOnce({
1183
- accessToken: 'temp-access',
1184
- refreshToken: 'temp-refresh',
1185
- expiresIn: 900,
1186
- })
1187
- .mockResolvedValueOnce({
1188
- accessToken: 'access-token',
1189
- refreshToken: 'refresh-token',
1190
- expiresIn: 900,
1191
- });
1192
- mockJwtService.hashToken.mockReturnValue('token-hash');
1193
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
1194
- mockJwtService.validateAccessToken.mockResolvedValue({
1195
- valid: true,
1196
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
1197
- });
1198
- mockJwtService.validateRefreshToken.mockResolvedValue({
1199
- valid: true,
1200
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
1201
- });
1202
-
1203
- const result = await serviceWithConfig.determineAuthResponse({
1204
- user,
1205
- config,
1206
- skipMFAVerification: true,
1207
- });
1208
-
1209
- expect(result.accessToken).toBe('access-token');
1210
- // skipMFAVerification is handled by context builder, which sets isMFAVerificationRequired: false
1211
- // No explicit log message is required - the state machine evaluates to AUTHENTICATED
1212
- });
1213
-
1214
- it('should check trusted device status for trusted flag', async () => {
1215
- const user = { ...mockUser, isEmailVerified: true } as IUser;
1216
- const config = {
1217
- ...mockConfig,
1218
- signup: { verificationMethod: 'none' as const },
1219
- mfa: {
1220
- enabled: false,
1221
- rememberDevices: 'user_opt_in' as const,
1222
- },
1223
- };
1224
- const serviceWithConfig = new AuthChallengeHelperService(
1225
- mockChallengeService,
1226
- mockJwtService,
1227
- mockSessionService,
1228
- mockMFADeviceRepository,
1229
- mockLogger,
1230
- mockStateMachine,
1231
- mockContextBuilder,
1232
- mockClientInfoService,
1233
- );
1234
-
1235
- mockTrustedDeviceService.isDeviceTrusted.mockResolvedValue(true);
1236
-
1237
- // Mock context builder
1238
- mockContextBuilder.build.mockResolvedValue({
1239
- user,
1240
- config,
1241
- authMethod: 'password',
1242
- deviceToken: 'device-token-123',
1243
- computed: {
1244
- isEmailVerificationRequired: false,
1245
- isPhoneVerificationRequired: false,
1246
- isPhoneCollectionNeeded: false,
1247
- isMFAExempt: false,
1248
- isMFASetupRequired: false,
1249
- isMFAVerificationRequired: false,
1250
- isDeviceTrusted: true,
1251
- isGracePeriodActive: false,
1252
- riskScore: 0,
1253
- riskLevel: 'low',
1254
- isBlocked: false,
1255
- },
1256
- } as AuthFlowContext);
1257
-
1258
- // Mock state machine
1259
- mockStateMachine.evaluateState.mockResolvedValue(AuthFlowState.AUTHENTICATED);
1260
- mockStateMachine.getStateDefinition.mockReturnValue({
1261
- state: AuthFlowState.AUTHENTICATED,
1262
- priority: 9,
1263
- condition: () => true,
1264
- });
1265
- mockStateMachine.buildMetadata.mockReturnValue({});
1266
-
1267
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
1268
- mockJwtService.generateTokenPair
1269
- .mockResolvedValueOnce({
1270
- accessToken: 'temp-access',
1271
- refreshToken: 'temp-refresh',
1272
- expiresIn: 900,
1273
- })
1274
- .mockResolvedValueOnce({
1275
- accessToken: 'access-token',
1276
- refreshToken: 'refresh-token',
1277
- expiresIn: 900,
1278
- });
1279
- mockJwtService.hashToken.mockReturnValue('token-hash');
1280
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
1281
- mockJwtService.validateAccessToken.mockResolvedValue({
1282
- valid: true,
1283
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
1284
- });
1285
- mockJwtService.validateRefreshToken.mockResolvedValue({
1286
- valid: true,
1287
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
1288
- });
1289
-
1290
- const result = await serviceWithConfig.determineAuthResponse({
1291
- user,
1292
- config,
1293
- deviceToken: 'device-token-123',
1294
- });
1295
-
1296
- expect(result.trusted).toBe(true);
1297
- // isDeviceTrusted is called by context builder, not directly by challenge helper
1298
- // The context builder pre-computes isDeviceTrusted and includes it in the context
1299
- });
1300
- });
1301
-
1302
- // ============================================================================
1303
- // Comprehensive Scenario Tests - Based on CHALLENGE_SCENARIOS.md
1304
- // ============================================================================
1305
- // These tests verify all scenarios documented in CHALLENGE_SCENARIOS.md
1306
-
1307
- describe('determineAuthResponse - Comprehensive Scenarios', () => {
1308
- // Helper to create a properly configured service with state machine mocks
1309
- const createServiceWithMocks = (config: NAuthConfig) => {
1310
- return new AuthChallengeHelperService(
1311
- mockChallengeService,
1312
- mockJwtService,
1313
- mockSessionService,
1314
- mockMFADeviceRepository,
1315
- mockLogger,
1316
- mockStateMachine,
1317
- mockContextBuilder,
1318
- mockClientInfoService,
1319
- mockEmailVerificationService,
1320
- mockPhoneVerificationService,
1321
- );
1322
- };
1323
-
1324
- // Helper to mock state machine evaluation
1325
- const mockStateEvaluation = (state: AuthFlowState, challenge?: AuthChallenge, metadata?: ResponseMetadata) => {
1326
- mockStateMachine.evaluateState.mockResolvedValue(state);
1327
- mockStateMachine.getStateDefinition.mockReturnValue({
1328
- state,
1329
- priority: 1,
1330
- condition: () => true,
1331
- challenge,
1332
- });
1333
- mockStateMachine.buildMetadata.mockReturnValue(metadata || {});
1334
- };
1335
-
1336
- // Helper to mock context builder
1337
- const mockContextBuild = (computed: Partial<AuthFlowContext['computed']> = {}, userOverride?: Partial<IUser>) => {
1338
- const user = { ...mockUser, ...userOverride } as IUser;
1339
- mockContextBuilder.build.mockResolvedValue({
1340
- user,
1341
- config: mockConfig,
1342
- authMethod: 'password',
1343
- computed: {
1344
- isDeviceTrusted: false,
1345
- isEmailVerificationRequired: false,
1346
- isPhoneVerificationRequired: false,
1347
- isPhoneCollectionNeeded: false,
1348
- isMFAExempt: false,
1349
- isMFASetupRequired: false,
1350
- isMFAVerificationRequired: false,
1351
- isGracePeriodActive: false,
1352
- riskScore: 0,
1353
- riskLevel: 'low',
1354
- isBlocked: false,
1355
- ...computed,
1356
- },
1357
- } as AuthFlowContext);
1358
- };
1359
-
1360
- beforeEach(() => {
1361
- mockClientInfoService.get.mockReturnValue({
1362
- ipAddress: '1.2.3.4',
1363
- userAgent: 'test-agent',
1364
- deviceToken: undefined,
1365
- } as any);
1366
- // Setup default mocks for services
1367
- mockEmailVerificationService.sendVerificationEmail.mockResolvedValue(1);
1368
- mockPhoneVerificationService.sendVerificationSMS.mockResolvedValue(123456);
1369
- });
1370
-
1371
- // ============================================================================
1372
- // Signup Scenarios - MFA OPTIONAL
1373
- // ============================================================================
1374
-
1375
- describe('Signup - MFA OPTIONAL', () => {
1376
- it('should return SUCCESS when verificationMethod is none', async () => {
1377
- const config: NAuthConfig = {
1378
- ...mockConfig,
1379
- signup: { verificationMethod: 'none' },
1380
- mfa: { enabled: true, enforcement: 'OPTIONAL' },
1381
- };
1382
- const service = createServiceWithMocks(config);
1383
- mockContextBuild({ isEmailVerificationRequired: false, isPhoneVerificationRequired: false });
1384
- mockStateEvaluation(AuthFlowState.AUTHENTICATED);
1385
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
1386
- mockJwtService.generateTokenPair.mockResolvedValue({
1387
- accessToken: 'access-token',
1388
- refreshToken: 'refresh-token',
1389
- expiresIn: 900,
1390
- });
1391
- mockJwtService.hashToken.mockReturnValue('token-hash');
1392
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
1393
- mockJwtService.validateAccessToken.mockResolvedValue({
1394
- valid: true,
1395
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
1396
- });
1397
- mockJwtService.validateRefreshToken.mockResolvedValue({
1398
- valid: true,
1399
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
1400
- });
1401
-
1402
- const result = await service.determineAuthResponse({
1403
- user: mockUser as IUser,
1404
- config,
1405
- });
1406
-
1407
- expect(result.challengeName).toBeUndefined();
1408
- expect(result.accessToken).toBe('access-token');
1409
- });
1410
-
1411
- it('should return VERIFY_EMAIL when verificationMethod is email', async () => {
1412
- const config: NAuthConfig = {
1413
- ...mockConfig,
1414
- signup: { verificationMethod: 'email' },
1415
- mfa: { enabled: true, enforcement: 'OPTIONAL' },
1416
- };
1417
- const service = createServiceWithMocks(config);
1418
- const user = { ...mockUser, isEmailVerified: false } as IUser;
1419
- mockContextBuild({ isEmailVerificationRequired: true }, user);
1420
- mockStateEvaluation(AuthFlowState.PENDING_EMAIL_VERIFICATION, AuthChallenge.VERIFY_EMAIL);
1421
- mockChallengeService.createChallengeSession.mockResolvedValue(
1422
- createMockChallengeSession('session-123', AuthChallenge.VERIFY_EMAIL),
1423
- );
1424
-
1425
- const result = await service.determineAuthResponse({ user, config });
1426
-
1427
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_EMAIL);
1428
- });
1429
-
1430
- it('should return VERIFY_PHONE when verificationMethod is phone', async () => {
1431
- const config: NAuthConfig = {
1432
- ...mockConfig,
1433
- signup: { verificationMethod: 'phone' },
1434
- mfa: { enabled: true, enforcement: 'OPTIONAL' },
1435
- };
1436
- const service = createServiceWithMocks(config);
1437
- const user = { ...mockUser, phone: '+1234567890', isPhoneVerified: false } as IUser;
1438
- mockContextBuild({ isPhoneVerificationRequired: true }, user);
1439
- mockStateEvaluation(AuthFlowState.PENDING_PHONE_VERIFICATION, AuthChallenge.VERIFY_PHONE);
1440
- mockChallengeService.createChallengeSession.mockResolvedValue(
1441
- createMockChallengeSession('session-123', AuthChallenge.VERIFY_PHONE),
1442
- );
1443
-
1444
- const result = await service.determineAuthResponse({ user, config });
1445
-
1446
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_PHONE);
1447
- });
1448
-
1449
- it('should return VERIFY_EMAIL first when verificationMethod is both (sequential flow)', async () => {
1450
- const config: NAuthConfig = {
1451
- ...mockConfig,
1452
- signup: { verificationMethod: 'both' },
1453
- mfa: { enabled: true, enforcement: 'OPTIONAL' },
1454
- };
1455
- const service = createServiceWithMocks(config);
1456
- const user = { ...mockUser, isEmailVerified: false, isPhoneVerified: false } as IUser;
1457
- mockContextBuild({ isEmailVerificationRequired: true, isPhoneVerificationRequired: true }, user);
1458
- mockStateEvaluation(AuthFlowState.PENDING_EMAIL_VERIFICATION, AuthChallenge.VERIFY_EMAIL);
1459
- mockChallengeService.createChallengeSession.mockResolvedValue(
1460
- createMockChallengeSession('session-123', AuthChallenge.VERIFY_EMAIL),
1461
- );
1462
-
1463
- const result = await service.determineAuthResponse({ user, config });
1464
-
1465
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_EMAIL);
1466
- });
1467
- });
1468
-
1469
- // ============================================================================
1470
- // Signup Scenarios - MFA REQUIRED
1471
- // ============================================================================
1472
-
1473
- describe('Signup - MFA REQUIRED', () => {
1474
- it('should return MFA_SETUP_REQUIRED when gracePeriod is 0 and verificationMethod is none', async () => {
1475
- const config: NAuthConfig = {
1476
- ...mockConfig,
1477
- signup: { verificationMethod: 'none' },
1478
- mfa: { enabled: true, enforcement: 'REQUIRED', gracePeriod: 0 },
1479
- };
1480
- const service = createServiceWithMocks(config);
1481
- const user = { ...mockUser, mfaEnabled: false, createdAt: new Date() } as IUser;
1482
- mockContextBuild({ isMFASetupRequired: true });
1483
- mockStateEvaluation(AuthFlowState.PENDING_MFA_SETUP, AuthChallenge.MFA_SETUP_REQUIRED);
1484
- mockChallengeService.createChallengeSession.mockResolvedValue(
1485
- createMockChallengeSession('session-123', AuthChallenge.MFA_SETUP_REQUIRED),
1486
- );
1487
-
1488
- const result = await service.determineAuthResponse({ user, config });
1489
-
1490
- expect(result.challengeName).toBe(AuthChallenge.MFA_SETUP_REQUIRED);
1491
- });
1492
-
1493
- it('should return SUCCESS when gracePeriod is 7 days (grace period active)', async () => {
1494
- const config: NAuthConfig = {
1495
- ...mockConfig,
1496
- signup: { verificationMethod: 'none' },
1497
- mfa: { enabled: true, enforcement: 'REQUIRED', gracePeriod: 7 },
1498
- };
1499
- const service = createServiceWithMocks(config);
1500
- const user = { ...mockUser, mfaEnabled: false, createdAt: new Date() } as IUser;
1501
- mockContextBuild({ isGracePeriodActive: true, isMFASetupRequired: false });
1502
- mockStateEvaluation(AuthFlowState.GRACE_PERIOD_ACTIVE);
1503
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
1504
- mockJwtService.generateTokenPair.mockResolvedValue({
1505
- accessToken: 'access-token',
1506
- refreshToken: 'refresh-token',
1507
- expiresIn: 900,
1508
- });
1509
- mockJwtService.hashToken.mockReturnValue('token-hash');
1510
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
1511
- mockJwtService.validateAccessToken.mockResolvedValue({
1512
- valid: true,
1513
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
1514
- });
1515
- mockJwtService.validateRefreshToken.mockResolvedValue({
1516
- valid: true,
1517
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
1518
- });
1519
-
1520
- const result = await service.determineAuthResponse({ user, config });
1521
-
1522
- expect(result.challengeName).toBeUndefined();
1523
- expect(result.accessToken).toBe('access-token');
1524
- });
1525
-
1526
- it('should return VERIFY_EMAIL then MFA_SETUP_REQUIRED when gracePeriod is 0 and verificationMethod is email', async () => {
1527
- const config: NAuthConfig = {
1528
- ...mockConfig,
1529
- signup: { verificationMethod: 'email' },
1530
- mfa: { enabled: true, enforcement: 'REQUIRED', gracePeriod: 0 },
1531
- };
1532
- const service = createServiceWithMocks(config);
1533
- const user = { ...mockUser, isEmailVerified: false, mfaEnabled: false } as IUser;
1534
- mockContextBuild({ isEmailVerificationRequired: true, isMFASetupRequired: true });
1535
- mockStateEvaluation(AuthFlowState.PENDING_EMAIL_VERIFICATION, AuthChallenge.VERIFY_EMAIL);
1536
- mockChallengeService.createChallengeSession.mockResolvedValue(
1537
- createMockChallengeSession('session-123', AuthChallenge.VERIFY_EMAIL),
1538
- );
1539
-
1540
- const result = await service.determineAuthResponse({ user, config });
1541
-
1542
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_EMAIL);
1543
- });
1544
-
1545
- it('should return SUCCESS when gracePeriod is 7 days and verificationMethod is email', async () => {
1546
- const config: NAuthConfig = {
1547
- ...mockConfig,
1548
- signup: { verificationMethod: 'email' },
1549
- mfa: { enabled: true, enforcement: 'REQUIRED', gracePeriod: 7 },
1550
- };
1551
- const service = createServiceWithMocks(config);
1552
- const user = { ...mockUser, isEmailVerified: true, mfaEnabled: false, createdAt: new Date() } as IUser;
1553
- mockContextBuild({ isGracePeriodActive: true, isMFASetupRequired: false });
1554
- mockStateEvaluation(AuthFlowState.GRACE_PERIOD_ACTIVE);
1555
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
1556
- mockJwtService.generateTokenPair.mockResolvedValue({
1557
- accessToken: 'access-token',
1558
- refreshToken: 'refresh-token',
1559
- expiresIn: 900,
1560
- });
1561
- mockJwtService.hashToken.mockReturnValue('token-hash');
1562
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
1563
- mockJwtService.validateAccessToken.mockResolvedValue({
1564
- valid: true,
1565
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
1566
- });
1567
- mockJwtService.validateRefreshToken.mockResolvedValue({
1568
- valid: true,
1569
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
1570
- });
1571
-
1572
- const result = await service.determineAuthResponse({ user, config });
1573
-
1574
- expect(result.challengeName).toBeUndefined();
1575
- expect(result.accessToken).toBe('access-token');
1576
- });
1577
-
1578
- it('should return VERIFY_PHONE then MFA_SETUP_REQUIRED when gracePeriod is 0 and verificationMethod is phone', async () => {
1579
- const config: NAuthConfig = {
1580
- ...mockConfig,
1581
- signup: { verificationMethod: 'phone' },
1582
- mfa: { enabled: true, enforcement: 'REQUIRED', gracePeriod: 0 },
1583
- };
1584
- const service = createServiceWithMocks(config);
1585
- const user = { ...mockUser, phone: '+1234567890', isPhoneVerified: false, mfaEnabled: false } as IUser;
1586
- mockContextBuild({ isPhoneVerificationRequired: true, isMFASetupRequired: true });
1587
- mockStateEvaluation(AuthFlowState.PENDING_PHONE_VERIFICATION, AuthChallenge.VERIFY_PHONE);
1588
- mockChallengeService.createChallengeSession.mockResolvedValue(
1589
- createMockChallengeSession('session-123', AuthChallenge.VERIFY_PHONE),
1590
- );
1591
-
1592
- const result = await service.determineAuthResponse({ user, config });
1593
-
1594
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_PHONE);
1595
- });
1596
-
1597
- it('should return SUCCESS when gracePeriod is 7 days and verificationMethod is phone', async () => {
1598
- const config: NAuthConfig = {
1599
- ...mockConfig,
1600
- signup: { verificationMethod: 'phone' },
1601
- mfa: { enabled: true, enforcement: 'REQUIRED', gracePeriod: 7 },
1602
- };
1603
- const service = createServiceWithMocks(config);
1604
- const user = {
1605
- ...mockUser,
1606
- phone: '+1234567890',
1607
- isPhoneVerified: true,
1608
- mfaEnabled: false,
1609
- createdAt: new Date(),
1610
- } as IUser;
1611
- mockContextBuild({ isGracePeriodActive: true, isMFASetupRequired: false });
1612
- mockStateEvaluation(AuthFlowState.GRACE_PERIOD_ACTIVE);
1613
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
1614
- mockJwtService.generateTokenPair.mockResolvedValue({
1615
- accessToken: 'access-token',
1616
- refreshToken: 'refresh-token',
1617
- expiresIn: 900,
1618
- });
1619
- mockJwtService.hashToken.mockReturnValue('token-hash');
1620
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
1621
- mockJwtService.validateAccessToken.mockResolvedValue({
1622
- valid: true,
1623
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
1624
- });
1625
- mockJwtService.validateRefreshToken.mockResolvedValue({
1626
- valid: true,
1627
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
1628
- });
1629
-
1630
- const result = await service.determineAuthResponse({ user, config });
1631
-
1632
- expect(result.challengeName).toBeUndefined();
1633
- expect(result.accessToken).toBe('access-token');
1634
- });
1635
-
1636
- it('should return VERIFY_EMAIL first when gracePeriod is 0 and verificationMethod is both', async () => {
1637
- const config: NAuthConfig = {
1638
- ...mockConfig,
1639
- signup: { verificationMethod: 'both' },
1640
- mfa: { enabled: true, enforcement: 'REQUIRED', gracePeriod: 0 },
1641
- };
1642
- const service = createServiceWithMocks(config);
1643
- const user = { ...mockUser, isEmailVerified: false, isPhoneVerified: false, mfaEnabled: false } as IUser;
1644
- mockContextBuild({
1645
- isEmailVerificationRequired: true,
1646
- isPhoneVerificationRequired: true,
1647
- isMFASetupRequired: true,
1648
- });
1649
- mockStateEvaluation(AuthFlowState.PENDING_EMAIL_VERIFICATION, AuthChallenge.VERIFY_EMAIL);
1650
- mockChallengeService.createChallengeSession.mockResolvedValue(
1651
- createMockChallengeSession('session-123', AuthChallenge.VERIFY_EMAIL),
1652
- );
1653
-
1654
- const result = await service.determineAuthResponse({ user, config });
1655
-
1656
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_EMAIL);
1657
- });
1658
-
1659
- it('should return SUCCESS when gracePeriod is 7 days and verificationMethod is both', async () => {
1660
- const config: NAuthConfig = {
1661
- ...mockConfig,
1662
- signup: { verificationMethod: 'both' },
1663
- mfa: { enabled: true, enforcement: 'REQUIRED', gracePeriod: 7 },
1664
- };
1665
- const service = createServiceWithMocks(config);
1666
- const user = {
1667
- ...mockUser,
1668
- isEmailVerified: true,
1669
- isPhoneVerified: true,
1670
- mfaEnabled: false,
1671
- createdAt: new Date(),
1672
- } as IUser;
1673
- mockContextBuild({ isGracePeriodActive: true, isMFASetupRequired: false });
1674
- mockStateEvaluation(AuthFlowState.GRACE_PERIOD_ACTIVE);
1675
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
1676
- mockJwtService.generateTokenPair.mockResolvedValue({
1677
- accessToken: 'access-token',
1678
- refreshToken: 'refresh-token',
1679
- expiresIn: 900,
1680
- });
1681
- mockJwtService.hashToken.mockReturnValue('token-hash');
1682
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
1683
- mockJwtService.validateAccessToken.mockResolvedValue({
1684
- valid: true,
1685
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
1686
- });
1687
- mockJwtService.validateRefreshToken.mockResolvedValue({
1688
- valid: true,
1689
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
1690
- });
1691
-
1692
- const result = await service.determineAuthResponse({ user, config });
1693
-
1694
- expect(result.challengeName).toBeUndefined();
1695
- expect(result.accessToken).toBe('access-token');
1696
- });
1697
- });
1698
-
1699
- // ============================================================================
1700
- // Signup Scenarios - MFA ADAPTIVE
1701
- // ============================================================================
1702
-
1703
- describe('Signup - MFA ADAPTIVE', () => {
1704
- it('should return MFA_SETUP_REQUIRED when gracePeriod is 0 and verificationMethod is none', async () => {
1705
- const config: NAuthConfig = {
1706
- ...mockConfig,
1707
- signup: { verificationMethod: 'none' },
1708
- mfa: { enabled: true, enforcement: 'ADAPTIVE', gracePeriod: 0 },
1709
- };
1710
- const service = createServiceWithMocks(config);
1711
- const user = { ...mockUser, mfaEnabled: false, createdAt: new Date() } as IUser;
1712
- mockContextBuild({ isMFASetupRequired: true });
1713
- mockStateEvaluation(AuthFlowState.PENDING_MFA_SETUP, AuthChallenge.MFA_SETUP_REQUIRED);
1714
- mockChallengeService.createChallengeSession.mockResolvedValue(
1715
- createMockChallengeSession('session-123', AuthChallenge.MFA_SETUP_REQUIRED),
1716
- );
1717
-
1718
- const result = await service.determineAuthResponse({ user, config });
1719
-
1720
- expect(result.challengeName).toBe(AuthChallenge.MFA_SETUP_REQUIRED);
1721
- });
1722
-
1723
- it('should return SUCCESS when gracePeriod is 7 days (grace period active)', async () => {
1724
- const config: NAuthConfig = {
1725
- ...mockConfig,
1726
- signup: { verificationMethod: 'none' },
1727
- mfa: { enabled: true, enforcement: 'ADAPTIVE', gracePeriod: 7 },
1728
- };
1729
- const service = createServiceWithMocks(config);
1730
- const user = { ...mockUser, mfaEnabled: false, createdAt: new Date() } as IUser;
1731
- mockContextBuild({ isGracePeriodActive: true, isMFASetupRequired: false });
1732
- mockStateEvaluation(AuthFlowState.GRACE_PERIOD_ACTIVE);
1733
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
1734
- mockJwtService.generateTokenPair.mockResolvedValue({
1735
- accessToken: 'access-token',
1736
- refreshToken: 'refresh-token',
1737
- expiresIn: 900,
1738
- });
1739
- mockJwtService.hashToken.mockReturnValue('token-hash');
1740
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
1741
- mockJwtService.validateAccessToken.mockResolvedValue({
1742
- valid: true,
1743
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
1744
- });
1745
- mockJwtService.validateRefreshToken.mockResolvedValue({
1746
- valid: true,
1747
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
1748
- });
1749
-
1750
- const result = await service.determineAuthResponse({ user, config });
1751
-
1752
- expect(result.challengeName).toBeUndefined();
1753
- expect(result.accessToken).toBe('access-token');
1754
- });
1755
-
1756
- it('should return VERIFY_EMAIL then MFA_SETUP_REQUIRED when gracePeriod is 0 and verificationMethod is email', async () => {
1757
- const config: NAuthConfig = {
1758
- ...mockConfig,
1759
- signup: { verificationMethod: 'email' },
1760
- mfa: { enabled: true, enforcement: 'ADAPTIVE', gracePeriod: 0 },
1761
- };
1762
- const service = createServiceWithMocks(config);
1763
- const user = { ...mockUser, isEmailVerified: false, mfaEnabled: false } as IUser;
1764
- mockContextBuild({ isEmailVerificationRequired: true, isMFASetupRequired: true });
1765
- mockStateEvaluation(AuthFlowState.PENDING_EMAIL_VERIFICATION, AuthChallenge.VERIFY_EMAIL);
1766
- mockChallengeService.createChallengeSession.mockResolvedValue(
1767
- createMockChallengeSession('session-123', AuthChallenge.VERIFY_EMAIL),
1768
- );
1769
-
1770
- const result = await service.determineAuthResponse({ user, config });
1771
-
1772
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_EMAIL);
1773
- });
1774
-
1775
- it('should return SUCCESS when gracePeriod is 7 days and verificationMethod is email', async () => {
1776
- const config: NAuthConfig = {
1777
- ...mockConfig,
1778
- signup: { verificationMethod: 'email' },
1779
- mfa: { enabled: true, enforcement: 'ADAPTIVE', gracePeriod: 7 },
1780
- };
1781
- const service = createServiceWithMocks(config);
1782
- const user = { ...mockUser, isEmailVerified: true, mfaEnabled: false, createdAt: new Date() } as IUser;
1783
- mockContextBuild({ isGracePeriodActive: true, isMFASetupRequired: false });
1784
- mockStateEvaluation(AuthFlowState.GRACE_PERIOD_ACTIVE);
1785
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
1786
- mockJwtService.generateTokenPair.mockResolvedValue({
1787
- accessToken: 'access-token',
1788
- refreshToken: 'refresh-token',
1789
- expiresIn: 900,
1790
- });
1791
- mockJwtService.hashToken.mockReturnValue('token-hash');
1792
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
1793
- mockJwtService.validateAccessToken.mockResolvedValue({
1794
- valid: true,
1795
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
1796
- });
1797
- mockJwtService.validateRefreshToken.mockResolvedValue({
1798
- valid: true,
1799
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
1800
- });
1801
-
1802
- const result = await service.determineAuthResponse({ user, config });
1803
-
1804
- expect(result.challengeName).toBeUndefined();
1805
- expect(result.accessToken).toBe('access-token');
1806
- });
1807
-
1808
- it('should return VERIFY_PHONE then MFA_SETUP_REQUIRED when gracePeriod is 0 and verificationMethod is phone', async () => {
1809
- const config: NAuthConfig = {
1810
- ...mockConfig,
1811
- signup: { verificationMethod: 'phone' },
1812
- mfa: { enabled: true, enforcement: 'ADAPTIVE', gracePeriod: 0 },
1813
- };
1814
- const service = createServiceWithMocks(config);
1815
- const user = { ...mockUser, phone: '+1234567890', isPhoneVerified: false, mfaEnabled: false } as IUser;
1816
- mockContextBuild({ isPhoneVerificationRequired: true, isMFASetupRequired: true });
1817
- mockStateEvaluation(AuthFlowState.PENDING_PHONE_VERIFICATION, AuthChallenge.VERIFY_PHONE);
1818
- mockChallengeService.createChallengeSession.mockResolvedValue(
1819
- createMockChallengeSession('session-123', AuthChallenge.VERIFY_PHONE),
1820
- );
1821
-
1822
- const result = await service.determineAuthResponse({ user, config });
1823
-
1824
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_PHONE);
1825
- });
1826
-
1827
- it('should return SUCCESS when gracePeriod is 7 days and verificationMethod is phone', async () => {
1828
- const config: NAuthConfig = {
1829
- ...mockConfig,
1830
- signup: { verificationMethod: 'phone' },
1831
- mfa: { enabled: true, enforcement: 'ADAPTIVE', gracePeriod: 7 },
1832
- };
1833
- const service = createServiceWithMocks(config);
1834
- const user = {
1835
- ...mockUser,
1836
- phone: '+1234567890',
1837
- isPhoneVerified: true,
1838
- mfaEnabled: false,
1839
- createdAt: new Date(),
1840
- } as IUser;
1841
- mockContextBuild({ isGracePeriodActive: true, isMFASetupRequired: false });
1842
- mockStateEvaluation(AuthFlowState.GRACE_PERIOD_ACTIVE);
1843
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
1844
- mockJwtService.generateTokenPair.mockResolvedValue({
1845
- accessToken: 'access-token',
1846
- refreshToken: 'refresh-token',
1847
- expiresIn: 900,
1848
- });
1849
- mockJwtService.hashToken.mockReturnValue('token-hash');
1850
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
1851
- mockJwtService.validateAccessToken.mockResolvedValue({
1852
- valid: true,
1853
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
1854
- });
1855
- mockJwtService.validateRefreshToken.mockResolvedValue({
1856
- valid: true,
1857
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
1858
- });
1859
-
1860
- const result = await service.determineAuthResponse({ user, config });
1861
-
1862
- expect(result.challengeName).toBeUndefined();
1863
- expect(result.accessToken).toBe('access-token');
1864
- });
1865
-
1866
- it('should return VERIFY_EMAIL first when gracePeriod is 0 and verificationMethod is both', async () => {
1867
- const config: NAuthConfig = {
1868
- ...mockConfig,
1869
- signup: { verificationMethod: 'both' },
1870
- mfa: { enabled: true, enforcement: 'ADAPTIVE', gracePeriod: 0 },
1871
- };
1872
- const service = createServiceWithMocks(config);
1873
- const user = { ...mockUser, isEmailVerified: false, isPhoneVerified: false, mfaEnabled: false } as IUser;
1874
- mockContextBuild({
1875
- isEmailVerificationRequired: true,
1876
- isPhoneVerificationRequired: true,
1877
- isMFASetupRequired: true,
1878
- });
1879
- mockStateEvaluation(AuthFlowState.PENDING_EMAIL_VERIFICATION, AuthChallenge.VERIFY_EMAIL);
1880
- mockChallengeService.createChallengeSession.mockResolvedValue(
1881
- createMockChallengeSession('session-123', AuthChallenge.VERIFY_EMAIL),
1882
- );
1883
-
1884
- const result = await service.determineAuthResponse({ user, config });
1885
-
1886
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_EMAIL);
1887
- });
1888
-
1889
- it('should return SUCCESS when gracePeriod is 7 days and verificationMethod is both', async () => {
1890
- const config: NAuthConfig = {
1891
- ...mockConfig,
1892
- signup: { verificationMethod: 'both' },
1893
- mfa: { enabled: true, enforcement: 'ADAPTIVE', gracePeriod: 7 },
1894
- };
1895
- const service = createServiceWithMocks(config);
1896
- const user = {
1897
- ...mockUser,
1898
- isEmailVerified: true,
1899
- isPhoneVerified: true,
1900
- mfaEnabled: false,
1901
- createdAt: new Date(),
1902
- } as IUser;
1903
- mockContextBuild({ isGracePeriodActive: true, isMFASetupRequired: false });
1904
- mockStateEvaluation(AuthFlowState.GRACE_PERIOD_ACTIVE);
1905
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
1906
- mockJwtService.generateTokenPair.mockResolvedValue({
1907
- accessToken: 'access-token',
1908
- refreshToken: 'refresh-token',
1909
- expiresIn: 900,
1910
- });
1911
- mockJwtService.hashToken.mockReturnValue('token-hash');
1912
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
1913
- mockJwtService.validateAccessToken.mockResolvedValue({
1914
- valid: true,
1915
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
1916
- });
1917
- mockJwtService.validateRefreshToken.mockResolvedValue({
1918
- valid: true,
1919
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
1920
- });
1921
-
1922
- const result = await service.determineAuthResponse({ user, config });
1923
-
1924
- expect(result.challengeName).toBeUndefined();
1925
- expect(result.accessToken).toBe('access-token');
1926
- });
1927
- });
1928
-
1929
- // ============================================================================
1930
- // Login Scenarios - MFA OPTIONAL
1931
- // ============================================================================
1932
-
1933
- describe('Login - MFA OPTIONAL', () => {
1934
- it('should return SUCCESS when MFA not enabled', async () => {
1935
- const config: NAuthConfig = {
1936
- ...mockConfig,
1937
- signup: { verificationMethod: 'none' },
1938
- mfa: { enabled: true, enforcement: 'OPTIONAL' },
1939
- };
1940
- const service = createServiceWithMocks(config);
1941
- const user = { ...mockUser, mfaEnabled: false } as IUser;
1942
- mockContextBuild({ isMFAVerificationRequired: false });
1943
- mockStateEvaluation(AuthFlowState.AUTHENTICATED);
1944
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
1945
- mockJwtService.generateTokenPair.mockResolvedValue({
1946
- accessToken: 'access-token',
1947
- refreshToken: 'refresh-token',
1948
- expiresIn: 900,
1949
- });
1950
- mockJwtService.hashToken.mockReturnValue('token-hash');
1951
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
1952
- mockJwtService.validateAccessToken.mockResolvedValue({
1953
- valid: true,
1954
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
1955
- });
1956
- mockJwtService.validateRefreshToken.mockResolvedValue({
1957
- valid: true,
1958
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
1959
- });
1960
-
1961
- const result = await service.determineAuthResponse({ user, config });
1962
-
1963
- expect(result.challengeName).toBeUndefined();
1964
- });
1965
-
1966
- it('should return SUCCESS when MFA enabled and device is trusted with bypassMFAForTrustedDevices = true', async () => {
1967
- const config: NAuthConfig = {
1968
- ...mockConfig,
1969
- signup: { verificationMethod: 'none' },
1970
- mfa: {
1971
- enabled: true,
1972
- enforcement: 'OPTIONAL',
1973
- rememberDevices: 'user_opt_in',
1974
- bypassMFAForTrustedDevices: true,
1975
- },
1976
- };
1977
- const service = createServiceWithMocks(config);
1978
- const user = { ...mockUser, mfaEnabled: true } as IUser;
1979
- mockContextBuild({ isDeviceTrusted: true, isMFAVerificationRequired: false });
1980
- mockStateEvaluation(AuthFlowState.AUTHENTICATED);
1981
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
1982
- mockJwtService.generateTokenPair.mockResolvedValue({
1983
- accessToken: 'access-token',
1984
- refreshToken: 'refresh-token',
1985
- expiresIn: 900,
1986
- });
1987
- mockJwtService.hashToken.mockReturnValue('token-hash');
1988
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
1989
- mockJwtService.validateAccessToken.mockResolvedValue({
1990
- valid: true,
1991
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
1992
- });
1993
- mockJwtService.validateRefreshToken.mockResolvedValue({
1994
- valid: true,
1995
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
1996
- });
1997
-
1998
- const result = await service.determineAuthResponse({
1999
- user,
2000
- config,
2001
- deviceToken: 'device-token-123',
2002
- });
2003
-
2004
- expect(result.challengeName).toBeUndefined();
2005
- });
2006
-
2007
- it('should return MFA_REQUIRED when MFA enabled and device is untrusted', async () => {
2008
- const config: NAuthConfig = {
2009
- ...mockConfig,
2010
- signup: { verificationMethod: 'none' },
2011
- mfa: { enabled: true, enforcement: 'OPTIONAL' },
2012
- };
2013
- const service = createServiceWithMocks(config);
2014
- const user = { ...mockUser, mfaEnabled: true } as IUser;
2015
- mockContextBuild({ isDeviceTrusted: false, isMFAVerificationRequired: true });
2016
- mockStateEvaluation(AuthFlowState.PENDING_MFA_VERIFICATION, AuthChallenge.MFA_REQUIRED);
2017
- mockMFADeviceRepository.find.mockResolvedValue([
2018
- { id: 1, userId: 1, type: MFAMethod.TOTP, isActive: true } as IMFADevice,
2019
- ]);
2020
- mockChallengeService.createChallengeSession.mockResolvedValue(
2021
- createMockChallengeSession('session-123', AuthChallenge.MFA_REQUIRED),
2022
- );
2023
-
2024
- const result = await service.determineAuthResponse({ user, config });
2025
-
2026
- expect(result.challengeName).toBe(AuthChallenge.MFA_REQUIRED);
2027
- });
2028
-
2029
- it('should return FORCE_CHANGE_PASSWORD when mustChangePassword is true', async () => {
2030
- const config: NAuthConfig = {
2031
- ...mockConfig,
2032
- signup: { verificationMethod: 'none' },
2033
- };
2034
- const service = createServiceWithMocks(config);
2035
- const user = { ...mockUser, mustChangePassword: true } as IUser;
2036
- mockContextBuild();
2037
- mockStateEvaluation(AuthFlowState.PENDING_PASSWORD_CHANGE, AuthChallenge.FORCE_CHANGE_PASSWORD);
2038
- mockChallengeService.createChallengeSession.mockResolvedValue(
2039
- createMockChallengeSession('session-123', AuthChallenge.FORCE_CHANGE_PASSWORD),
2040
- );
2041
-
2042
- const result = await service.determineAuthResponse({ user, config });
2043
-
2044
- expect(result.challengeName).toBe(AuthChallenge.FORCE_CHANGE_PASSWORD);
2045
- });
2046
-
2047
- it('should skip MFA even when mustChangePassword=true if mfaExempt=true', async () => {
2048
- // Note: mustChangePassword takes priority over mfaExempt
2049
- // User must change password first, but MFA checks are bypassed after password change
2050
- const config: NAuthConfig = {
2051
- ...mockConfig,
2052
- signup: { verificationMethod: 'none' },
2053
- mfa: { enabled: true, enforcement: 'REQUIRED' },
2054
- };
2055
- const service = createServiceWithMocks(config);
2056
- const user = { ...mockUser, mustChangePassword: true, mfaExempt: true, mfaEnabled: false } as IUser;
2057
- // Password change takes priority, but mfaExempt means no MFA after password change
2058
- mockContextBuild({ isMFAExempt: true, isMFASetupRequired: false, isMFAVerificationRequired: false });
2059
- mockStateEvaluation(AuthFlowState.PENDING_PASSWORD_CHANGE, AuthChallenge.FORCE_CHANGE_PASSWORD);
2060
- mockChallengeService.createChallengeSession.mockResolvedValue(
2061
- createMockChallengeSession('session-123', AuthChallenge.FORCE_CHANGE_PASSWORD),
2062
- );
2063
-
2064
- const result = await service.determineAuthResponse({ user, config });
2065
-
2066
- // Password change is required first (takes priority)
2067
- expect(result.challengeName).toBe(AuthChallenge.FORCE_CHANGE_PASSWORD);
2068
- // After password change, flow re-evaluates and mfaExempt will bypass MFA
2069
- });
2070
-
2071
- it('should return SUCCESS when mfaExempt is true (bypasses all MFA checks)', async () => {
2072
- const config: NAuthConfig = {
2073
- ...mockConfig,
2074
- signup: { verificationMethod: 'none' },
2075
- mfa: { enabled: true, enforcement: 'REQUIRED' },
2076
- };
2077
- const service = createServiceWithMocks(config);
2078
- // CRITICAL: User must have mfaExempt field set (simulating real database query)
2079
- const user = { ...mockUser, mfaExempt: true, mfaEnabled: true } as IUser;
2080
- // Verify context builder is called with user that has mfaExempt
2081
- mockContextBuilder.build.mockImplementation(async (params) => {
2082
- // Verify user.mfaExempt is actually checked (not just mocked)
2083
- const isMFAExempt = params.user.mfaExempt === true || (params.user.mfaExempt as unknown) === 1;
2084
- return {
2085
- user: params.user,
2086
- config: params.config,
2087
- authMethod: params.authMethod,
2088
- authProvider: params.authProvider,
2089
- deviceToken: params.deviceToken,
2090
- skipMFAVerification: params.skipMFAVerification,
2091
- computed: {
2092
- isEmailVerificationRequired: false,
2093
- isPhoneVerificationRequired: false,
2094
- isPhoneCollectionNeeded: false,
2095
- isMFAExempt, // Use actual user.mfaExempt value
2096
- isMFASetupRequired: false,
2097
- isMFAVerificationRequired: false, // Should be false when exempt
2098
- isDeviceTrusted: false,
2099
- isGracePeriodActive: false,
2100
- isBlocked: false,
2101
- },
2102
- } as AuthFlowContext;
2103
- });
2104
- mockStateEvaluation(AuthFlowState.AUTHENTICATED);
2105
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
2106
- mockJwtService.generateTokenPair.mockResolvedValue({
2107
- accessToken: 'access-token',
2108
- refreshToken: 'refresh-token',
2109
- expiresIn: 900,
2110
- });
2111
- mockJwtService.hashToken.mockReturnValue('token-hash');
2112
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
2113
- mockJwtService.validateAccessToken.mockResolvedValue({
2114
- valid: true,
2115
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
2116
- });
2117
- mockJwtService.validateRefreshToken.mockResolvedValue({
2118
- valid: true,
2119
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
2120
- });
2121
-
2122
- const result = await service.determineAuthResponse({ user, config });
2123
-
2124
- expect(result.challengeName).toBeUndefined();
2125
- // Verify context builder was called with user that has mfaExempt
2126
- expect(mockContextBuilder.build).toHaveBeenCalled();
2127
- const buildCall = mockContextBuilder.build.mock.calls[0]?.[0];
2128
- expect(buildCall?.user?.mfaExempt).toBe(true);
2129
- });
2130
- });
2131
-
2132
- // ============================================================================
2133
- // Login Scenarios - MFA REQUIRED
2134
- // ============================================================================
2135
-
2136
- describe('Login - MFA REQUIRED', () => {
2137
- it('should return MFA_SETUP_REQUIRED when MFA not enabled and gracePeriod is 0', async () => {
2138
- const config: NAuthConfig = {
2139
- ...mockConfig,
2140
- signup: { verificationMethod: 'none' },
2141
- mfa: { enabled: true, enforcement: 'REQUIRED', gracePeriod: 0 },
2142
- };
2143
- const service = createServiceWithMocks(config);
2144
- const user = { ...mockUser, mfaEnabled: false, createdAt: new Date() } as IUser;
2145
- mockContextBuild({ isMFASetupRequired: true });
2146
- mockStateEvaluation(AuthFlowState.PENDING_MFA_SETUP, AuthChallenge.MFA_SETUP_REQUIRED);
2147
- mockChallengeService.createChallengeSession.mockResolvedValue(
2148
- createMockChallengeSession('session-123', AuthChallenge.MFA_SETUP_REQUIRED),
2149
- );
2150
-
2151
- const result = await service.determineAuthResponse({ user, config });
2152
-
2153
- expect(result.challengeName).toBe(AuthChallenge.MFA_SETUP_REQUIRED);
2154
- });
2155
-
2156
- it('should return SUCCESS when MFA not enabled and gracePeriod is 7 days (active)', async () => {
2157
- const config: NAuthConfig = {
2158
- ...mockConfig,
2159
- signup: { verificationMethod: 'none' },
2160
- mfa: { enabled: true, enforcement: 'REQUIRED', gracePeriod: 7 },
2161
- };
2162
- const service = createServiceWithMocks(config);
2163
- const user = { ...mockUser, mfaEnabled: false, createdAt: new Date() } as IUser;
2164
- mockContextBuild({ isGracePeriodActive: true, isMFASetupRequired: false });
2165
- mockStateEvaluation(AuthFlowState.GRACE_PERIOD_ACTIVE);
2166
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
2167
- mockJwtService.generateTokenPair.mockResolvedValue({
2168
- accessToken: 'access-token',
2169
- refreshToken: 'refresh-token',
2170
- expiresIn: 900,
2171
- });
2172
- mockJwtService.hashToken.mockReturnValue('token-hash');
2173
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
2174
- mockJwtService.validateAccessToken.mockResolvedValue({
2175
- valid: true,
2176
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
2177
- });
2178
- mockJwtService.validateRefreshToken.mockResolvedValue({
2179
- valid: true,
2180
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
2181
- });
2182
-
2183
- const result = await service.determineAuthResponse({ user, config });
2184
-
2185
- expect(result.challengeName).toBeUndefined();
2186
- });
2187
-
2188
- it('should trigger MFA_SETUP_REQUIRED when grace period expired', async () => {
2189
- const config: NAuthConfig = {
2190
- ...mockConfig,
2191
- signup: { verificationMethod: 'none' },
2192
- mfa: { enabled: true, enforcement: 'REQUIRED', gracePeriod: 7 },
2193
- };
2194
- const service = createServiceWithMocks(config);
2195
- // User created 10 days ago, grace period was 7 days, so it's expired
2196
- const user = {
2197
- ...mockUser,
2198
- mfaEnabled: false,
2199
- createdAt: new Date(Date.now() - 10 * 24 * 60 * 60 * 1000),
2200
- } as IUser;
2201
- mockContextBuild({ isGracePeriodActive: false, isMFASetupRequired: true });
2202
- mockStateEvaluation(AuthFlowState.PENDING_MFA_SETUP, AuthChallenge.MFA_SETUP_REQUIRED);
2203
- mockChallengeService.createChallengeSession.mockResolvedValue(
2204
- createMockChallengeSession('session-123', AuthChallenge.MFA_SETUP_REQUIRED),
2205
- );
2206
-
2207
- const result = await service.determineAuthResponse({ user, config });
2208
-
2209
- expect(result.challengeName).toBe(AuthChallenge.MFA_SETUP_REQUIRED);
2210
- });
2211
-
2212
- it('should return MFA_REQUIRED when MFA enabled and device is untrusted', async () => {
2213
- const config: NAuthConfig = {
2214
- ...mockConfig,
2215
- signup: { verificationMethod: 'none' },
2216
- mfa: { enabled: true, enforcement: 'REQUIRED' },
2217
- };
2218
- const service = createServiceWithMocks(config);
2219
- const user = { ...mockUser, mfaEnabled: true } as IUser;
2220
- mockContextBuild({ isDeviceTrusted: false, isMFAVerificationRequired: true });
2221
- mockStateEvaluation(AuthFlowState.PENDING_MFA_VERIFICATION, AuthChallenge.MFA_REQUIRED);
2222
- mockMFADeviceRepository.find.mockResolvedValue([
2223
- { id: 1, userId: 1, type: MFAMethod.TOTP, isActive: true } as IMFADevice,
2224
- ]);
2225
- mockChallengeService.createChallengeSession.mockResolvedValue(
2226
- createMockChallengeSession('session-123', AuthChallenge.MFA_REQUIRED),
2227
- );
2228
-
2229
- const result = await service.determineAuthResponse({ user, config });
2230
-
2231
- expect(result.challengeName).toBe(AuthChallenge.MFA_REQUIRED);
2232
- });
2233
-
2234
- it('should return SUCCESS when MFA enabled, device trusted, and bypassMFAForTrustedDevices is true', async () => {
2235
- const config: NAuthConfig = {
2236
- ...mockConfig,
2237
- signup: { verificationMethod: 'none' },
2238
- mfa: {
2239
- enabled: true,
2240
- enforcement: 'REQUIRED',
2241
- rememberDevices: 'user_opt_in',
2242
- bypassMFAForTrustedDevices: true,
2243
- },
2244
- };
2245
- const service = createServiceWithMocks(config);
2246
- const user = { ...mockUser, mfaEnabled: true } as IUser;
2247
- mockContextBuild({ isDeviceTrusted: true, isMFAVerificationRequired: false });
2248
- mockStateEvaluation(AuthFlowState.AUTHENTICATED);
2249
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
2250
- mockJwtService.generateTokenPair.mockResolvedValue({
2251
- accessToken: 'access-token',
2252
- refreshToken: 'refresh-token',
2253
- expiresIn: 900,
2254
- });
2255
- mockJwtService.hashToken.mockReturnValue('token-hash');
2256
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
2257
- mockJwtService.validateAccessToken.mockResolvedValue({
2258
- valid: true,
2259
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
2260
- });
2261
- mockJwtService.validateRefreshToken.mockResolvedValue({
2262
- valid: true,
2263
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
2264
- });
2265
-
2266
- const result = await service.determineAuthResponse({
2267
- user,
2268
- config,
2269
- deviceToken: 'device-token-123',
2270
- });
2271
-
2272
- expect(result.challengeName).toBeUndefined();
2273
- expect(result.accessToken).toBe('access-token');
2274
- });
2275
-
2276
- it('should return MFA_REQUIRED when MFA enabled, device trusted, and bypassMFAForTrustedDevices is false', async () => {
2277
- const config: NAuthConfig = {
2278
- ...mockConfig,
2279
- signup: { verificationMethod: 'none' },
2280
- mfa: {
2281
- enabled: true,
2282
- enforcement: 'REQUIRED',
2283
- rememberDevices: 'user_opt_in',
2284
- bypassMFAForTrustedDevices: false,
2285
- },
2286
- };
2287
- const service = createServiceWithMocks(config);
2288
- const user = { ...mockUser, mfaEnabled: true } as IUser;
2289
- mockContextBuild({ isDeviceTrusted: true, isMFAVerificationRequired: true });
2290
- mockStateEvaluation(AuthFlowState.PENDING_MFA_VERIFICATION, AuthChallenge.MFA_REQUIRED);
2291
- mockMFADeviceRepository.find.mockResolvedValue([
2292
- { id: 1, userId: 1, type: MFAMethod.TOTP, isActive: true } as IMFADevice,
2293
- ]);
2294
- mockChallengeService.createChallengeSession.mockResolvedValue(
2295
- createMockChallengeSession('session-123', AuthChallenge.MFA_REQUIRED),
2296
- );
2297
-
2298
- const result = await service.determineAuthResponse({
2299
- user,
2300
- config,
2301
- deviceToken: 'device-token-123',
2302
- });
2303
-
2304
- expect(result.challengeName).toBe(AuthChallenge.MFA_REQUIRED);
2305
- });
2306
- });
2307
-
2308
- // ============================================================================
2309
- // Login Scenarios - MFA ADAPTIVE
2310
- // ============================================================================
2311
-
2312
- describe('Login - MFA ADAPTIVE', () => {
2313
- it('should return SUCCESS with gracePeriodEndsAt when grace period active and MFA not enabled', async () => {
2314
- const config: NAuthConfig = {
2315
- ...mockConfig,
2316
- signup: { verificationMethod: 'none' },
2317
- mfa: { enabled: true, enforcement: 'ADAPTIVE', gracePeriod: 7 },
2318
- };
2319
- const service = createServiceWithMocks(config);
2320
- const user = { ...mockUser, mfaEnabled: false, createdAt: new Date() } as IUser;
2321
- const gracePeriodEndsAt = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000); // 5 days from now
2322
- mockContextBuild({ isGracePeriodActive: true, isMFASetupRequired: false });
2323
- mockStateEvaluation(AuthFlowState.GRACE_PERIOD_ACTIVE, undefined, { gracePeriodEndsAt });
2324
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
2325
- mockJwtService.generateTokenPair.mockResolvedValue({
2326
- accessToken: 'access-token',
2327
- refreshToken: 'refresh-token',
2328
- expiresIn: 900,
2329
- });
2330
- mockJwtService.hashToken.mockReturnValue('token-hash');
2331
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
2332
- mockJwtService.validateAccessToken.mockResolvedValue({
2333
- valid: true,
2334
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
2335
- });
2336
- mockJwtService.validateRefreshToken.mockResolvedValue({
2337
- valid: true,
2338
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
2339
- });
2340
-
2341
- const result = await service.determineAuthResponse({ user, config });
2342
-
2343
- expect(result.challengeName).toBeUndefined();
2344
- expect((result as any).gracePeriodEndsAt).toEqual(gracePeriodEndsAt);
2345
- });
2346
-
2347
- it('should return MFA_REQUIRED when MFA enabled, device trusted, and risk is medium', async () => {
2348
- const config: NAuthConfig = {
2349
- ...mockConfig,
2350
- signup: { verificationMethod: 'none' },
2351
- mfa: {
2352
- enabled: true,
2353
- enforcement: 'ADAPTIVE',
2354
- rememberDevices: 'user_opt_in',
2355
- bypassMFAForTrustedDevices: true,
2356
- },
2357
- };
2358
- const service = createServiceWithMocks(config);
2359
- const user = { ...mockUser, mfaEnabled: true } as IUser;
2360
- mockContextBuild({
2361
- isDeviceTrusted: true,
2362
- isMFAVerificationRequired: true,
2363
- riskScore: 35,
2364
- riskLevel: 'medium',
2365
- });
2366
- mockStateEvaluation(AuthFlowState.PENDING_MFA_VERIFICATION, AuthChallenge.MFA_REQUIRED);
2367
- mockMFADeviceRepository.find.mockResolvedValue([
2368
- { id: 1, userId: 1, type: MFAMethod.TOTP, isActive: true } as IMFADevice,
2369
- ]);
2370
- mockChallengeService.createChallengeSession.mockResolvedValue(
2371
- createMockChallengeSession('session-123', AuthChallenge.MFA_REQUIRED),
2372
- );
2373
-
2374
- const result = await service.determineAuthResponse({
2375
- user,
2376
- config,
2377
- deviceToken: 'device-token-123',
2378
- });
2379
-
2380
- expect(result.challengeName).toBe(AuthChallenge.MFA_REQUIRED);
2381
- });
2382
-
2383
- it('should return MFA_REQUIRED when MFA enabled and device is untrusted (always required in ADAPTIVE)', async () => {
2384
- const config: NAuthConfig = {
2385
- ...mockConfig,
2386
- signup: { verificationMethod: 'none' },
2387
- mfa: {
2388
- enabled: true,
2389
- enforcement: 'ADAPTIVE',
2390
- rememberDevices: 'user_opt_in',
2391
- bypassMFAForTrustedDevices: true,
2392
- },
2393
- };
2394
- const service = createServiceWithMocks(config);
2395
- const user = { ...mockUser, mfaEnabled: true } as IUser;
2396
- mockContextBuild({ isDeviceTrusted: false, isMFAVerificationRequired: true, riskScore: 10 });
2397
- mockStateEvaluation(AuthFlowState.PENDING_MFA_VERIFICATION, AuthChallenge.MFA_REQUIRED);
2398
- mockMFADeviceRepository.find.mockResolvedValue([
2399
- { id: 1, userId: 1, type: MFAMethod.TOTP, isActive: true } as IMFADevice,
2400
- ]);
2401
- mockChallengeService.createChallengeSession.mockResolvedValue(
2402
- createMockChallengeSession('session-123', AuthChallenge.MFA_REQUIRED),
2403
- );
2404
-
2405
- const result = await service.determineAuthResponse({ user, config });
2406
-
2407
- expect(result.challengeName).toBe(AuthChallenge.MFA_REQUIRED);
2408
- });
2409
-
2410
- it('should throw BLOCKED error when risk is very high and user is blocked', async () => {
2411
- const config: NAuthConfig = {
2412
- ...mockConfig,
2413
- signup: { verificationMethod: 'none' },
2414
- mfa: {
2415
- enabled: true,
2416
- enforcement: 'ADAPTIVE',
2417
- adaptive: {
2418
- blockedSignIn: {
2419
- errorCode: AuthErrorCode.SIGNIN_BLOCKED_HIGH_RISK,
2420
- message: 'Sign-in blocked',
2421
- },
2422
- },
2423
- },
2424
- };
2425
- const service = createServiceWithMocks(config);
2426
- const user = { ...mockUser, mfaEnabled: true } as IUser;
2427
- const blockedUntil = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
2428
- mockContextBuild({ isBlocked: true, riskScore: 95, riskLevel: 'high' });
2429
- mockStateEvaluation(AuthFlowState.BLOCKED, undefined, { blockedUntil, reason: 'High risk detected' });
2430
-
2431
- try {
2432
- await service.determineAuthResponse({ user, config });
2433
- fail('Should have thrown NAuthException');
2434
- } catch (error) {
2435
- expect(error).toBeInstanceOf(NAuthException);
2436
- }
2437
- });
2438
-
2439
- it('should throw BLOCKED error when risk is very high on trusted device', async () => {
2440
- const config: NAuthConfig = {
2441
- ...mockConfig,
2442
- signup: { verificationMethod: 'none' },
2443
- mfa: {
2444
- enabled: true,
2445
- enforcement: 'ADAPTIVE',
2446
- rememberDevices: 'user_opt_in',
2447
- bypassMFAForTrustedDevices: true,
2448
- adaptive: {
2449
- blockedSignIn: {
2450
- errorCode: AuthErrorCode.SIGNIN_BLOCKED_HIGH_RISK,
2451
- message: 'Sign-in blocked',
2452
- },
2453
- },
2454
- },
2455
- };
2456
- const service = createServiceWithMocks(config);
2457
- const user = { ...mockUser, mfaEnabled: true } as IUser;
2458
- const blockedUntil = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
2459
- mockContextBuild({
2460
- isDeviceTrusted: true,
2461
- isBlocked: true,
2462
- riskScore: 95,
2463
- riskLevel: 'high',
2464
- });
2465
- mockStateEvaluation(AuthFlowState.BLOCKED, undefined, {
2466
- blockedUntil,
2467
- reason: 'High risk detected on trusted device',
2468
- });
2469
-
2470
- try {
2471
- await service.determineAuthResponse({
2472
- user,
2473
- config,
2474
- deviceToken: 'device-token-123',
2475
- });
2476
- fail('Should have thrown NAuthException');
2477
- } catch (error) {
2478
- expect(error).toBeInstanceOf(NAuthException);
2479
- }
2480
- });
2481
-
2482
- it('should return MFA_SETUP_REQUIRED when MFA not enabled and gracePeriod is 0', async () => {
2483
- const config: NAuthConfig = {
2484
- ...mockConfig,
2485
- signup: { verificationMethod: 'none' },
2486
- mfa: { enabled: true, enforcement: 'ADAPTIVE', gracePeriod: 0 },
2487
- };
2488
- const service = createServiceWithMocks(config);
2489
- const user = { ...mockUser, mfaEnabled: false, createdAt: new Date() } as IUser;
2490
- mockContextBuild({ isMFASetupRequired: true });
2491
- mockStateEvaluation(AuthFlowState.PENDING_MFA_SETUP, AuthChallenge.MFA_SETUP_REQUIRED);
2492
- mockChallengeService.createChallengeSession.mockResolvedValue(
2493
- createMockChallengeSession('session-123', AuthChallenge.MFA_SETUP_REQUIRED),
2494
- );
2495
-
2496
- const result = await service.determineAuthResponse({ user, config });
2497
-
2498
- expect(result.challengeName).toBe(AuthChallenge.MFA_SETUP_REQUIRED);
2499
- });
2500
-
2501
- it('should throw BLOCKED error when gracePeriod is 7 days, MFA not enabled, and risk is very high', async () => {
2502
- const config: NAuthConfig = {
2503
- ...mockConfig,
2504
- signup: { verificationMethod: 'none' },
2505
- mfa: {
2506
- enabled: true,
2507
- enforcement: 'ADAPTIVE',
2508
- gracePeriod: 7,
2509
- adaptive: {
2510
- blockedSignIn: {
2511
- errorCode: AuthErrorCode.SIGNIN_BLOCKED_HIGH_RISK,
2512
- message: 'Sign-in blocked',
2513
- },
2514
- },
2515
- },
2516
- };
2517
- const service = createServiceWithMocks(config);
2518
- const user = { ...mockUser, mfaEnabled: false, createdAt: new Date() } as IUser;
2519
- const blockedUntil = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
2520
- mockContextBuild({ isGracePeriodActive: true, isBlocked: true, riskScore: 95, riskLevel: 'high' });
2521
- mockStateEvaluation(AuthFlowState.BLOCKED, undefined, { blockedUntil, reason: 'High risk detected' });
2522
-
2523
- try {
2524
- await service.determineAuthResponse({ user, config });
2525
- fail('Should have thrown NAuthException');
2526
- } catch (error) {
2527
- expect(error).toBeInstanceOf(NAuthException);
2528
- }
2529
- });
2530
-
2531
- it('should return SUCCESS when MFA enabled, device trusted, and risk is low', async () => {
2532
- const config: NAuthConfig = {
2533
- ...mockConfig,
2534
- signup: { verificationMethod: 'none' },
2535
- mfa: {
2536
- enabled: true,
2537
- enforcement: 'ADAPTIVE',
2538
- rememberDevices: 'user_opt_in',
2539
- bypassMFAForTrustedDevices: true,
2540
- },
2541
- };
2542
- const service = createServiceWithMocks(config);
2543
- const user = { ...mockUser, mfaEnabled: true } as IUser;
2544
- mockContextBuild({
2545
- isDeviceTrusted: true,
2546
- isMFAVerificationRequired: false,
2547
- riskScore: 15,
2548
- riskLevel: 'low',
2549
- });
2550
- mockStateEvaluation(AuthFlowState.AUTHENTICATED);
2551
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
2552
- mockJwtService.generateTokenPair.mockResolvedValue({
2553
- accessToken: 'access-token',
2554
- refreshToken: 'refresh-token',
2555
- expiresIn: 900,
2556
- });
2557
- mockJwtService.hashToken.mockReturnValue('token-hash');
2558
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
2559
- mockJwtService.validateAccessToken.mockResolvedValue({
2560
- valid: true,
2561
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
2562
- });
2563
- mockJwtService.validateRefreshToken.mockResolvedValue({
2564
- valid: true,
2565
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
2566
- });
2567
-
2568
- const result = await service.determineAuthResponse({
2569
- user,
2570
- config,
2571
- deviceToken: 'device-token-123',
2572
- });
2573
-
2574
- expect(result.challengeName).toBeUndefined();
2575
- expect(result.accessToken).toBe('access-token');
2576
- });
2577
-
2578
- it('should return MFA_REQUIRED when MFA enabled, device trusted, and risk is high', async () => {
2579
- const config: NAuthConfig = {
2580
- ...mockConfig,
2581
- signup: { verificationMethod: 'none' },
2582
- mfa: {
2583
- enabled: true,
2584
- enforcement: 'ADAPTIVE',
2585
- rememberDevices: 'user_opt_in',
2586
- bypassMFAForTrustedDevices: true,
2587
- },
2588
- };
2589
- const service = createServiceWithMocks(config);
2590
- const user = { ...mockUser, mfaEnabled: true } as IUser;
2591
- mockContextBuild({
2592
- isDeviceTrusted: true,
2593
- isMFAVerificationRequired: true,
2594
- riskScore: 75,
2595
- riskLevel: 'high',
2596
- });
2597
- mockStateEvaluation(AuthFlowState.PENDING_MFA_VERIFICATION, AuthChallenge.MFA_REQUIRED);
2598
- mockMFADeviceRepository.find.mockResolvedValue([
2599
- { id: 1, userId: 1, type: MFAMethod.TOTP, isActive: true } as IMFADevice,
2600
- ]);
2601
- mockChallengeService.createChallengeSession.mockResolvedValue(
2602
- createMockChallengeSession('session-123', AuthChallenge.MFA_REQUIRED),
2603
- );
2604
-
2605
- const result = await service.determineAuthResponse({
2606
- user,
2607
- config,
2608
- deviceToken: 'device-token-123',
2609
- });
2610
-
2611
- expect(result.challengeName).toBe(AuthChallenge.MFA_REQUIRED);
2612
- });
2613
-
2614
- it('should return SUCCESS with riskScore when gracePeriod is active, MFA not enabled, and risk is medium', async () => {
2615
- const config: NAuthConfig = {
2616
- ...mockConfig,
2617
- signup: { verificationMethod: 'none' },
2618
- mfa: { enabled: true, enforcement: 'ADAPTIVE', gracePeriod: 7 },
2619
- };
2620
- const service = createServiceWithMocks(config);
2621
- const user = { ...mockUser, mfaEnabled: false, createdAt: new Date() } as IUser;
2622
- const gracePeriodEndsAt = new Date(Date.now() + 5 * 24 * 60 * 60 * 1000); // 5 days from now
2623
- mockContextBuild({ isGracePeriodActive: true, isMFASetupRequired: false, riskScore: 35, riskLevel: 'medium' });
2624
- mockStateEvaluation(AuthFlowState.GRACE_PERIOD_ACTIVE, undefined, { gracePeriodEndsAt, riskScore: 35 });
2625
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
2626
- mockJwtService.generateTokenPair.mockResolvedValue({
2627
- accessToken: 'access-token',
2628
- refreshToken: 'refresh-token',
2629
- expiresIn: 900,
2630
- });
2631
- mockJwtService.hashToken.mockReturnValue('token-hash');
2632
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
2633
- mockJwtService.validateAccessToken.mockResolvedValue({
2634
- valid: true,
2635
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
2636
- });
2637
- mockJwtService.validateRefreshToken.mockResolvedValue({
2638
- valid: true,
2639
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
2640
- });
2641
-
2642
- const result = await service.determineAuthResponse({ user, config });
2643
-
2644
- expect(result.challengeName).toBeUndefined();
2645
- expect((result as any).gracePeriodEndsAt).toEqual(gracePeriodEndsAt);
2646
- });
2647
- });
2648
-
2649
- // ============================================================================
2650
- // Social Login Scenarios
2651
- // ============================================================================
2652
-
2653
- describe('Social Login', () => {
2654
- it('should return SUCCESS when requireForSocialLogin is false (default)', async () => {
2655
- const config: NAuthConfig = {
2656
- ...mockConfig,
2657
- signup: { verificationMethod: 'none' },
2658
- mfa: { enabled: true, enforcement: 'REQUIRED', requireForSocialLogin: false },
2659
- };
2660
- const service = createServiceWithMocks(config);
2661
- const user = { ...mockUser, isEmailVerified: true, mfaEnabled: true } as IUser;
2662
- mockContextBuild({ isMFAVerificationRequired: false }); // MFA skipped for social
2663
- mockStateEvaluation(AuthFlowState.AUTHENTICATED);
2664
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
2665
- mockJwtService.generateTokenPair.mockResolvedValue({
2666
- accessToken: 'access-token',
2667
- refreshToken: 'refresh-token',
2668
- expiresIn: 900,
2669
- });
2670
- mockJwtService.hashToken.mockReturnValue('token-hash');
2671
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
2672
- mockJwtService.validateAccessToken.mockResolvedValue({
2673
- valid: true,
2674
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
2675
- });
2676
- mockJwtService.validateRefreshToken.mockResolvedValue({
2677
- valid: true,
2678
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
2679
- });
2680
-
2681
- const result = await service.determineAuthResponse({
2682
- user,
2683
- config,
2684
- isSocialLogin: true,
2685
- authProvider: 'google',
2686
- });
2687
-
2688
- expect(result.challengeName).toBeUndefined();
2689
- });
2690
-
2691
- it('should return VERIFY_PHONE when requireForSocialLogin is false and phone not verified', async () => {
2692
- const config: NAuthConfig = {
2693
- ...mockConfig,
2694
- signup: { verificationMethod: 'phone' },
2695
- mfa: { enabled: true, enforcement: 'REQUIRED', requireForSocialLogin: false },
2696
- };
2697
- const service = createServiceWithMocks(config);
2698
- const user = { ...mockUser, isEmailVerified: true, phone: '+1234567890', isPhoneVerified: false } as IUser;
2699
- mockContextBuild({ isPhoneVerificationRequired: true });
2700
- mockStateEvaluation(AuthFlowState.PENDING_PHONE_VERIFICATION, AuthChallenge.VERIFY_PHONE);
2701
- mockChallengeService.createChallengeSession.mockResolvedValue(
2702
- createMockChallengeSession('session-123', AuthChallenge.VERIFY_PHONE),
2703
- );
2704
-
2705
- const result = await service.determineAuthResponse({
2706
- user,
2707
- config,
2708
- isSocialLogin: true,
2709
- authProvider: 'google',
2710
- });
2711
-
2712
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_PHONE);
2713
- });
2714
-
2715
- it('should return MFA_REQUIRED when requireForSocialLogin is true and MFA enabled', async () => {
2716
- const config: NAuthConfig = {
2717
- ...mockConfig,
2718
- signup: { verificationMethod: 'none' },
2719
- mfa: { enabled: true, enforcement: 'OPTIONAL', requireForSocialLogin: true },
2720
- };
2721
- const service = createServiceWithMocks(config);
2722
- const user = { ...mockUser, isEmailVerified: true, mfaEnabled: true } as IUser;
2723
- mockContextBuild({ isMFAVerificationRequired: true });
2724
- mockStateEvaluation(AuthFlowState.PENDING_MFA_VERIFICATION, AuthChallenge.MFA_REQUIRED);
2725
- mockMFADeviceRepository.find.mockResolvedValue([
2726
- { id: 1, userId: 1, type: MFAMethod.TOTP, isActive: true } as IMFADevice,
2727
- ]);
2728
- mockChallengeService.createChallengeSession.mockResolvedValue(
2729
- createMockChallengeSession('session-123', AuthChallenge.MFA_REQUIRED),
2730
- );
2731
-
2732
- const result = await service.determineAuthResponse({
2733
- user,
2734
- config,
2735
- isSocialLogin: true,
2736
- authProvider: 'google',
2737
- });
2738
-
2739
- expect(result.challengeName).toBe(AuthChallenge.MFA_REQUIRED);
2740
- });
2741
-
2742
- it('should return SUCCESS when requireForSocialLogin is false and verificationMethod is email (email pre-verified)', async () => {
2743
- const config: NAuthConfig = {
2744
- ...mockConfig,
2745
- signup: { verificationMethod: 'email' },
2746
- mfa: { enabled: true, enforcement: 'REQUIRED', requireForSocialLogin: false },
2747
- };
2748
- const service = createServiceWithMocks(config);
2749
- const user = { ...mockUser, isEmailVerified: true, mfaEnabled: true } as IUser;
2750
- mockContextBuild({ isMFAVerificationRequired: false });
2751
- mockStateEvaluation(AuthFlowState.AUTHENTICATED);
2752
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
2753
- mockJwtService.generateTokenPair.mockResolvedValue({
2754
- accessToken: 'access-token',
2755
- refreshToken: 'refresh-token',
2756
- expiresIn: 900,
2757
- });
2758
- mockJwtService.hashToken.mockReturnValue('token-hash');
2759
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
2760
- mockJwtService.validateAccessToken.mockResolvedValue({
2761
- valid: true,
2762
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
2763
- });
2764
- mockJwtService.validateRefreshToken.mockResolvedValue({
2765
- valid: true,
2766
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
2767
- });
2768
-
2769
- const result = await service.determineAuthResponse({
2770
- user,
2771
- config,
2772
- isSocialLogin: true,
2773
- authProvider: 'google',
2774
- });
2775
-
2776
- expect(result.challengeName).toBeUndefined();
2777
- expect(result.accessToken).toBe('access-token');
2778
- });
2779
-
2780
- it('should return MFA_SETUP_REQUIRED when requireForSocialLogin is true, MFA REQUIRED, gracePeriod=0, and MFA not enabled', async () => {
2781
- const config: NAuthConfig = {
2782
- ...mockConfig,
2783
- signup: { verificationMethod: 'none' },
2784
- mfa: { enabled: true, enforcement: 'REQUIRED', requireForSocialLogin: true, gracePeriod: 0 },
2785
- };
2786
- const service = createServiceWithMocks(config);
2787
- const user = { ...mockUser, isEmailVerified: true, mfaEnabled: false, createdAt: new Date() } as IUser;
2788
- mockContextBuild({ isMFASetupRequired: true });
2789
- mockStateEvaluation(AuthFlowState.PENDING_MFA_SETUP, AuthChallenge.MFA_SETUP_REQUIRED);
2790
- mockChallengeService.createChallengeSession.mockResolvedValue(
2791
- createMockChallengeSession('session-123', AuthChallenge.MFA_SETUP_REQUIRED),
2792
- );
2793
-
2794
- const result = await service.determineAuthResponse({
2795
- user,
2796
- config,
2797
- isSocialLogin: true,
2798
- authProvider: 'google',
2799
- });
2800
-
2801
- expect(result.challengeName).toBe(AuthChallenge.MFA_SETUP_REQUIRED);
2802
- });
2803
-
2804
- it('should return SUCCESS when requireForSocialLogin is true, MFA REQUIRED, gracePeriod=7, and MFA not enabled', async () => {
2805
- const config: NAuthConfig = {
2806
- ...mockConfig,
2807
- signup: { verificationMethod: 'none' },
2808
- mfa: { enabled: true, enforcement: 'REQUIRED', requireForSocialLogin: true, gracePeriod: 7 },
2809
- };
2810
- const service = createServiceWithMocks(config);
2811
- const user = { ...mockUser, isEmailVerified: true, mfaEnabled: false, createdAt: new Date() } as IUser;
2812
- mockContextBuild({ isGracePeriodActive: true, isMFASetupRequired: false });
2813
- mockStateEvaluation(AuthFlowState.GRACE_PERIOD_ACTIVE);
2814
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
2815
- mockJwtService.generateTokenPair.mockResolvedValue({
2816
- accessToken: 'access-token',
2817
- refreshToken: 'refresh-token',
2818
- expiresIn: 900,
2819
- });
2820
- mockJwtService.hashToken.mockReturnValue('token-hash');
2821
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
2822
- mockJwtService.validateAccessToken.mockResolvedValue({
2823
- valid: true,
2824
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
2825
- });
2826
- mockJwtService.validateRefreshToken.mockResolvedValue({
2827
- valid: true,
2828
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
2829
- });
2830
-
2831
- const result = await service.determineAuthResponse({
2832
- user,
2833
- config,
2834
- isSocialLogin: true,
2835
- authProvider: 'google',
2836
- });
2837
-
2838
- expect(result.challengeName).toBeUndefined();
2839
- expect(result.accessToken).toBe('access-token');
2840
- });
2841
-
2842
- it('should return VERIFY_PHONE then MFA_REQUIRED when requireForSocialLogin is true, MFA enabled, and phone not verified', async () => {
2843
- const config: NAuthConfig = {
2844
- ...mockConfig,
2845
- signup: { verificationMethod: 'phone' },
2846
- mfa: { enabled: true, enforcement: 'OPTIONAL', requireForSocialLogin: true },
2847
- };
2848
- const service = createServiceWithMocks(config);
2849
- const user = {
2850
- ...mockUser,
2851
- isEmailVerified: true,
2852
- phone: '+1234567890',
2853
- isPhoneVerified: false,
2854
- mfaEnabled: true,
2855
- } as IUser;
2856
- mockContextBuild({ isPhoneVerificationRequired: true });
2857
- mockStateEvaluation(AuthFlowState.PENDING_PHONE_VERIFICATION, AuthChallenge.VERIFY_PHONE);
2858
- mockChallengeService.createChallengeSession.mockResolvedValue(
2859
- createMockChallengeSession('session-123', AuthChallenge.VERIFY_PHONE),
2860
- );
2861
-
2862
- const result = await service.determineAuthResponse({
2863
- user,
2864
- config,
2865
- isSocialLogin: true,
2866
- authProvider: 'google',
2867
- });
2868
-
2869
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_PHONE);
2870
- });
2871
-
2872
- it('should return SUCCESS when requireForSocialLogin is true, MFA ADAPTIVE, device trusted, and risk is low', async () => {
2873
- const config: NAuthConfig = {
2874
- ...mockConfig,
2875
- signup: { verificationMethod: 'none' },
2876
- mfa: {
2877
- enabled: true,
2878
- enforcement: 'ADAPTIVE',
2879
- requireForSocialLogin: true,
2880
- rememberDevices: 'user_opt_in',
2881
- bypassMFAForTrustedDevices: true,
2882
- },
2883
- };
2884
- const service = createServiceWithMocks(config);
2885
- const user = { ...mockUser, isEmailVerified: true, mfaEnabled: true } as IUser;
2886
- mockContextBuild({
2887
- isDeviceTrusted: true,
2888
- isMFAVerificationRequired: false,
2889
- riskScore: 15,
2890
- riskLevel: 'low',
2891
- });
2892
- mockStateEvaluation(AuthFlowState.AUTHENTICATED);
2893
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
2894
- mockJwtService.generateTokenPair.mockResolvedValue({
2895
- accessToken: 'access-token',
2896
- refreshToken: 'refresh-token',
2897
- expiresIn: 900,
2898
- });
2899
- mockJwtService.hashToken.mockReturnValue('token-hash');
2900
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
2901
- mockJwtService.validateAccessToken.mockResolvedValue({
2902
- valid: true,
2903
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
2904
- });
2905
- mockJwtService.validateRefreshToken.mockResolvedValue({
2906
- valid: true,
2907
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
2908
- });
2909
-
2910
- const result = await service.determineAuthResponse({
2911
- user,
2912
- config,
2913
- isSocialLogin: true,
2914
- authProvider: 'google',
2915
- deviceToken: 'device-token-123',
2916
- });
2917
-
2918
- expect(result.challengeName).toBeUndefined();
2919
- expect(result.accessToken).toBe('access-token');
2920
- });
2921
-
2922
- it('should return MFA_REQUIRED when requireForSocialLogin is true, MFA ADAPTIVE, device trusted, and risk is medium', async () => {
2923
- const config: NAuthConfig = {
2924
- ...mockConfig,
2925
- signup: { verificationMethod: 'none' },
2926
- mfa: {
2927
- enabled: true,
2928
- enforcement: 'ADAPTIVE',
2929
- requireForSocialLogin: true,
2930
- rememberDevices: 'user_opt_in',
2931
- bypassMFAForTrustedDevices: true,
2932
- },
2933
- };
2934
- const service = createServiceWithMocks(config);
2935
- const user = { ...mockUser, isEmailVerified: true, mfaEnabled: true } as IUser;
2936
- mockContextBuild({
2937
- isDeviceTrusted: true,
2938
- isMFAVerificationRequired: true,
2939
- riskScore: 35,
2940
- riskLevel: 'medium',
2941
- });
2942
- mockStateEvaluation(AuthFlowState.PENDING_MFA_VERIFICATION, AuthChallenge.MFA_REQUIRED);
2943
- mockMFADeviceRepository.find.mockResolvedValue([
2944
- { id: 1, userId: 1, type: MFAMethod.TOTP, isActive: true } as IMFADevice,
2945
- ]);
2946
- mockChallengeService.createChallengeSession.mockResolvedValue(
2947
- createMockChallengeSession('session-123', AuthChallenge.MFA_REQUIRED),
2948
- );
2949
-
2950
- const result = await service.determineAuthResponse({
2951
- user,
2952
- config,
2953
- isSocialLogin: true,
2954
- authProvider: 'google',
2955
- deviceToken: 'device-token-123',
2956
- });
2957
-
2958
- expect(result.challengeName).toBe(AuthChallenge.MFA_REQUIRED);
2959
- });
2960
-
2961
- it('should block social login when adaptive risk is very high', async () => {
2962
- const config: NAuthConfig = {
2963
- ...mockConfig,
2964
- signup: { verificationMethod: 'none' },
2965
- mfa: {
2966
- enabled: true,
2967
- enforcement: 'ADAPTIVE',
2968
- requireForSocialLogin: true,
2969
- adaptive: {
2970
- blockedSignIn: {
2971
- errorCode: AuthErrorCode.SIGNIN_BLOCKED_HIGH_RISK,
2972
- message: 'Sign-in blocked',
2973
- },
2974
- },
2975
- },
2976
- };
2977
- const service = createServiceWithMocks(config);
2978
- const user = { ...mockUser, isEmailVerified: true, mfaEnabled: true } as IUser;
2979
- const blockedUntil = new Date(Date.now() + 60 * 60 * 1000); // 1 hour from now
2980
- mockContextBuild({ isBlocked: true, riskScore: 95, riskLevel: 'high' });
2981
- mockStateEvaluation(AuthFlowState.BLOCKED, undefined, { blockedUntil, reason: 'High risk detected' });
2982
-
2983
- try {
2984
- await service.determineAuthResponse({
2985
- user,
2986
- config,
2987
- isSocialLogin: true,
2988
- authProvider: 'google',
2989
- });
2990
- fail('Should have thrown NAuthException');
2991
- } catch (error) {
2992
- expect(error).toBeInstanceOf(NAuthException);
2993
- }
2994
- });
2995
- });
2996
-
2997
- // ============================================================================
2998
- // Special Cases
2999
- // ============================================================================
3000
-
3001
- describe('Special Cases', () => {
3002
- it('should return VERIFY_PHONE for phone collection when user has no phone', async () => {
3003
- const config: NAuthConfig = {
3004
- ...mockConfig,
3005
- signup: { verificationMethod: 'phone' },
3006
- };
3007
- const service = createServiceWithMocks(config);
3008
- const user = { ...mockUser, phone: null, isPhoneVerified: false } as IUser;
3009
- mockContextBuild({ isPhoneCollectionNeeded: true });
3010
- mockStateEvaluation(AuthFlowState.PENDING_PHONE_COLLECTION, AuthChallenge.VERIFY_PHONE);
3011
- mockChallengeService.createChallengeSession.mockResolvedValue(
3012
- createMockChallengeSession('session-123', AuthChallenge.VERIFY_PHONE),
3013
- );
3014
-
3015
- const result = await service.determineAuthResponse({ user, config });
3016
-
3017
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_PHONE);
3018
- // Note: requiresPhoneCollection is set by createChallengeResponse when phone is null
3019
- // This is tested in the createChallengeResponse tests
3020
- });
3021
-
3022
- it('should return preferred MFA method from user.preferredMfaMethod', async () => {
3023
- const config: NAuthConfig = {
3024
- ...mockConfig,
3025
- signup: { verificationMethod: 'none' },
3026
- mfa: { enabled: true, enforcement: 'REQUIRED' },
3027
- };
3028
- const service = createServiceWithMocks(config);
3029
- const user = {
3030
- ...mockUser,
3031
- mfaEnabled: true,
3032
- preferredMfaMethod: MFAMethod.PASSKEY,
3033
- } as IUser;
3034
- mockContextBuild({ isMFAVerificationRequired: true });
3035
- mockStateEvaluation(AuthFlowState.PENDING_MFA_VERIFICATION, AuthChallenge.MFA_REQUIRED);
3036
- mockMFADeviceRepository.find.mockResolvedValue([
3037
- { id: 1, userId: 1, type: MFAMethod.PASSKEY, isActive: true, isPrimary: true } as IMFADevice,
3038
- { id: 2, userId: 1, type: MFAMethod.SMS, isActive: true } as IMFADevice,
3039
- ]);
3040
- mockChallengeService.createChallengeSession.mockResolvedValue(
3041
- createMockChallengeSession('session-123', AuthChallenge.MFA_REQUIRED),
3042
- );
3043
-
3044
- const result = await service.determineAuthResponse({ user, config });
3045
-
3046
- expect(result.challengeName).toBe(AuthChallenge.MFA_REQUIRED);
3047
- expect(result.challengeParameters?.preferredMethod).toBe(MFAMethod.PASSKEY);
3048
- });
3049
-
3050
- it('should handle phone verification via MFA SMS when verificationMethod is none', async () => {
3051
- // Note: This tests that when phone verification is disabled but user sets up SMS MFA,
3052
- // completing SMS MFA verification will mark the phone as verified in the directory.
3053
- // This is handled by the MFA service, not the challenge helper, but we verify the flow works.
3054
- const config: NAuthConfig = {
3055
- ...mockConfig,
3056
- signup: { verificationMethod: 'none' },
3057
- mfa: { enabled: true, enforcement: 'REQUIRED' },
3058
- };
3059
- const service = createServiceWithMocks(config);
3060
- const user = {
3061
- ...mockUser,
3062
- mfaEnabled: true,
3063
- phone: '+1234567890',
3064
- isPhoneVerified: false, // Phone not verified via VERIFY_PHONE challenge
3065
- } as IUser;
3066
- mockContextBuild({ isMFAVerificationRequired: true });
3067
- mockStateEvaluation(AuthFlowState.PENDING_MFA_VERIFICATION, AuthChallenge.MFA_REQUIRED);
3068
- mockMFADeviceRepository.find.mockResolvedValue([
3069
- { id: 1, userId: 1, type: MFAMethod.SMS, isActive: true, phoneNumber: '+1234567890' } as IMFADevice,
3070
- ]);
3071
- mockChallengeService.createChallengeSession.mockResolvedValue(
3072
- createMockChallengeSession('session-123', AuthChallenge.MFA_REQUIRED),
3073
- );
3074
-
3075
- const result = await service.determineAuthResponse({ user, config });
3076
-
3077
- expect(result.challengeName).toBe(AuthChallenge.MFA_REQUIRED);
3078
- expect(result.challengeParameters?.availableMethods).toContain(MFAMethod.SMS);
3079
- // Note: Phone verification via MFA SMS is handled when MFA challenge is completed,
3080
- // not during challenge creation. This test verifies the challenge is created correctly.
3081
- });
3082
-
3083
- it('should handle phone already verified - SMS MFA setup auto-complete', async () => {
3084
- // Note: This tests that when phone is already verified and user sets up SMS MFA,
3085
- // the MFA setup auto-completes (no SMS challenge during setup).
3086
- // This is handled by the state machine's onEnter hook for PENDING_MFA_SETUP.
3087
- const config: NAuthConfig = {
3088
- ...mockConfig,
3089
- signup: { verificationMethod: 'phone' },
3090
- mfa: { enabled: true, enforcement: 'REQUIRED', gracePeriod: 0 },
3091
- };
3092
- const service = createServiceWithMocks(config);
3093
- const user = {
3094
- ...mockUser,
3095
- phone: '+1234567890',
3096
- isPhoneVerified: true, // Phone already verified
3097
- mfaEnabled: false,
3098
- } as IUser;
3099
- // When phone is verified and user sets up SMS MFA, the onEnter hook should auto-complete
3100
- // This means the state machine should transition directly to AUTHENTICATED or MFA_REQUIRED
3101
- // depending on enforcement. For this test, we verify the state machine handles it correctly.
3102
- mockContextBuild({ isMFASetupRequired: true });
3103
- // The onEnter hook for PENDING_MFA_SETUP will auto-complete SMS MFA if phone is verified
3104
- // This is tested at the state machine level, but we verify the flow works here
3105
- mockStateEvaluation(AuthFlowState.PENDING_MFA_SETUP, AuthChallenge.MFA_SETUP_REQUIRED);
3106
- mockChallengeService.createChallengeSession.mockResolvedValue(
3107
- createMockChallengeSession('session-123', AuthChallenge.MFA_SETUP_REQUIRED),
3108
- );
3109
-
3110
- const result = await service.determineAuthResponse({ user, config });
3111
-
3112
- expect(result.challengeName).toBe(AuthChallenge.MFA_SETUP_REQUIRED);
3113
- // Note: The auto-complete logic is in the state machine's onEnter hook.
3114
- // When SMS MFA is selected and phone is verified, setup should auto-complete.
3115
- // This is verified by checking that the challenge is created correctly.
3116
- });
3117
-
3118
- it('should re-evaluate sequential challenges correctly (FORCE_CHANGE_PASSWORD → VERIFY_EMAIL → VERIFY_PHONE → MFA_SETUP_REQUIRED → MFA_REQUIRED → SUCCESS)', async () => {
3119
- // This test simulates the full challenge completion chain
3120
- // After each challenge is completed, the flow re-evaluates from priority 1
3121
- const config: NAuthConfig = {
3122
- ...mockConfig,
3123
- signup: { verificationMethod: 'both' },
3124
- mfa: { enabled: true, enforcement: 'REQUIRED', gracePeriod: 0 },
3125
- };
3126
- const service = createServiceWithMocks(config);
3127
-
3128
- // Step 1: Initial state - user has all challenges pending
3129
- let user = {
3130
- ...mockUser,
3131
- mustChangePassword: true,
3132
- isEmailVerified: false,
3133
- isPhoneVerified: false,
3134
- mfaEnabled: false,
3135
- } as IUser;
3136
-
3137
- // Step 1: FORCE_CHANGE_PASSWORD (priority 1)
3138
- mockContextBuild({
3139
- isEmailVerificationRequired: true,
3140
- isPhoneVerificationRequired: true,
3141
- isMFASetupRequired: true,
3142
- });
3143
- mockStateEvaluation(AuthFlowState.PENDING_PASSWORD_CHANGE, AuthChallenge.FORCE_CHANGE_PASSWORD);
3144
- mockChallengeService.createChallengeSession.mockResolvedValue(
3145
- createMockChallengeSession('session-1', AuthChallenge.FORCE_CHANGE_PASSWORD),
3146
- );
3147
- let result = await service.determineAuthResponse({ user, config });
3148
- expect(result.challengeName).toBe(AuthChallenge.FORCE_CHANGE_PASSWORD);
3149
-
3150
- // Step 2: After password change, re-evaluate → VERIFY_EMAIL (priority 2)
3151
- user = { ...user, mustChangePassword: false } as IUser;
3152
- mockContextBuild({
3153
- isEmailVerificationRequired: true,
3154
- isPhoneVerificationRequired: true,
3155
- isMFASetupRequired: true,
3156
- });
3157
- mockStateEvaluation(AuthFlowState.PENDING_EMAIL_VERIFICATION, AuthChallenge.VERIFY_EMAIL);
3158
- mockChallengeService.createChallengeSession.mockResolvedValue(
3159
- createMockChallengeSession('session-2', AuthChallenge.VERIFY_EMAIL),
3160
- );
3161
- result = await service.determineAuthResponse({ user, config });
3162
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_EMAIL);
3163
-
3164
- // Step 3: After email verification, re-evaluate → VERIFY_PHONE (priority 4, after phone collection if needed)
3165
- user = { ...user, isEmailVerified: true } as IUser;
3166
- mockContextBuild({
3167
- isPhoneVerificationRequired: true,
3168
- isMFASetupRequired: true,
3169
- });
3170
- mockStateEvaluation(AuthFlowState.PENDING_PHONE_VERIFICATION, AuthChallenge.VERIFY_PHONE);
3171
- mockChallengeService.createChallengeSession.mockResolvedValue(
3172
- createMockChallengeSession('session-3', AuthChallenge.VERIFY_PHONE),
3173
- );
3174
- result = await service.determineAuthResponse({ user, config });
3175
- expect(result.challengeName).toBe(AuthChallenge.VERIFY_PHONE);
3176
-
3177
- // Step 4: After phone verification, re-evaluate → MFA_SETUP_REQUIRED (priority 5)
3178
- user = { ...user, isPhoneVerified: true } as IUser;
3179
- mockContextBuild({ isMFASetupRequired: true });
3180
- mockStateEvaluation(AuthFlowState.PENDING_MFA_SETUP, AuthChallenge.MFA_SETUP_REQUIRED);
3181
- mockChallengeService.createChallengeSession.mockResolvedValue(
3182
- createMockChallengeSession('session-4', AuthChallenge.MFA_SETUP_REQUIRED),
3183
- );
3184
- result = await service.determineAuthResponse({ user, config });
3185
- expect(result.challengeName).toBe(AuthChallenge.MFA_SETUP_REQUIRED);
3186
-
3187
- // Step 5: After MFA setup, re-evaluate → MFA_REQUIRED (priority 6)
3188
- user = { ...user, mfaEnabled: true } as IUser;
3189
- mockContextBuild({ isMFAVerificationRequired: true });
3190
- mockStateEvaluation(AuthFlowState.PENDING_MFA_VERIFICATION, AuthChallenge.MFA_REQUIRED);
3191
- mockMFADeviceRepository.find.mockResolvedValue([
3192
- { id: 1, userId: 1, type: MFAMethod.TOTP, isActive: true } as IMFADevice,
3193
- ]);
3194
- mockChallengeService.createChallengeSession.mockResolvedValue(
3195
- createMockChallengeSession('session-5', AuthChallenge.MFA_REQUIRED),
3196
- );
3197
- result = await service.determineAuthResponse({ user, config });
3198
- expect(result.challengeName).toBe(AuthChallenge.MFA_REQUIRED);
3199
-
3200
- // Step 6: After MFA verification, re-evaluate → SUCCESS
3201
- // Note: In real flow, MFA verification happens via completeChallenge, which then calls determineAuthResponse again
3202
- // This simulates the final state after all challenges are complete
3203
- mockContextBuild({ isMFAVerificationRequired: false });
3204
- mockStateEvaluation(AuthFlowState.AUTHENTICATED);
3205
- mockJwtService.generateTokenFamily.mockReturnValue('family-xyz');
3206
- mockJwtService.generateTokenPair.mockResolvedValue({
3207
- accessToken: 'access-token',
3208
- refreshToken: 'refresh-token',
3209
- expiresIn: 900,
3210
- });
3211
- mockJwtService.hashToken.mockReturnValue('token-hash');
3212
- mockSessionService.createSession.mockResolvedValue({ id: 1 } as any);
3213
- mockJwtService.validateAccessToken.mockResolvedValue({
3214
- valid: true,
3215
- payload: { exp: Math.floor(Date.now() / 1000) + 900 } as any,
3216
- });
3217
- mockJwtService.validateRefreshToken.mockResolvedValue({
3218
- valid: true,
3219
- payload: { exp: Math.floor(Date.now() / 1000) + 2592000 } as any,
3220
- });
3221
- result = await service.determineAuthResponse({ user, config });
3222
- expect(result.challengeName).toBeUndefined();
3223
- expect(result.accessToken).toBe('access-token');
3224
- });
3225
- });
3226
- });
3227
- });