@o3co/create-auth-provider 0.17.0-rc.1 → 0.17.0-rc.2
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/package.json +1 -1
- package/templates/standalone/.env.example +0 -3
- package/templates/standalone/README.ja.md +42 -18
- package/templates/standalone/README.md +120 -44
- package/templates/standalone/config/application.conf +40 -36
- package/templates/standalone/config/reference.conf +21 -2
- package/templates/standalone/docker-compose.production.yml +5 -2
- package/templates/standalone/package.json +1 -1
- package/templates/standalone/src/__tests__/adapters.test.mts +80 -19
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +112 -98
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +27 -6
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +86 -51
- package/templates/standalone/src/__tests__/audit-sink.test.mts +10 -9
- package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
- package/templates/standalone/src/__tests__/core-federations.test.mts +48 -25
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +349 -54
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +15 -21
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +9 -15
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +10 -4
- package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +60 -67
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
- package/templates/standalone/src/__tests__/library-references.fixture.mts +51 -46
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -2
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +70 -25
- package/templates/standalone/src/__tests__/own-modules.test.mts +40 -11
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
- package/templates/standalone/src/__tests__/replica-safety.test.mts +127 -19
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +15 -20
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +3 -7
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
- package/templates/standalone/src/__tests__/session-requirements.test.mts +114 -31
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
- package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
- package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +50 -34
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +242 -76
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +23 -31
- package/templates/standalone/src/adapters.mts +31 -18
- package/templates/standalone/src/app.mts +6 -5
- package/templates/standalone/src/bootRefusal.mts +117 -0
- package/templates/standalone/src/buildModules.mts +46 -96
- package/templates/standalone/src/configPath.mts +180 -153
- package/templates/standalone/src/mfaSwitch.mts +43 -20
- package/templates/standalone/src/modules.mts +25 -213
- package/templates/standalone/src/rootRenames.mts +35 -18
- package/templates/standalone/src/sections.mts +1 -0
- package/templates/standalone/tests/index.test.js +4 -3
- package/templates/versions.json +18 -18
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -134
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -202
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -144
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Copyright 2026 1o1 Co. Ltd.
|
|
3
|
+
*
|
|
4
|
+
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
5
|
+
* you may not use this file except in compliance with the License.
|
|
6
|
+
* You may obtain a copy of the License at
|
|
7
|
+
*
|
|
8
|
+
* http://www.apache.org/licenses/LICENSE-2.0
|
|
9
|
+
*
|
|
10
|
+
* Unless required by applicable law or agreed to in writing, software
|
|
11
|
+
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
12
|
+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
13
|
+
* See the License for the specific language governing permissions and
|
|
14
|
+
* limitations under the License.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The MFA routes' budget the template ships: every `/session/mfa` POST is
|
|
19
|
+
* limited under the prefix `mfa` by the wired limiter alone, and the
|
|
20
|
+
* template's own `reference.conf` gives both limiters `limits.mfa`, 60
|
|
21
|
+
* requests per 300 s. Followed through both configuration phases and
|
|
22
|
+
* `createApp` to the limiter each module builds, in every environment the
|
|
23
|
+
* template ships a file for.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { fileURLToPath } from "node:url";
|
|
27
|
+
import {
|
|
28
|
+
createApp,
|
|
29
|
+
defineModule,
|
|
30
|
+
memoryRateLimiterModule,
|
|
31
|
+
type RateLimiter,
|
|
32
|
+
} from "@o3co/auth-provider-core";
|
|
33
|
+
import { redisRateLimiterModule } from "@o3co/auth-provider-redis";
|
|
34
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
35
|
+
import { readOwnLayers, readSwitches, resolveConfigPaths, resolveForBoot } from "../configPath.mjs";
|
|
36
|
+
import { loggingModule } from "../modules.mjs";
|
|
37
|
+
|
|
38
|
+
const configDir = fileURLToPath(new URL("../../config", import.meta.url));
|
|
39
|
+
|
|
40
|
+
/** The secrets `application.conf` substitutes: test-only values past the entropy floor. */
|
|
41
|
+
const SECRETS = {
|
|
42
|
+
KEY_STORE_LOCAL_SECRET: "test-secret-mfa-routes-budget.at-least-32-bytes.ok",
|
|
43
|
+
OAUTH_JWT_ISSUER: "https://auth.test",
|
|
44
|
+
SESSION_STORE_SECRET: "test-session-secret-mfa-routes-budget.at-least-32-bytes.ok",
|
|
45
|
+
MFA_MODE: "off",
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** The windows, in seconds, the Redis limiter asked its client to count over. */
|
|
49
|
+
const windows: number[] = [];
|
|
50
|
+
|
|
51
|
+
/** The limiter a consumer of the slot is handed, as the MFA routes are. */
|
|
52
|
+
let consumed: RateLimiter | undefined;
|
|
53
|
+
const consumerModule = defineModule({
|
|
54
|
+
name: "test:rate-limiter-consumer",
|
|
55
|
+
requires: ["rateLimiter"] as const,
|
|
56
|
+
contributes: {
|
|
57
|
+
grantMiddleware: [
|
|
58
|
+
({ rateLimiter }) => {
|
|
59
|
+
consumed = rateLimiter;
|
|
60
|
+
return null;
|
|
61
|
+
},
|
|
62
|
+
],
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
const handles: { dispose(): Promise<void> }[] = [];
|
|
67
|
+
afterEach(async () => {
|
|
68
|
+
await Promise.all(handles.splice(0).map((handle) => handle.dispose()));
|
|
69
|
+
consumed = undefined;
|
|
70
|
+
windows.splice(0);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
/** The shipped configuration for `environment`, booted with the limiter `adapter` selects. */
|
|
74
|
+
async function shippedLimiter(
|
|
75
|
+
environment: string,
|
|
76
|
+
adapter: "memory" | "redis",
|
|
77
|
+
): Promise<RateLimiter> {
|
|
78
|
+
const env = { ...SECRETS, ADAPTERS_RATE_LIMITER: adapter };
|
|
79
|
+
const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, environment);
|
|
80
|
+
const own = readOwnLayers([envConfPath, applicationConfPath], { env });
|
|
81
|
+
const switches = readSwitches(own);
|
|
82
|
+
expect(switches.adapters.rateLimiter).toBe(adapter);
|
|
83
|
+
const limiterModule = adapter === "redis" ? redisRateLimiterModule : memoryRateLimiterModule;
|
|
84
|
+
const modules = [limiterModule, consumerModule];
|
|
85
|
+
const handle = await createApp({
|
|
86
|
+
modules,
|
|
87
|
+
bootstrapComponents: {
|
|
88
|
+
config: resolveForBoot(own, [...modules, loggingModule], switches),
|
|
89
|
+
pathResolver: (s: string) => s,
|
|
90
|
+
// Counts one request per key, the way an untouched Redis counter would.
|
|
91
|
+
rateLimiterClient: {
|
|
92
|
+
incrementWithTtl: async (_key: string, windowSeconds: number) => {
|
|
93
|
+
windows.push(windowSeconds);
|
|
94
|
+
return 1;
|
|
95
|
+
},
|
|
96
|
+
} as never,
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
handles.push(handle);
|
|
100
|
+
if (consumed === undefined) throw new Error("the boot handed no consumer the limiter");
|
|
101
|
+
return consumed;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
describe("the MFA routes' budget the template ships", () => {
|
|
105
|
+
it.each([
|
|
106
|
+
["development", "memory"],
|
|
107
|
+
["development", "redis"],
|
|
108
|
+
["production", "memory"],
|
|
109
|
+
["production", "redis"],
|
|
110
|
+
] as const)("is 60 per 300 s under %s on the %s limiter", async (environment, adapter) => {
|
|
111
|
+
const limiter = await shippedLimiter(environment, adapter);
|
|
112
|
+
const before = Date.now();
|
|
113
|
+
const decision = await limiter.check("mfa:ip:203.0.113.7", { ip: "203.0.113.7" });
|
|
114
|
+
expect(decision.allowed).toBe(true);
|
|
115
|
+
expect(decision.limit).toBe(60);
|
|
116
|
+
const windowSeconds =
|
|
117
|
+
adapter === "redis"
|
|
118
|
+
? windows[0]
|
|
119
|
+
: Math.round(((decision.resetAt?.getTime() ?? 0) - before) / 1000);
|
|
120
|
+
expect(windowSeconds).toBe(300);
|
|
121
|
+
});
|
|
122
|
+
});
|
|
@@ -46,7 +46,13 @@ import { loginCompletionModule } from "@o3co/auth-provider-session";
|
|
|
46
46
|
import request from "supertest";
|
|
47
47
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
48
48
|
import { buildModules } from "#/buildModules.mjs";
|
|
49
|
-
import {
|
|
49
|
+
import {
|
|
50
|
+
configDefaultsFor,
|
|
51
|
+
readOwnLayers,
|
|
52
|
+
readSwitches,
|
|
53
|
+
resolveConfigPaths,
|
|
54
|
+
resolveForBoot,
|
|
55
|
+
} from "#/configPath.mjs";
|
|
50
56
|
import {
|
|
51
57
|
ALICE,
|
|
52
58
|
authorize,
|
|
@@ -143,13 +149,20 @@ describe("the switch is the template's own key, mfaMode, bound to MFA_MODE", ()
|
|
|
143
149
|
}
|
|
144
150
|
});
|
|
145
151
|
|
|
146
|
-
it("refuses an MFA_MODE that is none of the three before boot, a
|
|
152
|
+
it("refuses an MFA_MODE that is none of the three before boot, a config-validation-failed BootError naming mfaMode and MFA_MODE that quotes nothing of it", async () => {
|
|
147
153
|
const err = await refusal(boot({ env: on("required", { MFA_MODE: "sentinel-mode" }) }));
|
|
148
|
-
expect(err).toBeInstanceOf(
|
|
149
|
-
expect(err).
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
154
|
+
expect(err).toBeInstanceOf(BootError);
|
|
155
|
+
expect(err).toMatchObject({
|
|
156
|
+
reason: "config-validation-failed",
|
|
157
|
+
details: {
|
|
158
|
+
issues: [{ path: ["mfaMode"] }],
|
|
159
|
+
modules: [{ module: "mfaMode", schemaPath: "mfaMode" }],
|
|
160
|
+
},
|
|
161
|
+
});
|
|
162
|
+
expect((err as BootError).message).toContain("mfaMode");
|
|
163
|
+
expect((err as BootError).message).toContain("MFA_MODE");
|
|
164
|
+
expect((err as BootError).message).not.toContain("sentinel-mode");
|
|
165
|
+
expect(JSON.stringify((err as BootError).details)).not.toContain("sentinel-mode");
|
|
153
166
|
});
|
|
154
167
|
|
|
155
168
|
it.each([
|
|
@@ -165,12 +178,16 @@ describe("the switch is the template's own key, mfaMode, bound to MFA_MODE", ()
|
|
|
165
178
|
operatorHocon: `mfaMode = "${written}"\n`,
|
|
166
179
|
}),
|
|
167
180
|
);
|
|
168
|
-
expect(err).toBeInstanceOf(
|
|
169
|
-
expect(err).
|
|
170
|
-
|
|
181
|
+
expect(err).toBeInstanceOf(BootError);
|
|
182
|
+
expect(err).toMatchObject({
|
|
183
|
+
reason: "config-validation-failed",
|
|
184
|
+
details: { issues: [{ code: "custom", path: ["mfaMode"] }] },
|
|
185
|
+
});
|
|
186
|
+
const message = (err as BootError).message;
|
|
171
187
|
expect(message).toContain("mfaMode");
|
|
172
188
|
expect(message).toContain("MFA_MODE");
|
|
173
189
|
expect(message).not.toMatch(/"(?:off|optional|required)"/);
|
|
190
|
+
expect(JSON.stringify((err as BootError).details)).not.toMatch(/(?:off|optional|required)"/);
|
|
174
191
|
},
|
|
175
192
|
);
|
|
176
193
|
|
|
@@ -332,8 +349,18 @@ describe("the MFA stores in memory: development and test alone", () => {
|
|
|
332
349
|
|
|
333
350
|
it("refuses both, before boot, under the name production: the shipped selection, naming each setting and its variable", async () => {
|
|
334
351
|
const err = await refusal(compose({ env: on("required"), environment: "production" }));
|
|
335
|
-
expect(err).toBeInstanceOf(
|
|
336
|
-
|
|
352
|
+
expect(err).toBeInstanceOf(BootError);
|
|
353
|
+
expect(err).toMatchObject({
|
|
354
|
+
reason: "config-validation-failed",
|
|
355
|
+
details: {
|
|
356
|
+
issues: [
|
|
357
|
+
{ code: "custom", path: ["adapters", "mfaFactorStore"] },
|
|
358
|
+
{ code: "custom", path: ["adapters", "mfaTransactionStore"] },
|
|
359
|
+
],
|
|
360
|
+
modules: [{ module: "adapters", schemaPath: "adapters" }],
|
|
361
|
+
},
|
|
362
|
+
});
|
|
363
|
+
const message = (err as BootError).message;
|
|
337
364
|
for (const named of [
|
|
338
365
|
"adapters.mfaFactorStore",
|
|
339
366
|
"ADAPTERS_MFA_FACTOR_STORE",
|
|
@@ -351,9 +378,12 @@ describe("the MFA stores in memory: development and test alone", () => {
|
|
|
351
378
|
environment: "production",
|
|
352
379
|
}),
|
|
353
380
|
);
|
|
354
|
-
expect(err).toBeInstanceOf(
|
|
355
|
-
expect((err as
|
|
356
|
-
|
|
381
|
+
expect(err).toBeInstanceOf(BootError);
|
|
382
|
+
expect((err as BootError).details).toMatchObject({
|
|
383
|
+
issues: [{ path: ["adapters", "mfaTransactionStore"] }],
|
|
384
|
+
});
|
|
385
|
+
expect((err as BootError).message).toContain("ADAPTERS_MFA_TRANSACTION_STORE");
|
|
386
|
+
expect((err as BootError).message).not.toContain("ADAPTERS_MFA_FACTOR_STORE");
|
|
357
387
|
});
|
|
358
388
|
|
|
359
389
|
it.each(["CONFIG_ENV", "NODE_ENV"])(
|
|
@@ -361,14 +391,15 @@ describe("the MFA stores in memory: development and test alone", () => {
|
|
|
361
391
|
async (variable) => {
|
|
362
392
|
vi.stubEnv(variable, "production");
|
|
363
393
|
const err = await refusal(boot({ env: on("required") }));
|
|
364
|
-
expect(err).toBeInstanceOf(
|
|
365
|
-
expect((err as
|
|
394
|
+
expect(err).toBeInstanceOf(BootError);
|
|
395
|
+
expect((err as BootError).message).toContain("ADAPTERS_MFA_FACTOR_STORE");
|
|
366
396
|
},
|
|
367
397
|
);
|
|
368
398
|
|
|
369
399
|
it("refuses them under a name that is neither development nor test", async () => {
|
|
370
400
|
const err = await refusal(compose({ env: on("required"), environment: "staging-eu" }));
|
|
371
|
-
expect(err).toBeInstanceOf(
|
|
401
|
+
expect(err).toBeInstanceOf(BootError);
|
|
402
|
+
expect((err as BootError).reason).toBe("config-validation-failed");
|
|
372
403
|
});
|
|
373
404
|
|
|
374
405
|
it.each(["development", "test"])("lets them in under %s", async (environment) => {
|
|
@@ -533,8 +564,15 @@ describe("mfa.storeTimeoutMs", () => {
|
|
|
533
564
|
const err = await refusal(
|
|
534
565
|
boot({ env: on("optional", { ...IN_THE_STORE, REPOSITORIES_USER_HTTP_TIMEOUT: "8000" }) }),
|
|
535
566
|
);
|
|
536
|
-
expect(err).toBeInstanceOf(
|
|
537
|
-
|
|
567
|
+
expect(err).toBeInstanceOf(BootError);
|
|
568
|
+
expect(err).toMatchObject({
|
|
569
|
+
reason: "config-validation-failed",
|
|
570
|
+
details: {
|
|
571
|
+
issues: [{ code: "custom", path: ["mfa", "storeTimeoutMs"] }],
|
|
572
|
+
modules: [{ module: "mfa", schemaPath: "mfa" }],
|
|
573
|
+
},
|
|
574
|
+
});
|
|
575
|
+
const message = (err as BootError).message;
|
|
538
576
|
for (const named of [
|
|
539
577
|
"mfa.storeTimeoutMs",
|
|
540
578
|
"MFA_STORE_TIMEOUT_MS",
|
|
@@ -619,7 +657,6 @@ async function bootShipped(
|
|
|
619
657
|
const modules = adjustModules(
|
|
620
658
|
buildModules(switches, {
|
|
621
659
|
environment,
|
|
622
|
-
logger,
|
|
623
660
|
refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule],
|
|
624
661
|
}),
|
|
625
662
|
);
|
|
@@ -627,6 +664,7 @@ async function bootShipped(
|
|
|
627
664
|
modules,
|
|
628
665
|
bootstrapComponents: {
|
|
629
666
|
config: resolveForBoot(own, modules, switches),
|
|
667
|
+
configDefaults: configDefaultsFor(modules),
|
|
630
668
|
pathResolver: (s: string) => s,
|
|
631
669
|
logger,
|
|
632
670
|
},
|
|
@@ -713,8 +751,8 @@ describe("the shipped configuration with nothing set about MFA", () => {
|
|
|
713
751
|
await bootShipped(unset(SHIPPED_ENV), "production").catch((caught: unknown) => {
|
|
714
752
|
err = caught;
|
|
715
753
|
});
|
|
716
|
-
expect(err).toBeInstanceOf(
|
|
717
|
-
const message = (err as
|
|
754
|
+
expect(err).toBeInstanceOf(BootError);
|
|
755
|
+
const message = (err as BootError).message;
|
|
718
756
|
for (const named of [
|
|
719
757
|
"ADAPTERS_MFA_FACTOR_STORE",
|
|
720
758
|
"ADAPTERS_MFA_TRANSACTION_STORE",
|
|
@@ -750,8 +788,15 @@ describe("under CONFIG_ENV=development", () => {
|
|
|
750
788
|
err = caught;
|
|
751
789
|
},
|
|
752
790
|
);
|
|
753
|
-
expect(err).toBeInstanceOf(
|
|
754
|
-
|
|
791
|
+
expect(err).toBeInstanceOf(BootError);
|
|
792
|
+
expect(err).toMatchObject({
|
|
793
|
+
reason: "config-validation-failed",
|
|
794
|
+
details: { issues: [{ code: "custom", path: ["mfa", "encryptionKeys"] }] },
|
|
795
|
+
});
|
|
796
|
+
const details = JSON.stringify((err as BootError).details);
|
|
797
|
+
expect(details).not.toContain(MFA_KEY);
|
|
798
|
+
expect(details).not.toContain(MFA_DEVELOPMENT_SAMPLE_KEY);
|
|
799
|
+
const message = (err as BootError).message;
|
|
755
800
|
expect(message).toContain("MFA_ENCRYPTION_KEY");
|
|
756
801
|
expect(message).toContain("mfa.encryptionKeys");
|
|
757
802
|
expect(message).not.toContain(MFA_KEY);
|
|
@@ -61,7 +61,6 @@ import {
|
|
|
61
61
|
readSwitches,
|
|
62
62
|
resolveConfigPaths,
|
|
63
63
|
resolveForBoot,
|
|
64
|
-
SWITCHES,
|
|
65
64
|
} from "../configPath.mjs";
|
|
66
65
|
import { createAppLogger } from "../logger.mjs";
|
|
67
66
|
import { MFA_SWITCH } from "../mfaSwitch.mjs";
|
|
@@ -215,19 +214,26 @@ const REFERENCED_MODULES = [
|
|
|
215
214
|
auditSinkModuleFor("logger"),
|
|
216
215
|
];
|
|
217
216
|
|
|
217
|
+
/** The rate limiters' sections, in which the template's reference gives the MFA routes' budget. */
|
|
218
|
+
const LIMITER_SECTIONS = ["core-rate-limiter-memory", "redis-rate-limiter"] as const;
|
|
219
|
+
|
|
218
220
|
describe("the template's config/reference.conf", () => {
|
|
219
|
-
it("holds only its own modules' sections
|
|
221
|
+
it("holds only its own modules' sections, the composition root's adapters and mfaMode, and the limiters' MFA budget, each module's parsing its part without losing a path", () => {
|
|
220
222
|
expect(
|
|
221
223
|
packageReferenceProblems({
|
|
222
224
|
reference: TEMPLATE_REFERENCE,
|
|
223
225
|
modules: REFERENCED_MODULES,
|
|
224
226
|
// `adapters` and `mfaMode` are the composition root's own,
|
|
225
227
|
// which phase one reads with its own schema
|
|
226
|
-
// (`adapters.test.mts`, `mfa-switch.test.mts`)
|
|
228
|
+
// (`adapters.test.mts`, `mfa-switch.test.mts`); the limiters'
|
|
229
|
+
// sections hold the MFA budget alone (below), each parsed by its
|
|
230
|
+
// own module where it is loaded (`mfa-routes-budget.test.mts`).
|
|
227
231
|
read: (path, env) => {
|
|
228
232
|
const {
|
|
229
233
|
[ADAPTERS_SECTION]: _adapters,
|
|
230
234
|
[MFA_SWITCH]: _mfaMode,
|
|
235
|
+
[LIMITER_SECTIONS[0]]: _memoryLimiter,
|
|
236
|
+
[LIMITER_SECTIONS[1]]: _redisLimiter,
|
|
231
237
|
...tree
|
|
232
238
|
} = parseFile(path, {
|
|
233
239
|
env: { ...env },
|
|
@@ -238,6 +244,14 @@ describe("the template's config/reference.conf", () => {
|
|
|
238
244
|
).toEqual([]);
|
|
239
245
|
});
|
|
240
246
|
|
|
247
|
+
it.each(LIMITER_SECTIONS)("gives %s the MFA routes' budget, and nothing else", (section) => {
|
|
248
|
+
const tree = parseFile(fileURLToPath(TEMPLATE_REFERENCE), { env: {} }).toObject() as Record<
|
|
249
|
+
string,
|
|
250
|
+
unknown
|
|
251
|
+
>;
|
|
252
|
+
expect(tree[section]).toEqual({ limits: { mfa: { limit: 60, windowSeconds: 300 } } });
|
|
253
|
+
});
|
|
254
|
+
|
|
241
255
|
it.each(REFERENCED_MODULES)("is among the references $name alone brings", (module) => {
|
|
242
256
|
expect(moduleReferences([module]).map((url) => url.href)).toContain(TEMPLATE_REFERENCE.href);
|
|
243
257
|
});
|
|
@@ -246,7 +260,7 @@ describe("the template's config/reference.conf", () => {
|
|
|
246
260
|
describe("logging", () => {
|
|
247
261
|
it("owns logging, and requires nothing", () => {
|
|
248
262
|
expect(loggingModule.name).toBe("logging");
|
|
249
|
-
expect(loggingModule.section
|
|
263
|
+
expect(loggingModule.section).not.toHaveProperty("at");
|
|
250
264
|
expect(loggingModule.section?.reference?.href).toBe(TEMPLATE_REFERENCE.href);
|
|
251
265
|
expect(loggingModule.requires ?? []).toEqual([]);
|
|
252
266
|
expect(loggingModule.optional ?? []).toEqual([]);
|
|
@@ -265,8 +279,9 @@ describe("logging", () => {
|
|
|
265
279
|
});
|
|
266
280
|
});
|
|
267
281
|
|
|
268
|
-
it("is not among
|
|
269
|
-
|
|
282
|
+
it("is not among what phase one reads", () => {
|
|
283
|
+
const own = readOwnLayers(ownFiles(), { env: BASE_ENV });
|
|
284
|
+
expect(Object.keys(readSwitches(own))).not.toContain("logging");
|
|
270
285
|
});
|
|
271
286
|
|
|
272
287
|
it.each([
|
|
@@ -284,6 +299,20 @@ describe("logging", () => {
|
|
|
284
299
|
it("refuses a level it does not know before boot, naming logging.level", () => {
|
|
285
300
|
const own = readOwnLayers(ownFiles(), { env: { ...BASE_ENV, LOGGING_LEVEL: "verbose" } });
|
|
286
301
|
expect(() => readLogging(own)).toThrow(/logging\.level/);
|
|
302
|
+
expect(() => readLogging(own)).toThrow(BootError);
|
|
303
|
+
let err: unknown;
|
|
304
|
+
try {
|
|
305
|
+
readLogging(own);
|
|
306
|
+
} catch (caught) {
|
|
307
|
+
err = caught;
|
|
308
|
+
}
|
|
309
|
+
expect(err).toMatchObject({
|
|
310
|
+
reason: "config-validation-failed",
|
|
311
|
+
details: {
|
|
312
|
+
issues: [{ path: ["logging", "level"] }],
|
|
313
|
+
modules: [{ module: "logging", schemaPath: "logging" }],
|
|
314
|
+
},
|
|
315
|
+
});
|
|
287
316
|
});
|
|
288
317
|
|
|
289
318
|
it("has boot refuse LOG_LEVEL set alone, naming LOGGING_LEVEL and logging.level", async () => {
|
|
@@ -338,7 +367,7 @@ const preflight = (app: express.Express, origin: string) =>
|
|
|
338
367
|
describe("http", () => {
|
|
339
368
|
it("owns http, the CORS list included, and requires nothing", () => {
|
|
340
369
|
expect(httpModule.name).toBe("http");
|
|
341
|
-
expect(httpModule.section
|
|
370
|
+
expect(httpModule.section).not.toHaveProperty("at");
|
|
342
371
|
expect(httpModule.section?.reference?.href).toBe(TEMPLATE_REFERENCE.href);
|
|
343
372
|
expect(httpModule.requires ?? []).toEqual([]);
|
|
344
373
|
expect(httpModule.optional ?? []).toEqual([]);
|
|
@@ -753,7 +782,7 @@ describe("an environment variable takes effect though application.conf does not
|
|
|
753
782
|
describe("key-store", () => {
|
|
754
783
|
it("owns key-store, and reads it as its section rather than the configuration", () => {
|
|
755
784
|
expect(keyStoreModule.name).toBe("key-store");
|
|
756
|
-
expect(keyStoreModule.section
|
|
785
|
+
expect(keyStoreModule.section).not.toHaveProperty("at");
|
|
757
786
|
expect(keyStoreModule.section?.reference?.href).toBe(TEMPLATE_REFERENCE.href);
|
|
758
787
|
expect(keyStoreModule.requires ?? []).not.toContain("config");
|
|
759
788
|
expect(keyStoreModule.optional ?? []).not.toContain("config");
|
|
@@ -970,7 +999,7 @@ async function listeningRedis(): Promise<{
|
|
|
970
999
|
describe("redis-clients", () => {
|
|
971
1000
|
it("owns redis-clients, and reads it as its section rather than the configuration", () => {
|
|
972
1001
|
expect(standaloneRedisClientsModule.name).toBe("redis-clients");
|
|
973
|
-
expect(standaloneRedisClientsModule.section
|
|
1002
|
+
expect(standaloneRedisClientsModule.section).not.toHaveProperty("at");
|
|
974
1003
|
expect(standaloneRedisClientsModule.section?.reference?.href).toBe(TEMPLATE_REFERENCE.href);
|
|
975
1004
|
expect(standaloneRedisClientsModule.requires ?? []).not.toContain("config");
|
|
976
1005
|
expect(standaloneRedisClientsModule.optional ?? []).not.toContain("config");
|
|
@@ -1141,7 +1170,7 @@ describe("repositories", () => {
|
|
|
1141
1170
|
const own = readOwnLayers(ownFiles(), { env: BASE_ENV });
|
|
1142
1171
|
const modules = buildModules(readSwitches(own), { environment: "development" });
|
|
1143
1172
|
const repositories = named(modules, "repositories");
|
|
1144
|
-
expect(repositories.section
|
|
1173
|
+
expect(repositories.section).not.toHaveProperty("at");
|
|
1145
1174
|
expect(repositories.section?.reference?.href).toBe(TEMPLATE_REFERENCE.href);
|
|
1146
1175
|
expect(repositories.requires ?? []).not.toContain("config");
|
|
1147
1176
|
expect(repositories.optional ?? []).not.toContain("config");
|
|
@@ -1434,7 +1463,7 @@ describe("audit-sink", () => {
|
|
|
1434
1463
|
buildModules(readSwitches(own), { environment: "development" }),
|
|
1435
1464
|
"audit-sink",
|
|
1436
1465
|
);
|
|
1437
|
-
expect(sink.section
|
|
1466
|
+
expect(sink.section).not.toHaveProperty("at");
|
|
1438
1467
|
expect(sink.requires ?? []).not.toContain("config");
|
|
1439
1468
|
});
|
|
1440
1469
|
|
|
@@ -149,15 +149,15 @@ describe("a logout's post_logout_redirect_uri reaches the upstream only once it
|
|
|
149
149
|
|
|
150
150
|
describe("POST /oauth/federation/google/logout", () => {
|
|
151
151
|
it("does not send the browser to an unregistered post_logout_redirect_uri", async () => {
|
|
152
|
-
const { app, sid, accessToken,
|
|
152
|
+
const { app, sid, accessToken, federationTokenStore } = await signedInWithGoogle("shipped");
|
|
153
153
|
|
|
154
154
|
const res = await federationLogout(app, accessToken, UNREGISTERED);
|
|
155
155
|
|
|
156
156
|
expect(res.status).toBe(303);
|
|
157
157
|
expect(new URL(res.headers.location as string).origin).not.toBe("https://evil.example");
|
|
158
158
|
expect(res.headers.location).toBe(`${GOOGLE_LOGOUT}?state=s-1`);
|
|
159
|
-
// The federation was disconnected all the same.
|
|
160
|
-
expect(await
|
|
159
|
+
// The federation was disconnected all the same: its tokens are gone.
|
|
160
|
+
expect(await federationTokenStore.get(sid, "google")).toBeNull();
|
|
161
161
|
});
|
|
162
162
|
|
|
163
163
|
it("still returns the browser to one registered for the token's client", async () => {
|
|
@@ -156,15 +156,21 @@ describe("the shipped configuration reaches the Redis rate limiter", () => {
|
|
|
156
156
|
expect(booted.switches.adapters.rateLimiter).toBe("redis");
|
|
157
157
|
});
|
|
158
158
|
|
|
159
|
-
it("carries the operator's per-endpoint budgets through boot's parse", () => {
|
|
159
|
+
it("carries the operator's per-endpoint budgets through boot's parse, merged with the template's", () => {
|
|
160
160
|
expect(
|
|
161
161
|
(config as unknown as Record<string, { limits?: unknown }>)["redis-rate-limiter"]?.limits,
|
|
162
162
|
).toEqual({
|
|
163
163
|
token: { limit: 120, windowSeconds: 60 },
|
|
164
164
|
authorize: { limit: 30, windowSeconds: 60 },
|
|
165
|
+
mfa: { limit: 60, windowSeconds: 300 },
|
|
165
166
|
});
|
|
166
167
|
});
|
|
167
168
|
|
|
169
|
+
it("keeps the MFA routes' budget the template ships beside the operator's", async () => {
|
|
170
|
+
const decision = await limiter.check("mfa:ip:203.0.113.5", { ip: "203.0.113.5" });
|
|
171
|
+
expect(decision.limit).toBe(60);
|
|
172
|
+
});
|
|
173
|
+
|
|
168
174
|
it("applies the declared budget at /token", async () => {
|
|
169
175
|
const decision = await limiter.check("token:ip:203.0.113.5", { ip: "203.0.113.5" });
|
|
170
176
|
expect(decision.limit).toBe(120);
|
|
@@ -183,9 +189,7 @@ describe("the shipped configuration reaches the Redis rate limiter", () => {
|
|
|
183
189
|
expect(decision.limit).toBe(60);
|
|
184
190
|
});
|
|
185
191
|
|
|
186
|
-
it("applies no login budget
|
|
187
|
-
// This boot loads the limiter alone. With the session module, the
|
|
188
|
-
// limiter applies the budget it contributes from session.rateLimit.login.
|
|
192
|
+
it("applies no login budget: session.rateLimit.login is the session module's own attempt limit, which no limiter applies", async () => {
|
|
189
193
|
expect(config.session).toMatchObject({ rateLimit: { login: { limit: 20 } } });
|
|
190
194
|
const decision = await limiter.check("login:ip:203.0.113.5", { ip: "203.0.113.5" });
|
|
191
195
|
expect(decision.limit).toBe(60);
|