@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.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/templates/standalone/.env.example +0 -3
  3. package/templates/standalone/README.ja.md +42 -18
  4. package/templates/standalone/README.md +120 -44
  5. package/templates/standalone/config/application.conf +40 -36
  6. package/templates/standalone/config/reference.conf +21 -2
  7. package/templates/standalone/docker-compose.production.yml +5 -2
  8. package/templates/standalone/package.json +1 -1
  9. package/templates/standalone/src/__tests__/adapters.test.mts +80 -19
  10. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  11. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +112 -98
  12. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +27 -6
  13. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +86 -51
  14. package/templates/standalone/src/__tests__/audit-sink.test.mts +10 -9
  15. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  16. package/templates/standalone/src/__tests__/core-federations.test.mts +48 -25
  17. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
  18. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +349 -54
  19. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +15 -21
  20. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +9 -15
  21. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +10 -4
  22. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  23. package/templates/standalone/src/__tests__/google-session.fixture.mts +60 -67
  24. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  25. package/templates/standalone/src/__tests__/library-references.fixture.mts +51 -46
  26. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  27. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -2
  28. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  29. package/templates/standalone/src/__tests__/mfa-switch.test.mts +70 -25
  30. package/templates/standalone/src/__tests__/own-modules.test.mts +40 -11
  31. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
  32. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
  33. package/templates/standalone/src/__tests__/replica-safety.test.mts +127 -19
  34. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +15 -20
  35. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  36. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +3 -7
  37. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  38. package/templates/standalone/src/__tests__/session-requirements.test.mts +114 -31
  39. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  40. package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
  41. package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
  42. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +50 -34
  43. package/templates/standalone/src/__tests__/two-phase-config.test.mts +242 -76
  44. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +23 -31
  45. package/templates/standalone/src/adapters.mts +31 -18
  46. package/templates/standalone/src/app.mts +6 -5
  47. package/templates/standalone/src/bootRefusal.mts +117 -0
  48. package/templates/standalone/src/buildModules.mts +46 -96
  49. package/templates/standalone/src/configPath.mts +180 -153
  50. package/templates/standalone/src/mfaSwitch.mts +43 -20
  51. package/templates/standalone/src/modules.mts +25 -213
  52. package/templates/standalone/src/rootRenames.mts +35 -18
  53. package/templates/standalone/src/sections.mts +1 -0
  54. package/templates/standalone/tests/index.test.js +4 -3
  55. package/templates/versions.json +18 -18
  56. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -134
  57. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -202
  58. 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 { readOwnLayers, readSwitches, resolveConfigPaths, resolveForBoot } from "#/configPath.mjs";
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 RangeError naming mfaMode and MFA_MODE that quotes nothing of it", async () => {
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(RangeError);
149
- expect(err).not.toBeInstanceOf(BootError);
150
- expect((err as RangeError).message).toContain("mfaMode");
151
- expect((err as RangeError).message).toContain("MFA_MODE");
152
- expect((err as RangeError).message).not.toContain("sentinel-mode");
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(RangeError);
169
- expect(err).not.toBeInstanceOf(BootError);
170
- const message = (err as RangeError).message;
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(RangeError);
336
- const message = (err as RangeError).message;
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(RangeError);
355
- expect((err as RangeError).message).toContain("ADAPTERS_MFA_TRANSACTION_STORE");
356
- expect((err as RangeError).message).not.toContain("ADAPTERS_MFA_FACTOR_STORE");
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(RangeError);
365
- expect((err as RangeError).message).toContain("ADAPTERS_MFA_FACTOR_STORE");
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(RangeError);
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(RangeError);
537
- const message = (err as RangeError).message;
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(RangeError);
717
- const message = (err as RangeError).message;
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(RangeError);
754
- const message = (err as RangeError).message;
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 and the composition root's adapters and mfaMode, each module's parsing its part without losing a path", () => {
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?.at).toBeUndefined();
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 SWITCHES", () => {
269
- expect(SWITCHES).not.toContain("logging");
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?.at).toBeUndefined();
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?.at).toBeUndefined();
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?.at).toBeUndefined();
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?.at).toBeUndefined();
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?.at).toBeUndefined();
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, sessionFederationIndex } = await signedInWithGoogle("shipped");
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 sessionFederationIndex.listFederations(sid)).not.toContain("google");
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 without the session module: session.rateLimit.login is that module's to contribute", async () => {
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);