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