@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,1058 +0,0 @@
|
|
|
1
|
-
import { AdaptiveMFADecisionService } from './adaptive-mfa-decision.service';
|
|
2
|
-
import { RiskDetectionService } from './risk-detection.service';
|
|
3
|
-
import { RiskScoringService } from './risk-scoring.service';
|
|
4
|
-
import { StorageAdapter } from '../interfaces/storage-adapter.interface';
|
|
5
|
-
import { AuthAuditService } from './auth-audit.service';
|
|
6
|
-
import { ClientInfoService } from './client-info.service';
|
|
7
|
-
import { IUser } from '../interfaces/entities.interface';
|
|
8
|
-
import { NAuthConfig, AdaptiveMFARiskEventPayload } from '../interfaces/config.interface';
|
|
9
|
-
import { NAuthLogger } from '../utils/nauth-logger';
|
|
10
|
-
import { ClientInfo } from '../interfaces/client-info.interface';
|
|
11
|
-
import { AuthAuditEventType } from '../enums/auth-audit-event-type.enum';
|
|
12
|
-
import { RiskFactor } from '../enums/risk-factor.enum';
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* Adaptive MFA Decision Service Unit Tests
|
|
16
|
-
*
|
|
17
|
-
* Platform-agnostic: Uses direct instantiation, no NestJS dependencies.
|
|
18
|
-
*
|
|
19
|
-
* Covers:
|
|
20
|
-
* - Risk evaluation and decision making
|
|
21
|
-
* - Risk level determination (low, medium, high)
|
|
22
|
-
* - Action determination (allow, require_mfa, block_signin)
|
|
23
|
-
* - Lifecycle hook integration (onAdaptiveMFATriggered, onSignInBlocked)
|
|
24
|
-
* - User blocking functionality (isUserBlocked, blockUserSignIn)
|
|
25
|
-
* - Configuration-based risk level customization
|
|
26
|
-
* - Error handling and graceful degradation
|
|
27
|
-
*/
|
|
28
|
-
describe('AdaptiveMFADecisionService', () => {
|
|
29
|
-
let service: AdaptiveMFADecisionService;
|
|
30
|
-
let mockRiskDetectionService: jest.Mocked<RiskDetectionService>;
|
|
31
|
-
let mockRiskScoringService: jest.Mocked<RiskScoringService>;
|
|
32
|
-
let mockStorageAdapter: jest.Mocked<StorageAdapter>;
|
|
33
|
-
let mockAuditService: jest.Mocked<AuthAuditService>;
|
|
34
|
-
let mockClientInfoService: jest.Mocked<ClientInfoService>;
|
|
35
|
-
let mockConfig: NAuthConfig;
|
|
36
|
-
let mockLogger: jest.Mocked<NAuthLogger>;
|
|
37
|
-
|
|
38
|
-
const mockUser: IUser = {
|
|
39
|
-
id: 1,
|
|
40
|
-
sub: 'user-123',
|
|
41
|
-
email: 'test@example.com',
|
|
42
|
-
username: 'testuser',
|
|
43
|
-
phone: null,
|
|
44
|
-
firstName: null,
|
|
45
|
-
lastName: null,
|
|
46
|
-
passwordHash: null,
|
|
47
|
-
passwordChangedAt: null,
|
|
48
|
-
passwordHistory: null,
|
|
49
|
-
isEmailVerified: true,
|
|
50
|
-
isPhoneVerified: false,
|
|
51
|
-
isActive: true,
|
|
52
|
-
mustChangePassword: false,
|
|
53
|
-
isLocked: false,
|
|
54
|
-
lockReason: null,
|
|
55
|
-
lockedAt: null,
|
|
56
|
-
lockedUntil: null,
|
|
57
|
-
failedLoginAttempts: 0,
|
|
58
|
-
lastFailedLoginAt: null,
|
|
59
|
-
lastLoginAt: null,
|
|
60
|
-
lastLoginIp: null,
|
|
61
|
-
hasSocialAuth: false,
|
|
62
|
-
socialProviders: null,
|
|
63
|
-
mfaEnabled: false,
|
|
64
|
-
mfaMethods: null,
|
|
65
|
-
preferredMfaMethod: null,
|
|
66
|
-
backupCodes: null,
|
|
67
|
-
metadata: null,
|
|
68
|
-
createdAt: new Date(),
|
|
69
|
-
updatedAt: new Date(),
|
|
70
|
-
deletedAt: null,
|
|
71
|
-
};
|
|
72
|
-
|
|
73
|
-
const mockClientInfo: ClientInfo = {
|
|
74
|
-
ipAddress: '192.168.1.100',
|
|
75
|
-
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
|
76
|
-
deviceToken: 'device-123',
|
|
77
|
-
deviceName: 'Chrome on Windows',
|
|
78
|
-
deviceType: 'desktop',
|
|
79
|
-
ipCountry: 'US',
|
|
80
|
-
ipCity: 'New York',
|
|
81
|
-
platform: 'Windows',
|
|
82
|
-
browser: 'Chrome',
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
beforeEach(() => {
|
|
86
|
-
mockRiskDetectionService = {
|
|
87
|
-
detectRiskFactors: jest.fn(),
|
|
88
|
-
} as any;
|
|
89
|
-
|
|
90
|
-
mockRiskScoringService = {
|
|
91
|
-
calculateRiskScore: jest.fn(),
|
|
92
|
-
getRiskLevel: jest.fn(),
|
|
93
|
-
} as any;
|
|
94
|
-
|
|
95
|
-
mockStorageAdapter = {
|
|
96
|
-
get: jest.fn(),
|
|
97
|
-
set: jest.fn(),
|
|
98
|
-
del: jest.fn(),
|
|
99
|
-
exists: jest.fn(),
|
|
100
|
-
incr: jest.fn(),
|
|
101
|
-
decr: jest.fn(),
|
|
102
|
-
expire: jest.fn(),
|
|
103
|
-
ttl: jest.fn(),
|
|
104
|
-
hget: jest.fn(),
|
|
105
|
-
hset: jest.fn(),
|
|
106
|
-
hgetall: jest.fn(),
|
|
107
|
-
hdel: jest.fn(),
|
|
108
|
-
lpush: jest.fn(),
|
|
109
|
-
lrange: jest.fn(),
|
|
110
|
-
llen: jest.fn(),
|
|
111
|
-
keys: jest.fn(),
|
|
112
|
-
scan: jest.fn(),
|
|
113
|
-
initialize: jest.fn(),
|
|
114
|
-
isHealthy: jest.fn(),
|
|
115
|
-
cleanup: jest.fn(),
|
|
116
|
-
disconnect: jest.fn(),
|
|
117
|
-
} as any;
|
|
118
|
-
|
|
119
|
-
mockAuditService = {
|
|
120
|
-
recordEvent: jest.fn().mockResolvedValue(null),
|
|
121
|
-
} as any;
|
|
122
|
-
|
|
123
|
-
mockClientInfoService = {
|
|
124
|
-
get: jest.fn().mockReturnValue(mockClientInfo),
|
|
125
|
-
} as any;
|
|
126
|
-
|
|
127
|
-
mockLogger = {
|
|
128
|
-
log: jest.fn(),
|
|
129
|
-
error: jest.fn(),
|
|
130
|
-
warn: jest.fn(),
|
|
131
|
-
debug: jest.fn(),
|
|
132
|
-
verbose: jest.fn(),
|
|
133
|
-
} as any;
|
|
134
|
-
|
|
135
|
-
mockConfig = {
|
|
136
|
-
jwt: {
|
|
137
|
-
accessToken: { secret: 'test-secret', expiresIn: '15m' },
|
|
138
|
-
refreshToken: { secret: 'test-refresh-secret', expiresIn: '7d' },
|
|
139
|
-
},
|
|
140
|
-
mfa: {
|
|
141
|
-
adaptive: {
|
|
142
|
-
triggers: ['new_device', 'new_ip', 'new_country'],
|
|
143
|
-
riskLevels: {
|
|
144
|
-
low: { maxScore: 20, action: 'allow', notifyUser: false },
|
|
145
|
-
medium: { maxScore: 50, action: 'require_mfa', notifyUser: true },
|
|
146
|
-
high: { maxScore: 100, action: 'require_mfa', notifyUser: true },
|
|
147
|
-
},
|
|
148
|
-
},
|
|
149
|
-
},
|
|
150
|
-
};
|
|
151
|
-
|
|
152
|
-
// Instantiate service directly
|
|
153
|
-
service = new AdaptiveMFADecisionService(
|
|
154
|
-
mockRiskDetectionService,
|
|
155
|
-
mockRiskScoringService,
|
|
156
|
-
mockStorageAdapter,
|
|
157
|
-
mockClientInfoService,
|
|
158
|
-
mockConfig,
|
|
159
|
-
mockLogger,
|
|
160
|
-
mockAuditService,
|
|
161
|
-
);
|
|
162
|
-
});
|
|
163
|
-
|
|
164
|
-
afterEach(() => {
|
|
165
|
-
jest.clearAllMocks();
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
// ============================================================================
|
|
169
|
-
// Service Initialization
|
|
170
|
-
// ============================================================================
|
|
171
|
-
|
|
172
|
-
it('should be defined', () => {
|
|
173
|
-
expect(service).toBeDefined();
|
|
174
|
-
// Verify clientInfoService is injected
|
|
175
|
-
expect((service as any).clientInfoService).toBe(mockClientInfoService);
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
// ============================================================================
|
|
179
|
-
// evaluateAdaptiveMFA - Low Risk
|
|
180
|
-
// ============================================================================
|
|
181
|
-
|
|
182
|
-
describe('evaluateAdaptiveMFA() - low risk', () => {
|
|
183
|
-
it('should return allow action for low risk score', async () => {
|
|
184
|
-
mockClientInfoService.get.mockReturnValue(mockClientInfo);
|
|
185
|
-
mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.NEW_DEVICE]);
|
|
186
|
-
mockRiskScoringService.calculateRiskScore.mockReturnValue(15); // Low risk
|
|
187
|
-
mockRiskScoringService.getRiskLevel.mockReturnValue('low');
|
|
188
|
-
mockAuditService.recordEvent.mockResolvedValue(null);
|
|
189
|
-
|
|
190
|
-
const decision = await service.evaluateAdaptiveMFA(mockUser, 'password');
|
|
191
|
-
|
|
192
|
-
expect(decision.action).toBe('allow');
|
|
193
|
-
expect(decision.riskScore).toBe(15);
|
|
194
|
-
expect(decision.riskLevel).toBe('low');
|
|
195
|
-
expect(decision.riskFactors).toEqual([RiskFactor.NEW_DEVICE]);
|
|
196
|
-
// Payload should not be included for low risk (allow action, notifyUser false)
|
|
197
|
-
expect(decision.payload).toBeUndefined();
|
|
198
|
-
expect(decision.notifyUser).toBe(false);
|
|
199
|
-
expect(decision.hookOverride).toBe(false);
|
|
200
|
-
});
|
|
201
|
-
|
|
202
|
-
it('should not call lifecycle hook for low risk when notifyUser is false', async () => {
|
|
203
|
-
const mockHook = jest.fn();
|
|
204
|
-
const testConfig: NAuthConfig = {
|
|
205
|
-
...mockConfig,
|
|
206
|
-
hooks: {
|
|
207
|
-
onAdaptiveMFATriggered: mockHook,
|
|
208
|
-
},
|
|
209
|
-
};
|
|
210
|
-
|
|
211
|
-
mockClientInfoService.get.mockReturnValue(mockClientInfo);
|
|
212
|
-
mockRiskDetectionService.detectRiskFactors.mockResolvedValue([]);
|
|
213
|
-
mockRiskScoringService.calculateRiskScore.mockReturnValue(0);
|
|
214
|
-
mockRiskScoringService.getRiskLevel.mockReturnValue('low');
|
|
215
|
-
|
|
216
|
-
const testService = new AdaptiveMFADecisionService(
|
|
217
|
-
mockRiskDetectionService,
|
|
218
|
-
mockRiskScoringService,
|
|
219
|
-
mockStorageAdapter,
|
|
220
|
-
mockClientInfoService,
|
|
221
|
-
testConfig,
|
|
222
|
-
mockLogger,
|
|
223
|
-
mockAuditService,
|
|
224
|
-
);
|
|
225
|
-
|
|
226
|
-
await testService.evaluateAdaptiveMFA(mockUser, 'password');
|
|
227
|
-
|
|
228
|
-
expect(mockHook).not.toHaveBeenCalled();
|
|
229
|
-
});
|
|
230
|
-
});
|
|
231
|
-
|
|
232
|
-
// ============================================================================
|
|
233
|
-
// evaluateAdaptiveMFA - Medium Risk
|
|
234
|
-
// ============================================================================
|
|
235
|
-
|
|
236
|
-
describe('evaluateAdaptiveMFA() - medium risk', () => {
|
|
237
|
-
it('should return require_mfa action for medium risk score', async () => {
|
|
238
|
-
mockClientInfoService.get.mockReturnValue(mockClientInfo);
|
|
239
|
-
mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.NEW_COUNTRY]);
|
|
240
|
-
mockRiskScoringService.calculateRiskScore.mockReturnValue(35); // Medium risk
|
|
241
|
-
mockRiskScoringService.getRiskLevel.mockReturnValue('medium');
|
|
242
|
-
mockAuditService.recordEvent.mockResolvedValue(null);
|
|
243
|
-
|
|
244
|
-
const decision = await service.evaluateAdaptiveMFA(mockUser, 'password');
|
|
245
|
-
|
|
246
|
-
expect(decision.action).toBe('require_mfa');
|
|
247
|
-
expect(decision.riskScore).toBe(35);
|
|
248
|
-
expect(decision.riskLevel).toBe('medium');
|
|
249
|
-
expect(decision.notifyUser).toBe(true);
|
|
250
|
-
// Payload should be included when notifyUser is true
|
|
251
|
-
expect(decision.payload).toBeDefined();
|
|
252
|
-
expect(decision.payload?.action).toBe('require_mfa');
|
|
253
|
-
expect(decision.payload?.user.email).toBe('test@example.com');
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
it('should call lifecycle hook for medium risk when notifyUser is true', async () => {
|
|
257
|
-
const mockHook = jest.fn().mockResolvedValue(undefined);
|
|
258
|
-
const testConfig: NAuthConfig = {
|
|
259
|
-
...mockConfig,
|
|
260
|
-
hooks: {
|
|
261
|
-
onAdaptiveMFATriggered: mockHook,
|
|
262
|
-
},
|
|
263
|
-
};
|
|
264
|
-
|
|
265
|
-
mockClientInfoService.get.mockReturnValue(mockClientInfo);
|
|
266
|
-
mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.NEW_COUNTRY]);
|
|
267
|
-
mockRiskScoringService.calculateRiskScore.mockReturnValue(35);
|
|
268
|
-
mockRiskScoringService.getRiskLevel.mockReturnValue('medium');
|
|
269
|
-
|
|
270
|
-
const testService = new AdaptiveMFADecisionService(
|
|
271
|
-
mockRiskDetectionService,
|
|
272
|
-
mockRiskScoringService,
|
|
273
|
-
mockStorageAdapter,
|
|
274
|
-
mockClientInfoService,
|
|
275
|
-
testConfig,
|
|
276
|
-
mockLogger,
|
|
277
|
-
mockAuditService,
|
|
278
|
-
);
|
|
279
|
-
|
|
280
|
-
await testService.evaluateAdaptiveMFA(mockUser, 'password');
|
|
281
|
-
|
|
282
|
-
expect(mockHook).toHaveBeenCalledTimes(1);
|
|
283
|
-
const payload: AdaptiveMFARiskEventPayload = mockHook.mock.calls[0][0];
|
|
284
|
-
expect(payload.user.sub).toBe('user-123');
|
|
285
|
-
expect(payload.user.email).toBe('test@example.com');
|
|
286
|
-
expect(payload.riskScore).toBe(35);
|
|
287
|
-
expect(payload.riskLevel).toBe('medium');
|
|
288
|
-
expect(payload.riskFactors).toEqual(['new_country']);
|
|
289
|
-
expect(payload.action).toBe('require_mfa');
|
|
290
|
-
});
|
|
291
|
-
|
|
292
|
-
it('should allow hook to override action by returning false', async () => {
|
|
293
|
-
const mockHook = jest.fn().mockResolvedValue(false); // Override
|
|
294
|
-
const testConfig: NAuthConfig = {
|
|
295
|
-
...mockConfig,
|
|
296
|
-
hooks: {
|
|
297
|
-
onAdaptiveMFATriggered: mockHook,
|
|
298
|
-
},
|
|
299
|
-
};
|
|
300
|
-
|
|
301
|
-
mockClientInfoService.get.mockReturnValue(mockClientInfo);
|
|
302
|
-
mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.NEW_COUNTRY]);
|
|
303
|
-
mockRiskScoringService.calculateRiskScore.mockReturnValue(35);
|
|
304
|
-
mockRiskScoringService.getRiskLevel.mockReturnValue('medium');
|
|
305
|
-
|
|
306
|
-
const testService = new AdaptiveMFADecisionService(
|
|
307
|
-
mockRiskDetectionService,
|
|
308
|
-
mockRiskScoringService,
|
|
309
|
-
mockStorageAdapter,
|
|
310
|
-
mockClientInfoService,
|
|
311
|
-
testConfig,
|
|
312
|
-
mockLogger,
|
|
313
|
-
mockAuditService,
|
|
314
|
-
);
|
|
315
|
-
|
|
316
|
-
const decision = await testService.evaluateAdaptiveMFA(mockUser, 'password');
|
|
317
|
-
|
|
318
|
-
expect(decision.action).toBe('allow'); // Overridden
|
|
319
|
-
expect(decision.hookOverride).toBe(true);
|
|
320
|
-
expect(mockHook).toHaveBeenCalled();
|
|
321
|
-
});
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
// ============================================================================
|
|
325
|
-
// evaluateAdaptiveMFA - High Risk
|
|
326
|
-
// ============================================================================
|
|
327
|
-
|
|
328
|
-
describe('evaluateAdaptiveMFA() - high risk', () => {
|
|
329
|
-
it('should return require_mfa action for high risk by default', async () => {
|
|
330
|
-
mockClientInfoService.get.mockReturnValue(mockClientInfo);
|
|
331
|
-
mockRiskDetectionService.detectRiskFactors.mockResolvedValue([
|
|
332
|
-
RiskFactor.IMPOSSIBLE_TRAVEL,
|
|
333
|
-
RiskFactor.SUSPICIOUS_ACTIVITY,
|
|
334
|
-
]);
|
|
335
|
-
mockRiskScoringService.calculateRiskScore.mockReturnValue(70); // High risk
|
|
336
|
-
mockRiskScoringService.getRiskLevel.mockReturnValue('high');
|
|
337
|
-
mockAuditService.recordEvent.mockResolvedValue(null);
|
|
338
|
-
|
|
339
|
-
const decision = await service.evaluateAdaptiveMFA(mockUser, 'password');
|
|
340
|
-
|
|
341
|
-
expect(decision.action).toBe('require_mfa');
|
|
342
|
-
expect(decision.riskScore).toBe(70);
|
|
343
|
-
expect(decision.riskLevel).toBe('high');
|
|
344
|
-
expect(decision.notifyUser).toBe(true);
|
|
345
|
-
});
|
|
346
|
-
|
|
347
|
-
it('should return block_signin action when configured for high risk', async () => {
|
|
348
|
-
const testConfig: NAuthConfig = {
|
|
349
|
-
...mockConfig,
|
|
350
|
-
mfa: {
|
|
351
|
-
...mockConfig.mfa,
|
|
352
|
-
adaptive: {
|
|
353
|
-
...mockConfig.mfa!.adaptive!,
|
|
354
|
-
riskLevels: {
|
|
355
|
-
...mockConfig.mfa!.adaptive!.riskLevels,
|
|
356
|
-
high: {
|
|
357
|
-
maxScore: 100,
|
|
358
|
-
action: 'block_signin' as const,
|
|
359
|
-
notifyUser: true,
|
|
360
|
-
},
|
|
361
|
-
},
|
|
362
|
-
},
|
|
363
|
-
},
|
|
364
|
-
};
|
|
365
|
-
|
|
366
|
-
mockClientInfoService.get.mockReturnValue(mockClientInfo);
|
|
367
|
-
mockRiskDetectionService.detectRiskFactors.mockResolvedValue([
|
|
368
|
-
RiskFactor.IMPOSSIBLE_TRAVEL,
|
|
369
|
-
RiskFactor.SUSPICIOUS_ACTIVITY,
|
|
370
|
-
]);
|
|
371
|
-
mockRiskScoringService.calculateRiskScore.mockReturnValue(70);
|
|
372
|
-
mockRiskScoringService.getRiskLevel.mockReturnValue('high');
|
|
373
|
-
|
|
374
|
-
const testService = new AdaptiveMFADecisionService(
|
|
375
|
-
mockRiskDetectionService,
|
|
376
|
-
mockRiskScoringService,
|
|
377
|
-
mockStorageAdapter,
|
|
378
|
-
mockClientInfoService,
|
|
379
|
-
testConfig,
|
|
380
|
-
mockLogger,
|
|
381
|
-
mockAuditService,
|
|
382
|
-
);
|
|
383
|
-
|
|
384
|
-
const decision = await testService.evaluateAdaptiveMFA(mockUser, 'password');
|
|
385
|
-
|
|
386
|
-
expect(decision.action).toBe('block_signin');
|
|
387
|
-
expect(decision.riskScore).toBe(70);
|
|
388
|
-
expect(decision.riskLevel).toBe('high');
|
|
389
|
-
// Payload should be included for block_signin action
|
|
390
|
-
expect(decision.payload).toBeDefined();
|
|
391
|
-
expect(decision.payload?.action).toBe('block_signin');
|
|
392
|
-
expect(decision.payload?.riskScore).toBe(70);
|
|
393
|
-
expect(decision.payload?.user.email).toBe('test@example.com');
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
it('should return block_signin action and include payload for blockUserSignIn', async () => {
|
|
397
|
-
const testConfig: NAuthConfig = {
|
|
398
|
-
...mockConfig,
|
|
399
|
-
mfa: {
|
|
400
|
-
...mockConfig.mfa,
|
|
401
|
-
adaptive: {
|
|
402
|
-
...mockConfig.mfa!.adaptive!,
|
|
403
|
-
riskLevels: {
|
|
404
|
-
...mockConfig.mfa!.adaptive!.riskLevels,
|
|
405
|
-
high: {
|
|
406
|
-
maxScore: 100,
|
|
407
|
-
action: 'block_signin' as const,
|
|
408
|
-
notifyUser: true,
|
|
409
|
-
},
|
|
410
|
-
},
|
|
411
|
-
},
|
|
412
|
-
},
|
|
413
|
-
};
|
|
414
|
-
|
|
415
|
-
mockClientInfoService.get.mockReturnValue(mockClientInfo);
|
|
416
|
-
mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.IMPOSSIBLE_TRAVEL]);
|
|
417
|
-
mockRiskScoringService.calculateRiskScore.mockReturnValue(70);
|
|
418
|
-
mockRiskScoringService.getRiskLevel.mockReturnValue('high');
|
|
419
|
-
|
|
420
|
-
const testService = new AdaptiveMFADecisionService(
|
|
421
|
-
mockRiskDetectionService,
|
|
422
|
-
mockRiskScoringService,
|
|
423
|
-
mockStorageAdapter,
|
|
424
|
-
mockClientInfoService,
|
|
425
|
-
testConfig,
|
|
426
|
-
mockLogger,
|
|
427
|
-
mockAuditService,
|
|
428
|
-
);
|
|
429
|
-
|
|
430
|
-
const decision = await testService.evaluateAdaptiveMFA(mockUser, 'password');
|
|
431
|
-
|
|
432
|
-
expect(decision.action).toBe('block_signin');
|
|
433
|
-
// Payload should be included for block_signin action (caller can use it to call blockUserSignIn)
|
|
434
|
-
expect(decision.payload).toBeDefined();
|
|
435
|
-
expect(decision.payload?.action).toBe('block_signin');
|
|
436
|
-
});
|
|
437
|
-
});
|
|
438
|
-
|
|
439
|
-
// ============================================================================
|
|
440
|
-
// evaluateAdaptiveMFA - Configuration
|
|
441
|
-
// ============================================================================
|
|
442
|
-
|
|
443
|
-
describe('evaluateAdaptiveMFA() - configuration', () => {
|
|
444
|
-
it('should use default risk levels when not configured', async () => {
|
|
445
|
-
const testConfig: NAuthConfig = {
|
|
446
|
-
...mockConfig,
|
|
447
|
-
mfa: {
|
|
448
|
-
...mockConfig.mfa,
|
|
449
|
-
adaptive: {
|
|
450
|
-
...mockConfig.mfa!.adaptive!,
|
|
451
|
-
riskLevels: undefined,
|
|
452
|
-
},
|
|
453
|
-
},
|
|
454
|
-
};
|
|
455
|
-
|
|
456
|
-
mockClientInfoService.get.mockReturnValue(mockClientInfo);
|
|
457
|
-
mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.NEW_DEVICE]);
|
|
458
|
-
mockRiskScoringService.calculateRiskScore.mockReturnValue(15);
|
|
459
|
-
mockRiskScoringService.getRiskLevel.mockReturnValue('low');
|
|
460
|
-
|
|
461
|
-
const testService = new AdaptiveMFADecisionService(
|
|
462
|
-
mockRiskDetectionService,
|
|
463
|
-
mockRiskScoringService,
|
|
464
|
-
mockStorageAdapter,
|
|
465
|
-
mockClientInfoService,
|
|
466
|
-
testConfig,
|
|
467
|
-
mockLogger,
|
|
468
|
-
mockAuditService,
|
|
469
|
-
);
|
|
470
|
-
|
|
471
|
-
const decision = await testService.evaluateAdaptiveMFA(mockUser, 'password');
|
|
472
|
-
|
|
473
|
-
expect(decision.action).toBe('allow'); // Default for low
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
it('should respect custom risk level thresholds', async () => {
|
|
477
|
-
const testConfig: NAuthConfig = {
|
|
478
|
-
...mockConfig,
|
|
479
|
-
mfa: {
|
|
480
|
-
...mockConfig.mfa,
|
|
481
|
-
adaptive: {
|
|
482
|
-
...mockConfig.mfa!.adaptive!,
|
|
483
|
-
riskLevels: {
|
|
484
|
-
low: { maxScore: 30, action: 'allow' as const, notifyUser: false },
|
|
485
|
-
medium: { maxScore: 70, action: 'require_mfa' as const, notifyUser: true },
|
|
486
|
-
high: { maxScore: 100, action: 'require_mfa' as const, notifyUser: true },
|
|
487
|
-
},
|
|
488
|
-
},
|
|
489
|
-
},
|
|
490
|
-
};
|
|
491
|
-
|
|
492
|
-
mockClientInfoService.get.mockReturnValue(mockClientInfo);
|
|
493
|
-
mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.NEW_DEVICE]);
|
|
494
|
-
mockRiskScoringService.calculateRiskScore.mockReturnValue(25); // Now in low range
|
|
495
|
-
mockRiskScoringService.getRiskLevel.mockReturnValue('low');
|
|
496
|
-
|
|
497
|
-
const testService = new AdaptiveMFADecisionService(
|
|
498
|
-
mockRiskDetectionService,
|
|
499
|
-
mockRiskScoringService,
|
|
500
|
-
mockStorageAdapter,
|
|
501
|
-
mockClientInfoService,
|
|
502
|
-
testConfig,
|
|
503
|
-
mockLogger,
|
|
504
|
-
mockAuditService,
|
|
505
|
-
);
|
|
506
|
-
|
|
507
|
-
const decision = await testService.evaluateAdaptiveMFA(mockUser, 'password');
|
|
508
|
-
|
|
509
|
-
expect(decision.action).toBe('allow');
|
|
510
|
-
});
|
|
511
|
-
});
|
|
512
|
-
|
|
513
|
-
// ============================================================================
|
|
514
|
-
// evaluateAdaptiveMFA - Audit Logging
|
|
515
|
-
// ============================================================================
|
|
516
|
-
|
|
517
|
-
describe('evaluateAdaptiveMFA() - audit logging', () => {
|
|
518
|
-
it('should record audit event with risk details', async () => {
|
|
519
|
-
mockClientInfoService.get.mockReturnValue(mockClientInfo);
|
|
520
|
-
mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.NEW_COUNTRY]);
|
|
521
|
-
mockRiskScoringService.calculateRiskScore.mockReturnValue(35);
|
|
522
|
-
mockRiskScoringService.getRiskLevel.mockReturnValue('medium');
|
|
523
|
-
mockAuditService.recordEvent.mockResolvedValue(null);
|
|
524
|
-
|
|
525
|
-
await service.evaluateAdaptiveMFA(mockUser, 'password');
|
|
526
|
-
|
|
527
|
-
expect(mockAuditService.recordEvent).toHaveBeenCalled();
|
|
528
|
-
const auditCall = mockAuditService.recordEvent.mock.calls[0][0];
|
|
529
|
-
expect(auditCall.userId).toBe(1);
|
|
530
|
-
expect(auditCall.eventType).toBe(AuthAuditEventType.ADAPTIVE_MFA_RISK_ASSESSED);
|
|
531
|
-
expect(auditCall.riskFactors).toEqual([RiskFactor.NEW_COUNTRY]);
|
|
532
|
-
expect(auditCall.riskFactor).toBe(35);
|
|
533
|
-
expect(auditCall.adaptiveMfaTriggered).toBe(true);
|
|
534
|
-
});
|
|
535
|
-
|
|
536
|
-
it('should handle audit logging errors gracefully', async () => {
|
|
537
|
-
mockClientInfoService.get.mockReturnValue(mockClientInfo);
|
|
538
|
-
mockRiskDetectionService.detectRiskFactors.mockResolvedValue([RiskFactor.NEW_DEVICE]);
|
|
539
|
-
mockRiskScoringService.calculateRiskScore.mockReturnValue(15);
|
|
540
|
-
mockRiskScoringService.getRiskLevel.mockReturnValue('low');
|
|
541
|
-
// recordEvent returns a promise that rejects - service catches it
|
|
542
|
-
mockAuditService.recordEvent.mockImplementation(() => Promise.reject(new Error('Audit error')));
|
|
543
|
-
|
|
544
|
-
// Should not throw
|
|
545
|
-
const decision = await service.evaluateAdaptiveMFA(mockUser, 'password');
|
|
546
|
-
|
|
547
|
-
expect(decision).toBeDefined();
|
|
548
|
-
// Service uses .catch() so error is handled internally
|
|
549
|
-
});
|
|
550
|
-
});
|
|
551
|
-
|
|
552
|
-
// ============================================================================
|
|
553
|
-
// isUserBlocked
|
|
554
|
-
// ============================================================================
|
|
555
|
-
|
|
556
|
-
describe('isUserBlocked()', () => {
|
|
557
|
-
it('should return blocked=false when no block exists', async () => {
|
|
558
|
-
mockStorageAdapter.get.mockClear();
|
|
559
|
-
mockStorageAdapter.get.mockResolvedValue(null);
|
|
560
|
-
|
|
561
|
-
const result = await service.isUserBlocked(1);
|
|
562
|
-
|
|
563
|
-
expect(result.blocked).toBe(false);
|
|
564
|
-
expect(mockStorageAdapter.get).toHaveBeenCalledWith('adaptive_mfa_block:1');
|
|
565
|
-
});
|
|
566
|
-
|
|
567
|
-
it('should return blocked=true when block exists', async () => {
|
|
568
|
-
const blockData = {
|
|
569
|
-
userId: 1,
|
|
570
|
-
userSub: 'user-123',
|
|
571
|
-
message: 'Sign-in blocked',
|
|
572
|
-
riskScore: 70,
|
|
573
|
-
riskFactors: [RiskFactor.IMPOSSIBLE_TRAVEL],
|
|
574
|
-
blockedAt: new Date().toISOString(),
|
|
575
|
-
expiresAt: new Date(Date.now() + 60 * 60 * 1000).toISOString(), // 1 hour from now
|
|
576
|
-
};
|
|
577
|
-
|
|
578
|
-
mockStorageAdapter.get.mockClear();
|
|
579
|
-
mockStorageAdapter.get.mockResolvedValue(JSON.stringify(blockData));
|
|
580
|
-
|
|
581
|
-
const result = await service.isUserBlocked(1);
|
|
582
|
-
|
|
583
|
-
expect(result.blocked).toBe(true);
|
|
584
|
-
expect(result.message).toBe('Sign-in blocked');
|
|
585
|
-
expect(result.expiresAt).toBeInstanceOf(Date);
|
|
586
|
-
});
|
|
587
|
-
|
|
588
|
-
it('should return blocked=false when block has expired', async () => {
|
|
589
|
-
const expiredBlockData = {
|
|
590
|
-
userId: 1,
|
|
591
|
-
userSub: 'user-123',
|
|
592
|
-
message: 'Sign-in blocked',
|
|
593
|
-
riskScore: 70,
|
|
594
|
-
riskFactors: [RiskFactor.IMPOSSIBLE_TRAVEL],
|
|
595
|
-
blockedAt: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(), // 2 hours ago
|
|
596
|
-
expiresAt: new Date(Date.now() - 60 * 60 * 1000).toISOString(), // 1 hour ago (expired)
|
|
597
|
-
};
|
|
598
|
-
|
|
599
|
-
mockStorageAdapter.get.mockClear();
|
|
600
|
-
mockStorageAdapter.get.mockResolvedValue(JSON.stringify(expiredBlockData));
|
|
601
|
-
mockStorageAdapter.del.mockClear();
|
|
602
|
-
mockStorageAdapter.del.mockResolvedValue(undefined);
|
|
603
|
-
|
|
604
|
-
const result = await service.isUserBlocked(1);
|
|
605
|
-
|
|
606
|
-
expect(result.blocked).toBe(false);
|
|
607
|
-
expect(mockStorageAdapter.del).toHaveBeenCalledWith('adaptive_mfa_block:1');
|
|
608
|
-
});
|
|
609
|
-
|
|
610
|
-
it('should handle permanent blocks (no expiration)', async () => {
|
|
611
|
-
const permanentBlockData: any = {
|
|
612
|
-
userId: 1,
|
|
613
|
-
userSub: 'user-123',
|
|
614
|
-
message: 'Sign-in blocked',
|
|
615
|
-
riskScore: 70,
|
|
616
|
-
riskFactors: [RiskFactor.IMPOSSIBLE_TRAVEL],
|
|
617
|
-
blockedAt: new Date().toISOString(),
|
|
618
|
-
};
|
|
619
|
-
// expiresAt is omitted (not set) for permanent blocks
|
|
620
|
-
|
|
621
|
-
mockStorageAdapter.get.mockClear();
|
|
622
|
-
mockStorageAdapter.get.mockResolvedValue(JSON.stringify(permanentBlockData));
|
|
623
|
-
|
|
624
|
-
const result = await service.isUserBlocked(1);
|
|
625
|
-
|
|
626
|
-
expect(result.blocked).toBe(true);
|
|
627
|
-
expect(result.expiresAt).toBeUndefined();
|
|
628
|
-
});
|
|
629
|
-
|
|
630
|
-
it('should handle errors gracefully', async () => {
|
|
631
|
-
mockStorageAdapter.get.mockRejectedValueOnce(new Error('Storage error'));
|
|
632
|
-
|
|
633
|
-
const result = await service.isUserBlocked(1);
|
|
634
|
-
|
|
635
|
-
expect(result.blocked).toBe(false); // Safer default
|
|
636
|
-
expect(mockLogger.warn).toHaveBeenCalled();
|
|
637
|
-
});
|
|
638
|
-
});
|
|
639
|
-
|
|
640
|
-
// ============================================================================
|
|
641
|
-
// blockUserSignIn
|
|
642
|
-
// ============================================================================
|
|
643
|
-
|
|
644
|
-
describe('blockUserSignIn()', () => {
|
|
645
|
-
it('should block user with temporary TTL when blockDuration configured', async () => {
|
|
646
|
-
const testConfig: NAuthConfig = {
|
|
647
|
-
...mockConfig,
|
|
648
|
-
mfa: {
|
|
649
|
-
...mockConfig.mfa,
|
|
650
|
-
adaptive: {
|
|
651
|
-
...mockConfig.mfa!.adaptive!,
|
|
652
|
-
blockedSignIn: {
|
|
653
|
-
blockDuration: 60, // 60 minutes
|
|
654
|
-
message: 'Custom block message',
|
|
655
|
-
},
|
|
656
|
-
},
|
|
657
|
-
},
|
|
658
|
-
};
|
|
659
|
-
|
|
660
|
-
const payload: AdaptiveMFARiskEventPayload = {
|
|
661
|
-
user: {
|
|
662
|
-
sub: 'user-123',
|
|
663
|
-
email: 'test@example.com',
|
|
664
|
-
username: 'testuser',
|
|
665
|
-
},
|
|
666
|
-
riskScore: 70,
|
|
667
|
-
riskLevel: 'high',
|
|
668
|
-
riskFactors: [RiskFactor.IMPOSSIBLE_TRAVEL],
|
|
669
|
-
action: 'block_signin',
|
|
670
|
-
clientInfo: {
|
|
671
|
-
ipAddress: mockClientInfo.ipAddress,
|
|
672
|
-
ipCountry: mockClientInfo.ipCountry,
|
|
673
|
-
ipCity: mockClientInfo.ipCity,
|
|
674
|
-
deviceId: mockClientInfo.deviceToken,
|
|
675
|
-
deviceName: mockClientInfo.deviceName,
|
|
676
|
-
deviceType: mockClientInfo.deviceType,
|
|
677
|
-
userAgent: mockClientInfo.userAgent,
|
|
678
|
-
platform: mockClientInfo.platform,
|
|
679
|
-
browser: mockClientInfo.browser,
|
|
680
|
-
},
|
|
681
|
-
authMethod: 'password',
|
|
682
|
-
timestamp: new Date(),
|
|
683
|
-
};
|
|
684
|
-
|
|
685
|
-
const testService = new AdaptiveMFADecisionService(
|
|
686
|
-
mockRiskDetectionService,
|
|
687
|
-
mockRiskScoringService,
|
|
688
|
-
mockStorageAdapter,
|
|
689
|
-
mockClientInfoService,
|
|
690
|
-
testConfig,
|
|
691
|
-
mockLogger,
|
|
692
|
-
mockAuditService,
|
|
693
|
-
);
|
|
694
|
-
|
|
695
|
-
await testService.blockUserSignIn(mockUser, payload);
|
|
696
|
-
|
|
697
|
-
const setCall = mockStorageAdapter.set.mock.calls[0];
|
|
698
|
-
expect(setCall[0]).toBe('adaptive_mfa_block:1');
|
|
699
|
-
expect(setCall[1]).toContain('Custom block message');
|
|
700
|
-
expect(setCall[2]).toBe(3600); // 60 minutes in seconds
|
|
701
|
-
expect(mockLogger.warn).toHaveBeenCalled();
|
|
702
|
-
});
|
|
703
|
-
|
|
704
|
-
it('should block user permanently when blockDuration not configured', async () => {
|
|
705
|
-
const testConfig: NAuthConfig = {
|
|
706
|
-
...mockConfig,
|
|
707
|
-
mfa: {
|
|
708
|
-
...mockConfig.mfa,
|
|
709
|
-
adaptive: {
|
|
710
|
-
...mockConfig.mfa!.adaptive!,
|
|
711
|
-
blockedSignIn: {
|
|
712
|
-
message: 'Sign-in blocked',
|
|
713
|
-
},
|
|
714
|
-
},
|
|
715
|
-
},
|
|
716
|
-
};
|
|
717
|
-
|
|
718
|
-
const payload: AdaptiveMFARiskEventPayload = {
|
|
719
|
-
user: {
|
|
720
|
-
sub: 'user-123',
|
|
721
|
-
email: 'test@example.com',
|
|
722
|
-
username: 'testuser',
|
|
723
|
-
},
|
|
724
|
-
riskScore: 70,
|
|
725
|
-
riskLevel: 'high',
|
|
726
|
-
riskFactors: [RiskFactor.IMPOSSIBLE_TRAVEL],
|
|
727
|
-
action: 'block_signin',
|
|
728
|
-
clientInfo: {
|
|
729
|
-
ipAddress: mockClientInfo.ipAddress,
|
|
730
|
-
ipCountry: mockClientInfo.ipCountry,
|
|
731
|
-
ipCity: mockClientInfo.ipCity,
|
|
732
|
-
deviceId: mockClientInfo.deviceToken,
|
|
733
|
-
deviceName: mockClientInfo.deviceName,
|
|
734
|
-
deviceType: mockClientInfo.deviceType,
|
|
735
|
-
userAgent: mockClientInfo.userAgent,
|
|
736
|
-
platform: mockClientInfo.platform,
|
|
737
|
-
browser: mockClientInfo.browser,
|
|
738
|
-
},
|
|
739
|
-
authMethod: 'password',
|
|
740
|
-
timestamp: new Date(),
|
|
741
|
-
};
|
|
742
|
-
|
|
743
|
-
const testService = new AdaptiveMFADecisionService(
|
|
744
|
-
mockRiskDetectionService,
|
|
745
|
-
mockRiskScoringService,
|
|
746
|
-
mockStorageAdapter,
|
|
747
|
-
mockClientInfoService,
|
|
748
|
-
testConfig,
|
|
749
|
-
mockLogger,
|
|
750
|
-
mockAuditService,
|
|
751
|
-
);
|
|
752
|
-
|
|
753
|
-
await testService.blockUserSignIn(mockUser, payload);
|
|
754
|
-
|
|
755
|
-
const setCall = mockStorageAdapter.set.mock.calls[0];
|
|
756
|
-
expect(setCall[0]).toBe('adaptive_mfa_block:1');
|
|
757
|
-
expect(typeof setCall[1]).toBe('string');
|
|
758
|
-
expect(setCall[2]).toBeUndefined(); // No TTL
|
|
759
|
-
});
|
|
760
|
-
|
|
761
|
-
it('should call onSignInBlocked lifecycle hook', async () => {
|
|
762
|
-
const mockHook = jest.fn().mockResolvedValue(undefined);
|
|
763
|
-
const testConfig: NAuthConfig = {
|
|
764
|
-
...mockConfig,
|
|
765
|
-
hooks: {
|
|
766
|
-
onSignInBlocked: mockHook,
|
|
767
|
-
},
|
|
768
|
-
mfa: {
|
|
769
|
-
...mockConfig.mfa,
|
|
770
|
-
adaptive: {
|
|
771
|
-
...mockConfig.mfa!.adaptive!,
|
|
772
|
-
blockedSignIn: {
|
|
773
|
-
blockDuration: 30,
|
|
774
|
-
message: 'Blocked',
|
|
775
|
-
},
|
|
776
|
-
},
|
|
777
|
-
},
|
|
778
|
-
};
|
|
779
|
-
|
|
780
|
-
const payload: AdaptiveMFARiskEventPayload = {
|
|
781
|
-
user: {
|
|
782
|
-
sub: 'user-123',
|
|
783
|
-
email: 'test@example.com',
|
|
784
|
-
username: 'testuser',
|
|
785
|
-
},
|
|
786
|
-
riskScore: 70,
|
|
787
|
-
riskLevel: 'high',
|
|
788
|
-
riskFactors: [RiskFactor.IMPOSSIBLE_TRAVEL],
|
|
789
|
-
action: 'block_signin',
|
|
790
|
-
clientInfo: {
|
|
791
|
-
ipAddress: mockClientInfo.ipAddress,
|
|
792
|
-
ipCountry: mockClientInfo.ipCountry,
|
|
793
|
-
ipCity: mockClientInfo.ipCity,
|
|
794
|
-
deviceId: mockClientInfo.deviceToken,
|
|
795
|
-
deviceName: mockClientInfo.deviceName,
|
|
796
|
-
deviceType: mockClientInfo.deviceType,
|
|
797
|
-
userAgent: mockClientInfo.userAgent,
|
|
798
|
-
platform: mockClientInfo.platform,
|
|
799
|
-
browser: mockClientInfo.browser,
|
|
800
|
-
},
|
|
801
|
-
authMethod: 'password',
|
|
802
|
-
timestamp: new Date(),
|
|
803
|
-
};
|
|
804
|
-
|
|
805
|
-
const testService = new AdaptiveMFADecisionService(
|
|
806
|
-
mockRiskDetectionService,
|
|
807
|
-
mockRiskScoringService,
|
|
808
|
-
mockStorageAdapter,
|
|
809
|
-
mockClientInfoService,
|
|
810
|
-
testConfig,
|
|
811
|
-
mockLogger,
|
|
812
|
-
mockAuditService,
|
|
813
|
-
);
|
|
814
|
-
|
|
815
|
-
await testService.blockUserSignIn(mockUser, payload);
|
|
816
|
-
|
|
817
|
-
expect(mockHook).toHaveBeenCalledTimes(1);
|
|
818
|
-
const hookPayload = mockHook.mock.calls[0][0];
|
|
819
|
-
expect(hookPayload.user.sub).toBe('user-123');
|
|
820
|
-
expect(hookPayload.riskScore).toBe(70);
|
|
821
|
-
expect(hookPayload.blockDuration).toBe(30);
|
|
822
|
-
expect(hookPayload.message).toBe('Blocked');
|
|
823
|
-
expect(hookPayload.blockExpiresAt).toBeDefined();
|
|
824
|
-
});
|
|
825
|
-
|
|
826
|
-
it('should handle hook errors gracefully', async () => {
|
|
827
|
-
const mockHook = jest.fn().mockRejectedValue(new Error('Hook error'));
|
|
828
|
-
const testConfig: NAuthConfig = {
|
|
829
|
-
...mockConfig,
|
|
830
|
-
hooks: {
|
|
831
|
-
onSignInBlocked: mockHook,
|
|
832
|
-
},
|
|
833
|
-
mfa: {
|
|
834
|
-
...mockConfig.mfa,
|
|
835
|
-
adaptive: {
|
|
836
|
-
...mockConfig.mfa!.adaptive!,
|
|
837
|
-
blockedSignIn: {
|
|
838
|
-
blockDuration: 30,
|
|
839
|
-
message: 'Blocked',
|
|
840
|
-
},
|
|
841
|
-
},
|
|
842
|
-
},
|
|
843
|
-
};
|
|
844
|
-
|
|
845
|
-
const payload: AdaptiveMFARiskEventPayload = {
|
|
846
|
-
user: {
|
|
847
|
-
sub: 'user-123',
|
|
848
|
-
email: 'test@example.com',
|
|
849
|
-
username: 'testuser',
|
|
850
|
-
},
|
|
851
|
-
riskScore: 70,
|
|
852
|
-
riskLevel: 'high',
|
|
853
|
-
riskFactors: [RiskFactor.IMPOSSIBLE_TRAVEL],
|
|
854
|
-
action: 'block_signin',
|
|
855
|
-
clientInfo: {
|
|
856
|
-
ipAddress: mockClientInfo.ipAddress,
|
|
857
|
-
ipCountry: mockClientInfo.ipCountry,
|
|
858
|
-
ipCity: mockClientInfo.ipCity,
|
|
859
|
-
deviceId: mockClientInfo.deviceToken,
|
|
860
|
-
deviceName: mockClientInfo.deviceName,
|
|
861
|
-
deviceType: mockClientInfo.deviceType,
|
|
862
|
-
userAgent: mockClientInfo.userAgent,
|
|
863
|
-
platform: mockClientInfo.platform,
|
|
864
|
-
browser: mockClientInfo.browser,
|
|
865
|
-
},
|
|
866
|
-
authMethod: 'password',
|
|
867
|
-
timestamp: new Date(),
|
|
868
|
-
};
|
|
869
|
-
|
|
870
|
-
const testService = new AdaptiveMFADecisionService(
|
|
871
|
-
mockRiskDetectionService,
|
|
872
|
-
mockRiskScoringService,
|
|
873
|
-
mockStorageAdapter,
|
|
874
|
-
mockClientInfoService,
|
|
875
|
-
testConfig,
|
|
876
|
-
mockLogger,
|
|
877
|
-
mockAuditService,
|
|
878
|
-
);
|
|
879
|
-
|
|
880
|
-
// Should not throw
|
|
881
|
-
await testService.blockUserSignIn(mockUser, payload);
|
|
882
|
-
|
|
883
|
-
expect(mockLogger.error).toHaveBeenCalled();
|
|
884
|
-
});
|
|
885
|
-
|
|
886
|
-
it('should use default message when not configured', async () => {
|
|
887
|
-
const testConfig: NAuthConfig = {
|
|
888
|
-
...mockConfig,
|
|
889
|
-
mfa: {
|
|
890
|
-
...mockConfig.mfa,
|
|
891
|
-
adaptive: {
|
|
892
|
-
...mockConfig.mfa!.adaptive!,
|
|
893
|
-
blockedSignIn: {},
|
|
894
|
-
},
|
|
895
|
-
},
|
|
896
|
-
};
|
|
897
|
-
|
|
898
|
-
const payload: AdaptiveMFARiskEventPayload = {
|
|
899
|
-
user: {
|
|
900
|
-
sub: 'user-123',
|
|
901
|
-
email: 'test@example.com',
|
|
902
|
-
username: 'testuser',
|
|
903
|
-
},
|
|
904
|
-
riskScore: 70,
|
|
905
|
-
riskLevel: 'high',
|
|
906
|
-
riskFactors: [RiskFactor.IMPOSSIBLE_TRAVEL],
|
|
907
|
-
action: 'block_signin',
|
|
908
|
-
clientInfo: {
|
|
909
|
-
ipAddress: mockClientInfo.ipAddress,
|
|
910
|
-
ipCountry: mockClientInfo.ipCountry,
|
|
911
|
-
ipCity: mockClientInfo.ipCity,
|
|
912
|
-
deviceId: mockClientInfo.deviceToken,
|
|
913
|
-
deviceName: mockClientInfo.deviceName,
|
|
914
|
-
deviceType: mockClientInfo.deviceType,
|
|
915
|
-
userAgent: mockClientInfo.userAgent,
|
|
916
|
-
platform: mockClientInfo.platform,
|
|
917
|
-
browser: mockClientInfo.browser,
|
|
918
|
-
},
|
|
919
|
-
authMethod: 'password',
|
|
920
|
-
timestamp: new Date(),
|
|
921
|
-
};
|
|
922
|
-
|
|
923
|
-
const testService = new AdaptiveMFADecisionService(
|
|
924
|
-
mockRiskDetectionService,
|
|
925
|
-
mockRiskScoringService,
|
|
926
|
-
mockStorageAdapter,
|
|
927
|
-
mockClientInfoService,
|
|
928
|
-
testConfig,
|
|
929
|
-
mockLogger,
|
|
930
|
-
mockAuditService,
|
|
931
|
-
);
|
|
932
|
-
|
|
933
|
-
await testService.blockUserSignIn(mockUser, payload);
|
|
934
|
-
|
|
935
|
-
const setCall = mockStorageAdapter.set.mock.calls[0];
|
|
936
|
-
const blockData = JSON.parse(setCall[1] as string);
|
|
937
|
-
expect(blockData.message).toContain('suspicious activity');
|
|
938
|
-
});
|
|
939
|
-
});
|
|
940
|
-
|
|
941
|
-
// ============================================================================
|
|
942
|
-
// evaluateAdaptiveMFA - Error Handling
|
|
943
|
-
// ============================================================================
|
|
944
|
-
|
|
945
|
-
describe('evaluateAdaptiveMFA() - error handling', () => {
|
|
946
|
-
it('should handle risk detection errors gracefully', async () => {
|
|
947
|
-
mockClientInfoService.get.mockReturnValue(mockClientInfo);
|
|
948
|
-
// Risk detection returns empty array on error (handled internally)
|
|
949
|
-
mockRiskDetectionService.detectRiskFactors.mockResolvedValue([]);
|
|
950
|
-
mockRiskScoringService.calculateRiskScore.mockReturnValue(0);
|
|
951
|
-
mockRiskScoringService.getRiskLevel.mockReturnValue('low');
|
|
952
|
-
mockAuditService.recordEvent.mockResolvedValue(null);
|
|
953
|
-
|
|
954
|
-
const decision = await service.evaluateAdaptiveMFA(mockUser, 'password');
|
|
955
|
-
|
|
956
|
-
expect(decision).toBeDefined();
|
|
957
|
-
expect(decision.action).toBe('allow');
|
|
958
|
-
});
|
|
959
|
-
|
|
960
|
-
it('should handle missing client info gracefully', async () => {
|
|
961
|
-
mockClientInfoService.get.mockReturnValue({
|
|
962
|
-
ipAddress: 'unknown',
|
|
963
|
-
userAgent: 'unknown',
|
|
964
|
-
} as ClientInfo);
|
|
965
|
-
|
|
966
|
-
mockRiskDetectionService.detectRiskFactors.mockResolvedValue([]);
|
|
967
|
-
mockRiskScoringService.calculateRiskScore.mockReturnValue(0);
|
|
968
|
-
mockRiskScoringService.getRiskLevel.mockReturnValue('low');
|
|
969
|
-
|
|
970
|
-
const decision = await service.evaluateAdaptiveMFA(mockUser, 'password');
|
|
971
|
-
|
|
972
|
-
expect(decision.action).toBe('allow');
|
|
973
|
-
});
|
|
974
|
-
|
|
975
|
-
it('should throw error when user email is missing', async () => {
|
|
976
|
-
const userWithoutEmail: IUser = {
|
|
977
|
-
...mockUser,
|
|
978
|
-
email: '', // Empty email
|
|
979
|
-
};
|
|
980
|
-
|
|
981
|
-
try {
|
|
982
|
-
await service.evaluateAdaptiveMFA(userWithoutEmail, 'password');
|
|
983
|
-
fail('Expected evaluateAdaptiveMFA to throw error for missing email');
|
|
984
|
-
} catch (error) {
|
|
985
|
-
expect(error).toBeInstanceOf(Error);
|
|
986
|
-
expect((error as Error).message).toBe('User email is required for adaptive MFA evaluation');
|
|
987
|
-
}
|
|
988
|
-
});
|
|
989
|
-
|
|
990
|
-
it('should throw error when user email is null', async () => {
|
|
991
|
-
const userWithoutEmail: IUser = {
|
|
992
|
-
...mockUser,
|
|
993
|
-
email: null as unknown as string, // Force null for test
|
|
994
|
-
};
|
|
995
|
-
|
|
996
|
-
try {
|
|
997
|
-
await service.evaluateAdaptiveMFA(userWithoutEmail, 'password');
|
|
998
|
-
fail('Expected evaluateAdaptiveMFA to throw error for null email');
|
|
999
|
-
} catch (error) {
|
|
1000
|
-
expect(error).toBeInstanceOf(Error);
|
|
1001
|
-
expect((error as Error).message).toBe('User email is required for adaptive MFA evaluation');
|
|
1002
|
-
}
|
|
1003
|
-
});
|
|
1004
|
-
});
|
|
1005
|
-
|
|
1006
|
-
// ============================================================================
|
|
1007
|
-
// clearUserBlock
|
|
1008
|
-
// ============================================================================
|
|
1009
|
-
|
|
1010
|
-
describe('clearUserBlock()', () => {
|
|
1011
|
-
it('should clear user block successfully', async () => {
|
|
1012
|
-
mockStorageAdapter.del.mockResolvedValue(undefined);
|
|
1013
|
-
|
|
1014
|
-
await service.clearUserBlock(1);
|
|
1015
|
-
|
|
1016
|
-
expect(mockStorageAdapter.del).toHaveBeenCalledWith('adaptive_mfa_block:1');
|
|
1017
|
-
expect(mockLogger.log).toHaveBeenCalledWith((expect as any).stringContaining('User block cleared'));
|
|
1018
|
-
});
|
|
1019
|
-
|
|
1020
|
-
it('should handle errors gracefully', async () => {
|
|
1021
|
-
mockStorageAdapter.del.mockRejectedValue(new Error('Storage error'));
|
|
1022
|
-
|
|
1023
|
-
await service.clearUserBlock(1);
|
|
1024
|
-
|
|
1025
|
-
expect(mockLogger.warn).toHaveBeenCalled();
|
|
1026
|
-
// Should not throw
|
|
1027
|
-
});
|
|
1028
|
-
});
|
|
1029
|
-
|
|
1030
|
-
// ============================================================================
|
|
1031
|
-
// Service Without Optional Dependencies
|
|
1032
|
-
// ============================================================================
|
|
1033
|
-
|
|
1034
|
-
describe('Service without optional dependencies', () => {
|
|
1035
|
-
it('should work without audit service', async () => {
|
|
1036
|
-
const serviceWithoutAudit = new AdaptiveMFADecisionService(
|
|
1037
|
-
mockRiskDetectionService,
|
|
1038
|
-
mockRiskScoringService,
|
|
1039
|
-
mockStorageAdapter,
|
|
1040
|
-
mockClientInfoService,
|
|
1041
|
-
mockConfig,
|
|
1042
|
-
mockLogger,
|
|
1043
|
-
undefined, // No audit service
|
|
1044
|
-
);
|
|
1045
|
-
|
|
1046
|
-
mockClientInfoService.get.mockReturnValue(mockClientInfo);
|
|
1047
|
-
mockRiskDetectionService.detectRiskFactors.mockResolvedValue([]);
|
|
1048
|
-
mockRiskScoringService.calculateRiskScore.mockReturnValue(0);
|
|
1049
|
-
mockRiskScoringService.getRiskLevel.mockReturnValue('low');
|
|
1050
|
-
|
|
1051
|
-
const decision = await serviceWithoutAudit.evaluateAdaptiveMFA(mockUser, 'password');
|
|
1052
|
-
|
|
1053
|
-
// Should not throw error
|
|
1054
|
-
expect(decision).toBeDefined();
|
|
1055
|
-
expect(decision.action).toBe('allow');
|
|
1056
|
-
});
|
|
1057
|
-
});
|
|
1058
|
-
});
|