@tstdl/base 0.93.178 → 0.93.180
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/api/response.js +4 -3
- package/api/server/gateway.js +9 -3
- package/audit/auditor.d.ts +1 -2
- package/audit/drizzle/{0000_lumpy_thunderball.sql → 0000_shallow_elektra.sql} +1 -1
- package/audit/drizzle/meta/0000_snapshot.json +2 -2
- package/audit/drizzle/meta/_journal.json +2 -2
- package/authentication/README.md +87 -42
- package/authentication/authentication.api.d.ts +392 -53
- package/authentication/authentication.api.js +133 -28
- package/authentication/client/api.client.d.ts +3 -3
- package/authentication/client/api.client.js +4 -4
- package/authentication/client/authentication.service.d.ts +93 -23
- package/authentication/client/authentication.service.js +113 -28
- package/authentication/client/http-client.middleware.d.ts +1 -1
- package/authentication/client/http-client.middleware.js +5 -4
- package/authentication/client/module.d.ts +1 -1
- package/authentication/client/module.js +2 -2
- package/authentication/errors/index.d.ts +1 -1
- package/authentication/errors/index.js +1 -1
- package/authentication/errors/password-requirements.error.d.ts +5 -0
- package/authentication/errors/{secret-requirements.error.js → password-requirements.error.js} +2 -2
- package/authentication/models/authentication-password.model.d.ts +8 -0
- package/authentication/models/{authentication-credentials.model.js → authentication-password.model.js} +11 -17
- package/authentication/models/authentication-session.model.d.ts +0 -2
- package/authentication/models/authentication-session.model.js +1 -7
- package/authentication/models/authentication-totp-recovery-code.model.d.ts +6 -0
- package/authentication/models/authentication-totp-recovery-code.model.js +34 -0
- package/authentication/models/authentication-totp.model.d.ts +19 -0
- package/authentication/models/authentication-totp.model.js +51 -0
- package/authentication/models/authentication-used-totp-token.model.d.ts +5 -0
- package/authentication/models/authentication-used-totp-token.model.js +32 -0
- package/authentication/models/index.d.ts +6 -3
- package/authentication/models/index.js +6 -3
- package/authentication/models/{init-secret-reset-data.model.d.ts → init-password-reset-data.model.d.ts} +3 -3
- package/authentication/models/{init-secret-reset-data.model.js → init-password-reset-data.model.js} +5 -5
- package/authentication/models/password-check-result.model.d.ts +3 -0
- package/authentication/models/{secret-check-result.model.js → password-check-result.model.js} +6 -6
- package/authentication/models/subject.model.d.ts +0 -6
- package/authentication/models/subject.model.js +0 -6
- package/authentication/models/token.model.d.ts +16 -2
- package/authentication/server/authentication-ancillary.service.d.ts +6 -6
- package/authentication/server/authentication-ancillary.service.js +1 -1
- package/authentication/server/authentication-password-requirements.validator.d.ts +55 -0
- package/authentication/server/{authentication-secret-requirements.validator.js → authentication-password-requirements.validator.js} +22 -22
- package/authentication/server/authentication.api-controller.d.ts +55 -27
- package/authentication/server/authentication.api-controller.js +214 -39
- package/authentication/server/authentication.audit.d.ts +42 -5
- package/authentication/server/authentication.service.d.ts +182 -93
- package/authentication/server/authentication.service.js +628 -206
- package/authentication/server/drizzle/{0000_soft_tag.sql → 0000_odd_echo.sql} +59 -13
- package/authentication/server/drizzle/meta/0000_snapshot.json +345 -32
- package/authentication/server/drizzle/meta/_journal.json +2 -2
- package/authentication/server/helper.d.ts +16 -16
- package/authentication/server/helper.js +33 -34
- package/authentication/server/index.d.ts +1 -1
- package/authentication/server/index.js +1 -1
- package/authentication/server/module.d.ts +2 -2
- package/authentication/server/module.js +4 -2
- package/authentication/server/schemas.d.ts +11 -7
- package/authentication/server/schemas.js +7 -3
- package/authentication/tests/authentication-password-requirements.validator.test.js +29 -0
- package/authentication/tests/authentication.api-controller.test.js +49 -15
- package/authentication/tests/authentication.client-error-handling.test.js +3 -2
- package/authentication/tests/authentication.client-middleware.test.js +5 -5
- package/authentication/tests/authentication.client-service-methods.test.js +28 -14
- package/authentication/tests/authentication.client-service-refresh.test.js +7 -6
- package/authentication/tests/authentication.client-service.test.js +10 -8
- package/authentication/tests/authentication.service.test.js +37 -29
- package/authentication/tests/authentication.test-ancillary-service.d.ts +1 -1
- package/authentication/tests/authentication.test-ancillary-service.js +1 -1
- package/authentication/tests/brute-force-protection.test.js +211 -0
- package/authentication/tests/helper.test.js +25 -21
- package/authentication/tests/password-requirements.error.test.js +14 -0
- package/authentication/tests/remember.api.test.js +22 -14
- package/authentication/tests/remember.service.test.js +23 -16
- package/authentication/tests/subject.service.test.js +2 -2
- package/authentication/tests/suspended-subject.test.d.ts +1 -0
- package/authentication/tests/suspended-subject.test.js +120 -0
- package/authentication/tests/totp.enrollment.test.d.ts +1 -0
- package/authentication/tests/totp.enrollment.test.js +123 -0
- package/authentication/tests/totp.login.test.d.ts +1 -0
- package/authentication/tests/totp.login.test.js +213 -0
- package/authentication/tests/totp.recovery-codes.test.d.ts +1 -0
- package/authentication/tests/totp.recovery-codes.test.js +97 -0
- package/authentication/tests/totp.status.test.d.ts +1 -0
- package/authentication/tests/totp.status.test.js +72 -0
- package/circuit-breaker/postgres/drizzle/{0000_cooing_korath.sql → 0000_same_captain_cross.sql} +1 -1
- package/circuit-breaker/postgres/drizzle/meta/0000_snapshot.json +2 -2
- package/circuit-breaker/postgres/drizzle/meta/_journal.json +2 -2
- package/cryptography/cryptography.d.ts +336 -0
- package/cryptography/cryptography.js +328 -0
- package/cryptography/index.d.ts +4 -0
- package/cryptography/index.js +4 -0
- package/{utils → cryptography}/jwt.d.ts +22 -4
- package/{utils → cryptography}/jwt.js +36 -18
- package/cryptography/module.d.ts +35 -0
- package/cryptography/module.js +148 -0
- package/cryptography/tests/cryptography.test.d.ts +1 -0
- package/cryptography/tests/cryptography.test.js +175 -0
- package/cryptography/tests/jwt.test.d.ts +1 -0
- package/cryptography/tests/jwt.test.js +54 -0
- package/cryptography/tests/modern.test.d.ts +1 -0
- package/cryptography/tests/modern.test.js +105 -0
- package/cryptography/tests/module.test.d.ts +1 -0
- package/cryptography/tests/module.test.js +100 -0
- package/cryptography/tests/totp.test.d.ts +1 -0
- package/cryptography/tests/totp.test.js +108 -0
- package/cryptography/totp.d.ts +96 -0
- package/cryptography/totp.js +123 -0
- package/document-management/server/drizzle/{0000_curious_nighthawk.sql → 0000_sharp_scream.sql} +21 -21
- package/document-management/server/drizzle/meta/0000_snapshot.json +22 -22
- package/document-management/server/drizzle/meta/_journal.json +2 -2
- package/document-management/server/services/document-file.service.js +1 -1
- package/errors/errors.localization.d.ts +2 -2
- package/errors/errors.localization.js +2 -2
- package/errors/index.d.ts +1 -0
- package/errors/index.js +1 -0
- package/errors/too-many-requests.error.d.ts +5 -0
- package/errors/too-many-requests.error.js +7 -0
- package/examples/api/authentication.js +5 -5
- package/examples/api/custom-authentication.js +4 -3
- package/file/server/mime-type.js +1 -1
- package/http/http-body.d.ts +1 -0
- package/http/http-body.js +3 -0
- package/image-service/imgproxy/imgproxy-image-service.d.ts +0 -1
- package/image-service/imgproxy/imgproxy-image-service.js +9 -27
- package/key-value-store/postgres/drizzle/{0000_shocking_slipstream.sql → 0000_moaning_calypso.sql} +1 -1
- package/key-value-store/postgres/drizzle/meta/0000_snapshot.json +2 -2
- package/key-value-store/postgres/drizzle/meta/_journal.json +2 -2
- package/lock/postgres/drizzle/{0000_busy_tattoo.sql → 0000_nappy_wraith.sql} +1 -1
- package/lock/postgres/drizzle/meta/0000_snapshot.json +2 -2
- package/lock/postgres/drizzle/meta/_journal.json +2 -2
- package/logger/formatters/json.js +1 -1
- package/logger/formatters/pretty-print.js +1 -1
- package/mail/drizzle/{0000_numerous_the_watchers.sql → 0000_cultured_quicksilver.sql} +2 -2
- package/mail/drizzle/meta/0000_snapshot.json +4 -4
- package/mail/drizzle/meta/_journal.json +2 -9
- package/notification/server/drizzle/{0000_wise_pyro.sql → 0000_new_tenebrous.sql} +6 -6
- package/notification/server/drizzle/meta/0000_snapshot.json +7 -7
- package/notification/server/drizzle/meta/_journal.json +2 -2
- package/notification/tests/notification-flow.test.js +1 -8
- package/notification/tests/notification-type.service.test.js +3 -3
- package/openid-connect/oidc.service.js +2 -3
- package/orm/data-types/common.js +1 -1
- package/orm/server/drizzle/schema-converter.js +9 -4
- package/orm/server/encryption.js +1 -1
- package/orm/server/module.d.ts +0 -1
- package/orm/server/module.js +0 -4
- package/orm/server/repository.d.ts +2 -1
- package/orm/server/repository.js +7 -10
- package/orm/tests/encryption.test.js +4 -6
- package/orm/tests/repository-extra-coverage.test.js +0 -2
- package/orm/tests/repository-regression.test.js +0 -3
- package/package.json +9 -8
- package/password/README.md +1 -1
- package/password/have-i-been-pwned.js +1 -1
- package/rate-limit/postgres/drizzle/{0000_watery_rage.sql → 0000_serious_sauron.sql} +1 -1
- package/rate-limit/postgres/drizzle/meta/0000_snapshot.json +2 -2
- package/rate-limit/postgres/drizzle/meta/_journal.json +2 -2
- package/rate-limit/postgres/postgres-rate-limiter.d.ts +1 -1
- package/rate-limit/postgres/postgres-rate-limiter.js +1 -1
- package/rate-limit/rate-limiter.d.ts +1 -1
- package/rpc/tests/rpc.integration.test.js +25 -31
- package/supports.d.ts +1 -0
- package/supports.js +1 -0
- package/task-queue/postgres/drizzle/{0000_faithful_daimon_hellstrom.sql → 0000_dark_ronan.sql} +5 -5
- package/task-queue/postgres/drizzle/meta/0000_snapshot.json +10 -10
- package/task-queue/postgres/drizzle/meta/_journal.json +2 -9
- package/task-queue/postgres/task-queue.js +2 -2
- package/task-queue/tests/coverage-enhancement.test.js +2 -2
- package/test/drizzle/{0000_natural_cannonball.sql → 0000_organic_gamora.sql} +2 -2
- package/test/drizzle/meta/0000_snapshot.json +3 -4
- package/test/drizzle/meta/_journal.json +2 -9
- package/testing/integration-setup.d.ts +7 -3
- package/testing/integration-setup.js +119 -96
- package/utils/alphabet.d.ts +1 -0
- package/utils/alphabet.js +1 -0
- package/utils/base32.d.ts +4 -0
- package/utils/base32.js +49 -0
- package/utils/base64.d.ts +0 -2
- package/utils/base64.js +6 -70
- package/utils/equals.d.ts +13 -3
- package/utils/equals.js +29 -9
- package/utils/index.d.ts +1 -2
- package/utils/index.js +1 -2
- package/utils/random.d.ts +1 -0
- package/utils/random.js +14 -8
- package/authentication/errors/secret-requirements.error.d.ts +0 -5
- package/authentication/models/authentication-credentials.model.d.ts +0 -10
- package/authentication/models/secret-check-result.model.d.ts +0 -3
- package/authentication/server/authentication-secret-requirements.validator.d.ts +0 -55
- package/authentication/tests/authentication-ancillary.service.test.js +0 -13
- package/authentication/tests/authentication-secret-requirements.validator.test.js +0 -29
- package/authentication/tests/secret-requirements.error.test.js +0 -14
- package/mail/drizzle/0001_married_tarantula.sql +0 -12
- package/mail/drizzle/meta/0001_snapshot.json +0 -69
- package/orm/server/tokens.d.ts +0 -1
- package/orm/server/tokens.js +0 -2
- package/task-queue/postgres/drizzle/0001_rapid_infant_terrible.sql +0 -16
- package/task-queue/postgres/drizzle/meta/0001_snapshot.json +0 -753
- package/test/drizzle/0001_closed_the_captain.sql +0 -2
- package/test/drizzle/meta/0001_snapshot.json +0 -117
- package/utils/cryptography.d.ts +0 -137
- package/utils/cryptography.js +0 -201
- /package/authentication/tests/{authentication-ancillary.service.test.d.ts → authentication-password-requirements.validator.test.d.ts} +0 -0
- /package/authentication/tests/{authentication-secret-requirements.validator.test.d.ts → brute-force-protection.test.d.ts} +0 -0
- /package/authentication/tests/{secret-requirements.error.test.d.ts → password-requirements.error.test.d.ts} +0 -0
|
@@ -8,6 +8,7 @@ import { Lock } from '../../lock/index.js';
|
|
|
8
8
|
import { Logger } from '../../logger/index.js';
|
|
9
9
|
import { MessageBus } from '../../message-bus/index.js';
|
|
10
10
|
import { configureDefaultSignalsImplementation } from '../../signals/implementation/configure.js';
|
|
11
|
+
import { currentTimestampSeconds } from '../../utils/date-time.js';
|
|
11
12
|
import { timeout } from '../../utils/timing.js';
|
|
12
13
|
describe('AuthenticationClientService Refresh Loop Reproduction', () => {
|
|
13
14
|
let injector;
|
|
@@ -30,7 +31,7 @@ describe('AuthenticationClientService Refresh Loop Reproduction', () => {
|
|
|
30
31
|
mockApiClient = {
|
|
31
32
|
login: vi.fn(),
|
|
32
33
|
refresh: vi.fn(),
|
|
33
|
-
timestamp: vi.fn().mockResolvedValue(
|
|
34
|
+
timestamp: vi.fn().mockResolvedValue(currentTimestampSeconds()),
|
|
34
35
|
endSession: vi.fn().mockResolvedValue(undefined),
|
|
35
36
|
};
|
|
36
37
|
mockLock = {
|
|
@@ -66,7 +67,7 @@ describe('AuthenticationClientService Refresh Loop Reproduction', () => {
|
|
|
66
67
|
});
|
|
67
68
|
test('Zombie Timer: loop should wake up immediately when token changes', async () => {
|
|
68
69
|
// 1. Mock a long expiration
|
|
69
|
-
const now =
|
|
70
|
+
const now = currentTimestampSeconds();
|
|
70
71
|
const initialToken = { iat: now - 3600, exp: now + 3600, jti: 'initial' };
|
|
71
72
|
// Set in storage so initialize() (called by resolve) loads it
|
|
72
73
|
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
@@ -82,7 +83,7 @@ describe('AuthenticationClientService Refresh Loop Reproduction', () => {
|
|
|
82
83
|
expect(mockApiClient.refresh).toHaveBeenCalled();
|
|
83
84
|
});
|
|
84
85
|
test('Forced Refresh Loss: forceRefreshToken should not be cleared on failure', async () => {
|
|
85
|
-
const now =
|
|
86
|
+
const now = currentTimestampSeconds();
|
|
86
87
|
const initialToken = { iat: now - 3600, exp: now + 3600, jti: 'initial' };
|
|
87
88
|
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
88
89
|
service = injector.resolve(AuthenticationClientService);
|
|
@@ -96,7 +97,7 @@ describe('AuthenticationClientService Refresh Loop Reproduction', () => {
|
|
|
96
97
|
expect(service.forceRefreshRequested()).toBe(true); // Should STILL be set
|
|
97
98
|
});
|
|
98
99
|
test('Lock Contention Backoff: should wait 5 seconds and not busy-loop', async () => {
|
|
99
|
-
const now =
|
|
100
|
+
const now = currentTimestampSeconds();
|
|
100
101
|
const initialToken = { iat: now - 3600, exp: now + 5, jti: 'initial' }; // Expiring soon
|
|
101
102
|
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
102
103
|
// 1. Mock lock already held
|
|
@@ -111,7 +112,7 @@ describe('AuthenticationClientService Refresh Loop Reproduction', () => {
|
|
|
111
112
|
expect(duration).toBeLessThan(500);
|
|
112
113
|
});
|
|
113
114
|
test('Busy Loop: should not busy loop when forceRefreshToken is set and lock is held', async () => {
|
|
114
|
-
const now =
|
|
115
|
+
const now = currentTimestampSeconds();
|
|
115
116
|
const initialToken = { iat: now - 3600, exp: now + 3600, jti: 'initial' };
|
|
116
117
|
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
117
118
|
// Mock lock already held
|
|
@@ -131,7 +132,7 @@ describe('AuthenticationClientService Refresh Loop Reproduction', () => {
|
|
|
131
132
|
expect(mockApiClient.timestamp).toHaveBeenCalled();
|
|
132
133
|
});
|
|
133
134
|
test('Cross-tab Sync: should not refresh if another tab already did (simulated via localStorage update)', async () => {
|
|
134
|
-
const now =
|
|
135
|
+
const now = currentTimestampSeconds();
|
|
135
136
|
const initialToken = { iat: now - 3600, exp: now + 5, jti: 'initial' }; // Expiring soon
|
|
136
137
|
globalThis.localStorage.setItem('AuthenticationService:token', JSON.stringify(initialToken));
|
|
137
138
|
// 1. Mock lock behavior to simulate another tab refreshing while we wait for the lock
|
|
@@ -25,8 +25,10 @@ describe('AuthenticationClientService Integration', () => {
|
|
|
25
25
|
clear: vi.fn(() => storage.clear()),
|
|
26
26
|
};
|
|
27
27
|
({ injector, database } = await setupIntegrationTest({
|
|
28
|
-
modules: { authentication: true, audit: true, keyValueStore: true, signals: true, api: true
|
|
29
|
-
|
|
28
|
+
modules: { authentication: true, audit: true, keyValueStore: true, signals: true, api: true },
|
|
29
|
+
authentication: {
|
|
30
|
+
ancillaryService: DefaultAuthenticationAncillaryService,
|
|
31
|
+
},
|
|
30
32
|
}));
|
|
31
33
|
await runInInjectionContext(injector, async () => {
|
|
32
34
|
server = injector.resolve(HttpServer);
|
|
@@ -44,11 +46,11 @@ describe('AuthenticationClientService Integration', () => {
|
|
|
44
46
|
});
|
|
45
47
|
beforeEach(async () => {
|
|
46
48
|
globalThis.localStorage?.clear();
|
|
47
|
-
await clearTenantData(database, schema, ['
|
|
49
|
+
await clearTenantData(database, schema, ['password', 'session', 'user', 'service_account', 'system_account', 'subject'], tenantId);
|
|
48
50
|
});
|
|
49
51
|
test('login and logout should work', async () => {
|
|
50
52
|
const user = await subjectService.createUser({ tenantId, email: 'client-test@example.com', firstName: 'C', lastName: 'T' });
|
|
51
|
-
await serverService.
|
|
53
|
+
await serverService.setPassword(user, 'Strong-Pass-2026!');
|
|
52
54
|
expect(service.isLoggedIn()).toBe(false);
|
|
53
55
|
await service.login({ tenantId, subject: user.id }, 'Strong-Pass-2026!');
|
|
54
56
|
expect(service.isLoggedIn()).toBe(true);
|
|
@@ -58,17 +60,17 @@ describe('AuthenticationClientService Integration', () => {
|
|
|
58
60
|
});
|
|
59
61
|
test('refresh should work', async () => {
|
|
60
62
|
const user = await subjectService.createUser({ tenantId, email: 'refresh-test@example.com', firstName: 'R', lastName: 'T' });
|
|
61
|
-
await serverService.
|
|
63
|
+
await serverService.setPassword(user, 'Strong-Pass-2026!');
|
|
62
64
|
await service.login({ tenantId, subject: user.id }, 'Strong-Pass-2026!');
|
|
63
65
|
const initialToken = service.token()?.jti;
|
|
64
66
|
await service.refresh();
|
|
65
67
|
expect(service.token()?.jti).not.toBe(initialToken);
|
|
66
68
|
expect(service.isLoggedIn()).toBe(true);
|
|
67
69
|
});
|
|
68
|
-
test('
|
|
69
|
-
const result = await service.
|
|
70
|
+
test('checkPassword should work', async () => {
|
|
71
|
+
const result = await service.checkPassword('123');
|
|
70
72
|
expect(result.strength).toBeLessThan(2);
|
|
71
|
-
const strongResult = await service.
|
|
73
|
+
const strongResult = await service.checkPassword('Very-Strong-Password-2026-!@#$');
|
|
72
74
|
expect(strongResult.strength).toBeGreaterThanOrEqual(2);
|
|
73
75
|
});
|
|
74
76
|
});
|
|
@@ -18,7 +18,9 @@ describe('AuthenticationService', () => {
|
|
|
18
18
|
beforeAll(async () => {
|
|
19
19
|
({ injector, database } = await setupIntegrationTest({
|
|
20
20
|
modules: { authentication: true },
|
|
21
|
-
|
|
21
|
+
authentication: {
|
|
22
|
+
ancillaryService: DefaultAuthenticationAncillaryService,
|
|
23
|
+
},
|
|
22
24
|
}));
|
|
23
25
|
authenticationService = await injector.resolveAsync(AuthenticationService);
|
|
24
26
|
subjectService = await injector.resolveAsync(SubjectService);
|
|
@@ -29,7 +31,7 @@ describe('AuthenticationService', () => {
|
|
|
29
31
|
await injector?.dispose();
|
|
30
32
|
});
|
|
31
33
|
beforeEach(async () => {
|
|
32
|
-
await clearTenantData(database, schema, ['
|
|
34
|
+
await clearTenantData(database, schema, ['password', 'session', 'user', 'service_account', 'system_account', 'subject'], tenantId);
|
|
33
35
|
});
|
|
34
36
|
test('login should create a session and listSessions should return it', async () => {
|
|
35
37
|
const user = await subjectService.createUser({
|
|
@@ -38,8 +40,10 @@ describe('AuthenticationService', () => {
|
|
|
38
40
|
firstName: 'John',
|
|
39
41
|
lastName: 'Doe',
|
|
40
42
|
});
|
|
41
|
-
await authenticationService.
|
|
42
|
-
const
|
|
43
|
+
await authenticationService.setPassword(user, 'Strong-Password-2026!');
|
|
44
|
+
const loginResult = await authenticationService.login({ tenantId, subject: user.id }, 'Strong-Password-2026!', undefined, auditor.with({ actor: user.id, actorType: ActorType.Subject }));
|
|
45
|
+
expect(loginResult.type).toBe('success');
|
|
46
|
+
const tokenResult = loginResult.result;
|
|
43
47
|
expect(tokenResult.token).toBeDefined();
|
|
44
48
|
const sessions = await authenticationService.listSessions(tenantId, user.id);
|
|
45
49
|
expect(sessions).toHaveLength(1);
|
|
@@ -52,7 +56,7 @@ describe('AuthenticationService', () => {
|
|
|
52
56
|
firstName: 'John',
|
|
53
57
|
lastName: 'Doe',
|
|
54
58
|
});
|
|
55
|
-
await authenticationService.
|
|
59
|
+
await authenticationService.setPassword(user, 'Strong-Password-2026!');
|
|
56
60
|
const userAuditor = auditor.with({ actor: user.id, actorType: ActorType.Subject });
|
|
57
61
|
await authenticationService.login({ tenantId, subject: user.id }, 'Strong-Password-2026!', undefined, userAuditor);
|
|
58
62
|
await authenticationService.login({ tenantId, subject: user.id }, 'Strong-Password-2026!', undefined, userAuditor);
|
|
@@ -62,7 +66,7 @@ describe('AuthenticationService', () => {
|
|
|
62
66
|
expect(sessions.every((s) => s.end > now)).toBe(true);
|
|
63
67
|
await authenticationService.invalidateAllSessions(tenantId, user.id, userAuditor);
|
|
64
68
|
sessions = await authenticationService.listSessions(tenantId, user.id);
|
|
65
|
-
expect(sessions
|
|
69
|
+
expect(sessions).toHaveLength(0);
|
|
66
70
|
});
|
|
67
71
|
test('getSession and tryGetSession', async () => {
|
|
68
72
|
const user = await subjectService.createUser({
|
|
@@ -71,9 +75,11 @@ describe('AuthenticationService', () => {
|
|
|
71
75
|
firstName: 'John',
|
|
72
76
|
lastName: 'Doe',
|
|
73
77
|
});
|
|
74
|
-
await authenticationService.
|
|
78
|
+
await authenticationService.setPassword(user, 'Strong-Password-2026!');
|
|
75
79
|
const userAuditor = auditor.with({ actor: user.id, actorType: ActorType.Subject });
|
|
76
|
-
const
|
|
80
|
+
const loginResult = await authenticationService.login({ tenantId, subject: user.id }, 'Strong-Password-2026!', undefined, userAuditor);
|
|
81
|
+
expect(loginResult.type).toBe('success');
|
|
82
|
+
const tokenResult = loginResult.result;
|
|
77
83
|
const sessionId = tokenResult.jsonToken.payload.session;
|
|
78
84
|
const session = await authenticationService.getSession(sessionId);
|
|
79
85
|
expect(session.id).toBe(sessionId);
|
|
@@ -84,30 +90,32 @@ describe('AuthenticationService', () => {
|
|
|
84
90
|
});
|
|
85
91
|
test('refresh should issue new token and session', async () => {
|
|
86
92
|
const user = await subjectService.createUser({ tenantId, email: 'refresh@example.com', firstName: 'R', lastName: 'F' });
|
|
87
|
-
await authenticationService.
|
|
93
|
+
await authenticationService.setPassword(user, 'Strong-Password-2026!');
|
|
88
94
|
const userAuditor = auditor.with({ actor: user.id, actorType: ActorType.Subject });
|
|
89
95
|
const loginResult = await authenticationService.login({ tenantId, subject: user.id }, 'Strong-Password-2026!', undefined, userAuditor);
|
|
90
|
-
|
|
96
|
+
expect(loginResult.type).toBe('success');
|
|
97
|
+
const loginSuccessResult = loginResult.result;
|
|
98
|
+
const refreshResult = await authenticationService.refresh(loginSuccessResult.refreshToken, undefined, {}, userAuditor);
|
|
91
99
|
expect(refreshResult.token).toBeDefined();
|
|
92
|
-
expect(refreshResult.refreshToken).not.toBe(
|
|
100
|
+
expect(refreshResult.refreshToken).not.toBe(loginSuccessResult.refreshToken);
|
|
93
101
|
});
|
|
94
|
-
test('
|
|
102
|
+
test('changePassword should update password', async () => {
|
|
95
103
|
const user = await subjectService.createUser({ tenantId, email: 'change@example.com', firstName: 'C', lastName: 'S' });
|
|
96
|
-
await authenticationService.
|
|
104
|
+
await authenticationService.setPassword(user, 'Old-Password-2026!');
|
|
97
105
|
const userAuditor = auditor.with({ actor: user.id, actorType: ActorType.Subject });
|
|
98
|
-
await authenticationService.
|
|
99
|
-
const authResult = await authenticationService.
|
|
106
|
+
await authenticationService.changePassword({ tenantId, subject: user.id }, 'Old-Password-2026!', 'New-Password-2026!', userAuditor);
|
|
107
|
+
const authResult = await authenticationService.authenticateWithPassword({ tenantId, subject: user.id }, 'New-Password-2026!');
|
|
100
108
|
expect(authResult.success).toBe(true);
|
|
101
109
|
});
|
|
102
|
-
test('
|
|
110
|
+
test('checkPassword, testPassword, and validatePassword', async () => {
|
|
103
111
|
const weak = 'abc';
|
|
104
112
|
const strong = 'Very-Strong-Password-2026-!@#$';
|
|
105
|
-
expect((await authenticationService.
|
|
106
|
-
expect((await authenticationService.
|
|
107
|
-
expect((await authenticationService.
|
|
108
|
-
expect((await authenticationService.
|
|
109
|
-
await expect(authenticationService.
|
|
110
|
-
await expect(authenticationService.
|
|
113
|
+
expect((await authenticationService.checkPassword(weak)).strength).toBeLessThan(2);
|
|
114
|
+
expect((await authenticationService.checkPassword(strong)).strength).toBeGreaterThanOrEqual(2);
|
|
115
|
+
expect((await authenticationService.testPassword(weak)).success).toBe(false);
|
|
116
|
+
expect((await authenticationService.testPassword(strong)).success).toBe(true);
|
|
117
|
+
await expect(authenticationService.validatePassword(weak)).rejects.toThrow();
|
|
118
|
+
await expect(authenticationService.validatePassword(strong)).resolves.not.toThrow();
|
|
111
119
|
});
|
|
112
120
|
test('tryResolveSubject and resolveSubject', async () => {
|
|
113
121
|
const user = await subjectService.createUser({ tenantId, email: 'resolve@example.com', firstName: 'R', lastName: 'S' });
|
|
@@ -125,16 +133,16 @@ describe('AuthenticationService', () => {
|
|
|
125
133
|
const impersonated = await authenticationService.impersonate(adminToken.token, adminToken.refreshToken, user.id, undefined, adminAuditor);
|
|
126
134
|
expect(impersonated.jsonToken.payload.subject).toBe(user.id);
|
|
127
135
|
expect(impersonated.jsonToken.payload.impersonator).toBe(admin.id);
|
|
128
|
-
const unimpersonated = await authenticationService.unimpersonate(impersonated.impersonatorRefreshToken, undefined, adminAuditor);
|
|
136
|
+
const unimpersonated = await authenticationService.unimpersonate(impersonated.impersonatorRefreshToken, impersonated.token, undefined, adminAuditor);
|
|
129
137
|
expect(unimpersonated.jsonToken.payload.subject).toBe(admin.id);
|
|
130
138
|
expect(unimpersonated.jsonToken.payload.impersonator).toBeUndefined();
|
|
131
139
|
});
|
|
132
140
|
test('refresh should throw on invalid token', async () => {
|
|
133
141
|
await expect(authenticationService.refresh('invalid', undefined, {}, auditor)).rejects.toThrow();
|
|
134
142
|
});
|
|
135
|
-
test('login should throw on invalid
|
|
143
|
+
test('login should throw on invalid password', async () => {
|
|
136
144
|
const user = await subjectService.createUser({ tenantId, email: 'fail@example.com', firstName: 'F', lastName: 'L' });
|
|
137
|
-
await authenticationService.
|
|
145
|
+
await authenticationService.setPassword(user, 'Very-Strong-Password-2026!');
|
|
138
146
|
await expect(authenticationService.login({ tenantId, subject: user.id }, 'wrong', undefined, auditor)).rejects.toThrow();
|
|
139
147
|
});
|
|
140
148
|
test('endSession should handle non-existent session gracefully', async () => {
|
|
@@ -143,13 +151,13 @@ describe('AuthenticationService', () => {
|
|
|
143
151
|
test('resolveSubject should throw if not found', async () => {
|
|
144
152
|
await expect(authenticationService.resolveSubject({ tenantId, subject: 'missing' })).rejects.toThrow();
|
|
145
153
|
});
|
|
146
|
-
test('
|
|
154
|
+
test('password reset flow', async () => {
|
|
147
155
|
const user = await subjectService.createUser({ tenantId, email: 'reset@example.com', firstName: 'R', lastName: 'E' });
|
|
148
156
|
const userAuditor = auditor.with({ actor: user.id, actorType: ActorType.Subject });
|
|
149
|
-
await authenticationService.
|
|
157
|
+
await authenticationService.initPasswordReset({ tenantId, subject: user.id }, undefined, userAuditor);
|
|
150
158
|
expect(ancillaryService.lastResetData).toBeDefined();
|
|
151
|
-
await authenticationService.
|
|
152
|
-
const authResult = await authenticationService.
|
|
159
|
+
await authenticationService.resetPassword(ancillaryService.lastResetData.token, 'New-Password-Reset-2026!', userAuditor);
|
|
160
|
+
const authResult = await authenticationService.authenticateWithPassword({ tenantId, subject: user.id }, 'New-Password-Reset-2026!');
|
|
153
161
|
expect(authResult.success).toBe(true);
|
|
154
162
|
});
|
|
155
163
|
test('deriveSigningSecrets should work', async () => {
|
|
@@ -3,7 +3,7 @@ export declare class DefaultAuthenticationAncillaryService extends Authenticatio
|
|
|
3
3
|
#private;
|
|
4
4
|
lastResetData: any;
|
|
5
5
|
getTokenPayload(): Promise<{}>;
|
|
6
|
-
|
|
6
|
+
handleInitPasswordReset(data: any): Promise<void>;
|
|
7
7
|
canImpersonate(_token: any, _subject: any, _data: any): Promise<boolean>;
|
|
8
8
|
resolveSubjects(data: any): Promise<import("../index.js").Subject[]>;
|
|
9
9
|
}
|
|
@@ -12,7 +12,7 @@ let DefaultAuthenticationAncillaryService = class DefaultAuthenticationAncillary
|
|
|
12
12
|
#authenticationService;
|
|
13
13
|
lastResetData;
|
|
14
14
|
async getTokenPayload() { return {}; }
|
|
15
|
-
async
|
|
15
|
+
async handleInitPasswordReset(data) { this.lastResetData = data; }
|
|
16
16
|
async canImpersonate(_token, _subject, _data) { return true; }
|
|
17
17
|
async resolveSubjects(data) {
|
|
18
18
|
if (isUndefined(this.#authenticationService)) {
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
import { afterAll, beforeAll, beforeEach, describe, expect, test } from 'vitest';
|
|
2
|
+
import { ApiGateway } from '../../api/server/gateway.js';
|
|
3
|
+
import { Auditor } from '../../audit/index.js';
|
|
4
|
+
import { importKey } from '../../cryptography/index.js';
|
|
5
|
+
import { generateTotpToken } from '../../cryptography/totp.js';
|
|
6
|
+
import { HttpBody } from '../../http/http-body.js';
|
|
7
|
+
import { HttpHeaders } from '../../http/http-headers.js';
|
|
8
|
+
import { HttpQuery } from '../../http/http-query.js';
|
|
9
|
+
import { HttpServerRequest } from '../../http/server/http-server-request.js';
|
|
10
|
+
import { clearTenantData, setupIntegrationTest } from '../../testing/index.js';
|
|
11
|
+
import { currentTimestampSeconds } from '../../utils/date-time.js';
|
|
12
|
+
import { assert } from '../../utils/type-guards.js';
|
|
13
|
+
import { AuthenticationService } from '../server/authentication.service.js';
|
|
14
|
+
import { SubjectService } from '../server/subject.service.js';
|
|
15
|
+
import { DefaultAuthenticationAncillaryService } from './authentication.test-ancillary-service.js';
|
|
16
|
+
describe('Authentication Brute Force Protection (API Level)', () => {
|
|
17
|
+
let injector;
|
|
18
|
+
let database;
|
|
19
|
+
let gateway;
|
|
20
|
+
let subjectService;
|
|
21
|
+
let authenticationService;
|
|
22
|
+
let auditor;
|
|
23
|
+
const schema = 'authentication';
|
|
24
|
+
const tenantId = crypto.randomUUID();
|
|
25
|
+
beforeAll(async () => {
|
|
26
|
+
({ injector, database } = await setupIntegrationTest({
|
|
27
|
+
modules: { authentication: true, rateLimiter: true, api: true },
|
|
28
|
+
authentication: {
|
|
29
|
+
ancillaryService: DefaultAuthenticationAncillaryService,
|
|
30
|
+
options: {
|
|
31
|
+
bruteForceProtection: {
|
|
32
|
+
subjectBurstCapacity: 2,
|
|
33
|
+
subjectRefillInterval: 10000,
|
|
34
|
+
ipBurstCapacity: 2,
|
|
35
|
+
ipRefillInterval: 10000,
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
},
|
|
39
|
+
}));
|
|
40
|
+
gateway = injector.resolve(ApiGateway);
|
|
41
|
+
subjectService = await injector.resolveAsync(SubjectService);
|
|
42
|
+
authenticationService = await injector.resolveAsync(AuthenticationService);
|
|
43
|
+
auditor = injector.resolve(Auditor);
|
|
44
|
+
});
|
|
45
|
+
afterAll(async () => {
|
|
46
|
+
await injector?.dispose();
|
|
47
|
+
});
|
|
48
|
+
beforeEach(async () => {
|
|
49
|
+
await clearTenantData(database, schema, ['used_totp_tokens', 'totp_recovery_code', 'totp', 'password', 'session', 'user', 'service_account', 'system_account', 'subject'], tenantId);
|
|
50
|
+
});
|
|
51
|
+
async function callLogin(ip, body) {
|
|
52
|
+
const headers = new HttpHeaders({ 'Content-Type': 'application/json' });
|
|
53
|
+
const encodedBody = new TextEncoder().encode(JSON.stringify(body));
|
|
54
|
+
const request = new HttpServerRequest({
|
|
55
|
+
url: new URL('http://localhost/api/v1/auth/token'),
|
|
56
|
+
method: 'POST',
|
|
57
|
+
headers,
|
|
58
|
+
query: new HttpQuery(),
|
|
59
|
+
ip,
|
|
60
|
+
body: HttpBody.from(encodedBody, headers),
|
|
61
|
+
});
|
|
62
|
+
let capturedResponse;
|
|
63
|
+
const respond = async (response) => { capturedResponse = response; };
|
|
64
|
+
const close = async () => { };
|
|
65
|
+
const abortSignal = new AbortController().signal;
|
|
66
|
+
await gateway.handleHttpServerRequestContext({ request, respond, close, abortSignal, context: {} });
|
|
67
|
+
return capturedResponse;
|
|
68
|
+
}
|
|
69
|
+
async function callDisableTotp(ip, token, body) {
|
|
70
|
+
const headers = new HttpHeaders({
|
|
71
|
+
'Content-Type': 'application/json',
|
|
72
|
+
'Authorization': `Bearer ${token}`
|
|
73
|
+
});
|
|
74
|
+
const encodedBody = new TextEncoder().encode(JSON.stringify(body));
|
|
75
|
+
const request = new HttpServerRequest({
|
|
76
|
+
url: new URL('http://localhost/api/v1/auth/totp/disable'),
|
|
77
|
+
method: 'POST',
|
|
78
|
+
headers,
|
|
79
|
+
query: new HttpQuery(),
|
|
80
|
+
ip,
|
|
81
|
+
body: HttpBody.from(encodedBody, headers),
|
|
82
|
+
});
|
|
83
|
+
let capturedResponse;
|
|
84
|
+
const respond = async (response) => { capturedResponse = response; };
|
|
85
|
+
const close = async () => { };
|
|
86
|
+
const abortSignal = new AbortController().signal;
|
|
87
|
+
await gateway.handleHttpServerRequestContext({ request, respond, close, abortSignal, context: {} });
|
|
88
|
+
return capturedResponse;
|
|
89
|
+
}
|
|
90
|
+
test('should limit login attempts by user', async () => {
|
|
91
|
+
const user = await subjectService.createUser({ tenantId, email: 'user-limit@example.com', firstName: 'John', lastName: 'Doe' });
|
|
92
|
+
await authenticationService.setPassword(user, 'Strong-Password-2026!');
|
|
93
|
+
const body = { tenantId, subject: user.id, password: 'wrong' };
|
|
94
|
+
// 1st attempt - Fail (InvalidCredentials)
|
|
95
|
+
let res = await callLogin('1.1.1.1', body);
|
|
96
|
+
expect(res.statusCode).toBe(401);
|
|
97
|
+
// 2nd attempt - Fail (InvalidCredentials)
|
|
98
|
+
res = await callLogin('1.1.1.2', body);
|
|
99
|
+
expect(res.statusCode).toBe(401);
|
|
100
|
+
// 3rd attempt - Should be throttled (429)
|
|
101
|
+
res = await callLogin('1.1.1.3', body);
|
|
102
|
+
expect(res.statusCode).toBe(429);
|
|
103
|
+
expect(res.body.json.error.name).toBe('TooManyRequestsError');
|
|
104
|
+
});
|
|
105
|
+
test('should limit login attempts by IP', async () => {
|
|
106
|
+
const ip = '2.2.2.2';
|
|
107
|
+
const user1 = await subjectService.createUser({ tenantId, email: 'ip-limit-1@example.com', firstName: 'A', lastName: 'B' });
|
|
108
|
+
const user2 = await subjectService.createUser({ tenantId, email: 'ip-limit-2@example.com', firstName: 'C', lastName: 'D' });
|
|
109
|
+
await authenticationService.setPassword(user1, 'Strong-Password-2026!');
|
|
110
|
+
await authenticationService.setPassword(user2, 'Strong-Password-2026!');
|
|
111
|
+
// Fail for user1
|
|
112
|
+
let res = await callLogin(ip, { tenantId, subject: user1.id, password: 'wrong' });
|
|
113
|
+
expect(res.statusCode).toBe(401);
|
|
114
|
+
// Fail for user2
|
|
115
|
+
res = await callLogin(ip, { tenantId, subject: user2.id, password: 'wrong' });
|
|
116
|
+
expect(res.statusCode).toBe(401);
|
|
117
|
+
// 3rd attempt from same IP - Should be throttled (429)
|
|
118
|
+
res = await callLogin(ip, { tenantId, subject: 'any', password: 'any' });
|
|
119
|
+
expect(res.statusCode).toBe(429);
|
|
120
|
+
});
|
|
121
|
+
test('should work for multiple successful logins', async () => {
|
|
122
|
+
const user1 = await subjectService.createUser({ tenantId, email: 'success1@example.com', firstName: 'E', lastName: 'F' });
|
|
123
|
+
const user2 = await subjectService.createUser({ tenantId, email: 'success2@example.com', firstName: 'E', lastName: 'F' });
|
|
124
|
+
const user3 = await subjectService.createUser({ tenantId, email: 'success3@example.com', firstName: 'E', lastName: 'F' });
|
|
125
|
+
await authenticationService.setPassword(user1, 'Strong-Password-2026!');
|
|
126
|
+
await authenticationService.setPassword(user2, 'Strong-Password-2026!');
|
|
127
|
+
await authenticationService.setPassword(user3, 'Strong-Password-2026!');
|
|
128
|
+
// 1st attempt
|
|
129
|
+
let res = await callLogin('4.4.4.1', { tenantId, subject: user1.id, password: 'Strong-Password-2026!' });
|
|
130
|
+
expect(res.statusCode).toBe(200);
|
|
131
|
+
// 2nd attempt
|
|
132
|
+
res = await callLogin('4.4.4.2', { tenantId, subject: user2.id, password: 'Strong-Password-2026!' });
|
|
133
|
+
expect(res.statusCode).toBe(200);
|
|
134
|
+
// 3rd attempt
|
|
135
|
+
res = await callLogin('4.4.4.3', { tenantId, subject: user3.id, password: 'Strong-Password-2026!' });
|
|
136
|
+
expect(res.statusCode).toBe(200);
|
|
137
|
+
});
|
|
138
|
+
test('should limit disableTotp attempts by user', async () => {
|
|
139
|
+
const password = 'Strong-Password-2026!';
|
|
140
|
+
const user = await subjectService.createUser({ tenantId, email: 'totp-limit@example.com', firstName: 'John', lastName: 'Doe' });
|
|
141
|
+
await authenticationService.setPassword(user, password);
|
|
142
|
+
// Enable TOTP for the user
|
|
143
|
+
await authenticationService.initEnrollTotp(tenantId, user.id, auditor);
|
|
144
|
+
const totpEntry = await authenticationService.tryGetTotp(tenantId, user.id);
|
|
145
|
+
const secret = await importKey('raw-secret', totpEntry.secret, { name: 'HMAC', hash: authenticationService.getTotpOptions().codeHashAlgorithm }, false, ['sign']);
|
|
146
|
+
const setupToken = await generateTotpToken(secret, { ...authenticationService.getTotpOptions(), timestamp: currentTimestampSeconds() - 30 });
|
|
147
|
+
await authenticationService.completeEnrollTotp(tenantId, user.id, setupToken, auditor);
|
|
148
|
+
// Login to get a session token
|
|
149
|
+
const loginResult = await authenticationService.login({ tenantId, subject: user.id }, password, undefined, auditor);
|
|
150
|
+
expect(loginResult.type).toBe('totp');
|
|
151
|
+
assert(loginResult.type == 'totp', 'Login should have triggered TOTP');
|
|
152
|
+
const verifyToken = await generateTotpToken(secret, authenticationService.getTotpOptions());
|
|
153
|
+
const verifyResult = await authenticationService.loginVerifyTotp(loginResult.challengeToken, verifyToken, auditor);
|
|
154
|
+
expect(verifyResult.type).toBe('success');
|
|
155
|
+
assert(verifyResult.type == 'success', 'TOTP verification failed');
|
|
156
|
+
const sessionToken = verifyResult.result.token;
|
|
157
|
+
// 1st attempt - Fail (Invalid TOTP token)
|
|
158
|
+
let res = await callDisableTotp('1.1.1.1', sessionToken, { token: '000000' });
|
|
159
|
+
expect(res.statusCode).toBe(403);
|
|
160
|
+
// 2nd attempt - Fail (Invalid TOTP token)
|
|
161
|
+
res = await callDisableTotp('1.1.1.2', sessionToken, { token: '000001' });
|
|
162
|
+
expect(res.statusCode).toBe(403);
|
|
163
|
+
// 3rd attempt - Should be throttled (429)
|
|
164
|
+
res = await callDisableTotp('1.1.1.3', sessionToken, { token: '000002' });
|
|
165
|
+
expect(res.statusCode).toBe(429);
|
|
166
|
+
expect(res.body.json.error.name).toBe('TooManyRequestsError');
|
|
167
|
+
});
|
|
168
|
+
test('should limit disableTotp attempts by IP', async () => {
|
|
169
|
+
const password = 'Strong-Password-2026!';
|
|
170
|
+
const ip = '3.3.3.3';
|
|
171
|
+
// Create two users with TOTP enabled
|
|
172
|
+
const user1 = await subjectService.createUser({ tenantId, email: 'totp-ip-1@example.com', firstName: 'A', lastName: 'B' });
|
|
173
|
+
await authenticationService.setPassword(user1, password);
|
|
174
|
+
await authenticationService.initEnrollTotp(tenantId, user1.id, auditor);
|
|
175
|
+
const totpEntry1 = await authenticationService.tryGetTotp(tenantId, user1.id);
|
|
176
|
+
const secret1 = await importKey('raw-secret', totpEntry1.secret, { name: 'HMAC', hash: authenticationService.getTotpOptions().codeHashAlgorithm }, false, ['sign']);
|
|
177
|
+
const setupToken1 = await generateTotpToken(secret1, { ...authenticationService.getTotpOptions(), timestamp: currentTimestampSeconds() - 30 });
|
|
178
|
+
await authenticationService.completeEnrollTotp(tenantId, user1.id, setupToken1, auditor);
|
|
179
|
+
const user2 = await subjectService.createUser({ tenantId, email: 'totp-ip-2@example.com', firstName: 'C', lastName: 'D' });
|
|
180
|
+
await authenticationService.setPassword(user2, password);
|
|
181
|
+
await authenticationService.initEnrollTotp(tenantId, user2.id, auditor);
|
|
182
|
+
const totpEntry2 = await authenticationService.tryGetTotp(tenantId, user2.id);
|
|
183
|
+
const secret2 = await importKey('raw-secret', totpEntry2.secret, { name: 'HMAC', hash: authenticationService.getTotpOptions().codeHashAlgorithm }, false, ['sign']);
|
|
184
|
+
const setupToken2 = await generateTotpToken(secret2, { ...authenticationService.getTotpOptions(), timestamp: currentTimestampSeconds() - 30 });
|
|
185
|
+
await authenticationService.completeEnrollTotp(tenantId, user2.id, setupToken2, auditor);
|
|
186
|
+
// Login both users
|
|
187
|
+
const loginResult1 = await authenticationService.login({ tenantId, subject: user1.id }, password, undefined, auditor);
|
|
188
|
+
const loginResult2 = await authenticationService.login({ tenantId, subject: user2.id }, password, undefined, auditor);
|
|
189
|
+
expect(loginResult1.type).toBe('totp');
|
|
190
|
+
expect(loginResult2.type).toBe('totp');
|
|
191
|
+
assert(loginResult1.type == 'totp' && loginResult2.type == 'totp', 'Login should have triggered TOTP');
|
|
192
|
+
const verifyToken1 = await generateTotpToken(secret1, authenticationService.getTotpOptions());
|
|
193
|
+
const verifyToken2 = await generateTotpToken(secret2, authenticationService.getTotpOptions());
|
|
194
|
+
const verifyResult1 = await authenticationService.loginVerifyTotp(loginResult1.challengeToken, verifyToken1, auditor);
|
|
195
|
+
const verifyResult2 = await authenticationService.loginVerifyTotp(loginResult2.challengeToken, verifyToken2, auditor);
|
|
196
|
+
expect(verifyResult1.type).toBe('success');
|
|
197
|
+
expect(verifyResult2.type).toBe('success');
|
|
198
|
+
assert(verifyResult1.type == 'success' && verifyResult2.type == 'success', 'TOTP verification failed');
|
|
199
|
+
const sessionToken1 = verifyResult1.result.token;
|
|
200
|
+
const sessionToken2 = verifyResult2.result.token;
|
|
201
|
+
// Fail for user1 from IP
|
|
202
|
+
let res = await callDisableTotp(ip, sessionToken1, { token: '000000' });
|
|
203
|
+
expect(res.statusCode).toBe(403);
|
|
204
|
+
// Fail for user2 from same IP
|
|
205
|
+
res = await callDisableTotp(ip, sessionToken2, { token: '000000' });
|
|
206
|
+
expect(res.statusCode).toBe(403);
|
|
207
|
+
// 3rd attempt from same IP - Should be throttled (429)
|
|
208
|
+
res = await callDisableTotp(ip, sessionToken1, { token: 'any' });
|
|
209
|
+
expect(res.statusCode).toBe(429);
|
|
210
|
+
});
|
|
211
|
+
});
|
|
@@ -1,11 +1,15 @@
|
|
|
1
|
-
import { describe, expect, test } from 'vitest';
|
|
1
|
+
import { beforeAll, describe, expect, test } from 'vitest';
|
|
2
|
+
import { createJwtTokenString, importKmacKey } from '../../cryptography/index.js';
|
|
2
3
|
import { BadRequestError } from '../../errors/bad-request.error.js';
|
|
3
4
|
import { InvalidTokenError } from '../../errors/invalid-token.error.js';
|
|
4
5
|
import { currentTimestampSeconds } from '../../utils/date-time.js';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
6
|
+
import { encodeUtf8 } from '../../utils/encoding.js';
|
|
7
|
+
import { getPasswordResetTokenFromString, getRefreshTokenFromString, getTokenFromRequest, getTokenFromString, tryGetAuthorizationTokenStringFromRequest, tryGetTokenFromRequest } from '../server/helper.js';
|
|
7
8
|
describe('authentication helper', () => {
|
|
8
|
-
|
|
9
|
+
let signingKey;
|
|
10
|
+
beforeAll(async () => {
|
|
11
|
+
signingKey = await importKmacKey('raw-secret', 'KMAC256', encodeUtf8('test-secret-with-enough-length-32'));
|
|
12
|
+
});
|
|
9
13
|
test('tryGetAuthorizationTokenStringFromRequest should extract bearer token from header', () => {
|
|
10
14
|
const request = {
|
|
11
15
|
headers: {
|
|
@@ -17,7 +21,7 @@ describe('authentication helper', () => {
|
|
|
17
21
|
};
|
|
18
22
|
expect(tryGetAuthorizationTokenStringFromRequest(request)).toBe('my-token');
|
|
19
23
|
});
|
|
20
|
-
test('tryGetAuthorizationTokenStringFromRequest should
|
|
24
|
+
test('tryGetAuthorizationTokenStringFromRequest should throw if token without scheme from header', () => {
|
|
21
25
|
const request = {
|
|
22
26
|
headers: {
|
|
23
27
|
tryGet: (name) => name == 'Authorization' ? 'my-token' : undefined,
|
|
@@ -26,7 +30,7 @@ describe('authentication helper', () => {
|
|
|
26
30
|
tryGet: () => undefined,
|
|
27
31
|
},
|
|
28
32
|
};
|
|
29
|
-
expect(tryGetAuthorizationTokenStringFromRequest(request)).
|
|
33
|
+
expect(() => tryGetAuthorizationTokenStringFromRequest(request)).toThrow(BadRequestError);
|
|
30
34
|
});
|
|
31
35
|
test('tryGetAuthorizationTokenStringFromRequest should extract bearer token from cookie', () => {
|
|
32
36
|
const request = {
|
|
@@ -34,7 +38,7 @@ describe('authentication helper', () => {
|
|
|
34
38
|
tryGet: () => undefined,
|
|
35
39
|
},
|
|
36
40
|
cookies: {
|
|
37
|
-
tryGet: (name) => name == '
|
|
41
|
+
tryGet: (name) => name == 'token' ? 'my-token' : undefined,
|
|
38
42
|
},
|
|
39
43
|
};
|
|
40
44
|
expect(tryGetAuthorizationTokenStringFromRequest(request)).toBe('my-token');
|
|
@@ -63,35 +67,35 @@ describe('authentication helper', () => {
|
|
|
63
67
|
});
|
|
64
68
|
test('getTokenFromString should validate token', async () => {
|
|
65
69
|
const payload = { exp: currentTimestampSeconds() + 60, tenant: 't', subject: 's', jti: 'j' };
|
|
66
|
-
const token = await createJwtTokenString({ header: { v: 1, alg: '
|
|
67
|
-
const validated = await getTokenFromString(token, 1,
|
|
70
|
+
const token = await createJwtTokenString({ header: { v: 1, alg: 'KMAC256', typ: 'JWT' }, payload }, signingKey);
|
|
71
|
+
const validated = await getTokenFromString(token, 1, signingKey);
|
|
68
72
|
expect(validated.payload).toEqual(payload);
|
|
69
73
|
});
|
|
70
74
|
test('getTokenFromString should throw on version mismatch', async () => {
|
|
71
75
|
const payload = { exp: currentTimestampSeconds() + 60, tenant: 't', subject: 's', jti: 'j' };
|
|
72
|
-
const token = await createJwtTokenString({ header: { v: 2, alg: '
|
|
73
|
-
await expect(getTokenFromString(token, 1,
|
|
76
|
+
const token = await createJwtTokenString({ header: { v: 2, alg: 'KMAC256', typ: 'JWT' }, payload }, signingKey);
|
|
77
|
+
await expect(getTokenFromString(token, 1, signingKey)).rejects.toThrow(InvalidTokenError);
|
|
74
78
|
});
|
|
75
79
|
test('getTokenFromString should throw on expired token', async () => {
|
|
76
80
|
const payload = { exp: currentTimestampSeconds() - 60, tenant: 't', subject: 's', jti: 'j' };
|
|
77
|
-
const token = await createJwtTokenString({ header: { v: 1, alg: '
|
|
78
|
-
await expect(getTokenFromString(token, 1,
|
|
81
|
+
const token = await createJwtTokenString({ header: { v: 1, alg: 'KMAC256', typ: 'JWT' }, payload }, signingKey);
|
|
82
|
+
await expect(getTokenFromString(token, 1, signingKey)).rejects.toThrow('Token expired');
|
|
79
83
|
});
|
|
80
84
|
test('getRefreshTokenFromString should validate refresh token', async () => {
|
|
81
85
|
const payload = { exp: currentTimestampSeconds() + 60, tenant: 't', subject: 's', session: 'sess', secret: 'sec' };
|
|
82
|
-
const token = await createJwtTokenString({ header: { alg: '
|
|
83
|
-
const validated = await getRefreshTokenFromString(token,
|
|
86
|
+
const token = await createJwtTokenString({ header: { alg: 'KMAC256', typ: 'JWT' }, payload }, signingKey);
|
|
87
|
+
const validated = await getRefreshTokenFromString(token, signingKey);
|
|
84
88
|
expect(validated.payload).toEqual(payload);
|
|
85
89
|
});
|
|
86
|
-
test('
|
|
90
|
+
test('getPasswordResetTokenFromString should validate password reset token', async () => {
|
|
87
91
|
const payload = { iat: currentTimestampSeconds(), exp: currentTimestampSeconds() + 60, tenant: 't', subject: 's' };
|
|
88
|
-
const token = await createJwtTokenString({ header: { alg: '
|
|
89
|
-
const validated = await
|
|
92
|
+
const token = await createJwtTokenString({ header: { alg: 'KMAC256', typ: 'JWT' }, payload }, signingKey);
|
|
93
|
+
const validated = await getPasswordResetTokenFromString(token, signingKey);
|
|
90
94
|
expect(validated.payload).toEqual(payload);
|
|
91
95
|
});
|
|
92
96
|
test('getTokenFromRequest should extract and validate token', async () => {
|
|
93
97
|
const payload = { exp: currentTimestampSeconds() + 60, tenant: 't', subject: 's', jti: 'j' };
|
|
94
|
-
const token = await createJwtTokenString({ header: { v: 1, alg: '
|
|
98
|
+
const token = await createJwtTokenString({ header: { v: 1, alg: 'KMAC256', typ: 'JWT' }, payload }, signingKey);
|
|
95
99
|
const request = {
|
|
96
100
|
headers: {
|
|
97
101
|
tryGet: (name) => name == 'Authorization' ? `Bearer ${token}` : undefined,
|
|
@@ -100,7 +104,7 @@ describe('authentication helper', () => {
|
|
|
100
104
|
tryGet: () => undefined,
|
|
101
105
|
},
|
|
102
106
|
};
|
|
103
|
-
const validated = await getTokenFromRequest(request, 1,
|
|
107
|
+
const validated = await getTokenFromRequest(request, 1, signingKey);
|
|
104
108
|
expect(validated.payload).toEqual(payload);
|
|
105
109
|
});
|
|
106
110
|
test('tryGetTokenFromRequest should return undefined if no token in request', async () => {
|
|
@@ -112,7 +116,7 @@ describe('authentication helper', () => {
|
|
|
112
116
|
tryGet: () => undefined,
|
|
113
117
|
},
|
|
114
118
|
};
|
|
115
|
-
const validated = await tryGetTokenFromRequest(request, 1,
|
|
119
|
+
const validated = await tryGetTokenFromRequest(request, 1, signingKey);
|
|
116
120
|
expect(validated).toBeUndefined();
|
|
117
121
|
});
|
|
118
122
|
});
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { PasswordRequirementsError } from '../errors/password-requirements.error.js';
|
|
3
|
+
describe('PasswordRequirementsError', () => {
|
|
4
|
+
it('should create an error with the given message', () => {
|
|
5
|
+
const message = 'Password is too weak.';
|
|
6
|
+
const error = new PasswordRequirementsError(message);
|
|
7
|
+
expect(error.message).toBe(message);
|
|
8
|
+
expect(error.name).toBe('PasswordRequirementsError');
|
|
9
|
+
});
|
|
10
|
+
it('should have the correct name', () => {
|
|
11
|
+
const error = new PasswordRequirementsError('any message');
|
|
12
|
+
expect(error.name).toBe('PasswordRequirementsError');
|
|
13
|
+
});
|
|
14
|
+
});
|