@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
@@ -20,12 +20,18 @@ import { join } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import {
22
22
  type AppConfig,
23
+ BootError,
23
24
  createApp,
25
+ defineModule,
24
26
  type Module,
25
27
  moduleReferences,
26
28
  resolveAccessTokenLifetime,
27
29
  } from "@o3co/auth-provider-core";
28
- import { oauthModule } from "@o3co/auth-provider-oauth";
30
+ import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
31
+ import { googleFederationTypeModule } from "@o3co/auth-provider-federation-google";
32
+ import { federationGrantsModule } from "@o3co/auth-provider-federation-grants";
33
+ import { oidcFederationTypeModule } from "@o3co/auth-provider-federation-oidc";
34
+ import { type OAuthSection, oauthEndpointsModule } from "@o3co/auth-provider-oauth";
29
35
  import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
30
36
  import { describe, expect, it } from "vitest";
31
37
  import { buildModules } from "../buildModules.mjs";
@@ -35,6 +41,7 @@ import {
35
41
  readSwitches,
36
42
  resolveConfigPaths,
37
43
  resolveForBoot,
44
+ resolveLayers,
38
45
  type Switches,
39
46
  } from "../configPath.mjs";
40
47
  import { httpModule, keyStoreModule, templateReference } from "../modules.mjs";
@@ -43,8 +50,8 @@ import { httpModule, keyStoreModule, templateReference } from "../modules.mjs";
43
50
  * Boots the shipped config with EVERY documented override supplied the way an
44
51
  * operator supplies one: as a string.
45
52
  *
46
- * HOCON substitutes `${?VAR}` as a string, always. `app.mts` reads its switches
47
- * with core's transitional reader and hands `createApp` what it resolved,
53
+ * HOCON substitutes `${?VAR}` as a string, always. `app.mts` reads its own
54
+ * switches with the template's schema and hands `createApp` what it resolved,
48
55
  * which boot parses once with plain Zod, so every schema leaf must read the
49
56
  * string itself. This suite reads through that path: `readSwitches` for what
50
57
  * is read before boot, `resolveForBoot` and `createApp` for the configuration
@@ -94,11 +101,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
94
101
  // --- oauth tokens / policy ----------------------------------------
95
102
  OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "900",
96
103
  OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN: "7200",
97
- // Deprecated alias of the default. Exported alongside the new variable on
98
- // purpose, with a different value: the new variable must win.
99
- OAUTH_ACCESS_TOKEN_EXPIRES_IN: "3600",
100
104
  OAUTH_REFRESH_TOKEN_EXPIRES_IN: "86400",
101
- OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY: "reject",
102
105
  OAUTH_OIDC_MODE: "dual",
103
106
  OAUTH_REVOCATION_ACCESS_TOKEN: "denylist",
104
107
  OAUTH_REVOCATION_SUBJECT: "unsupported",
@@ -131,6 +134,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
131
134
  OAUTH_SESSION_ENABLED: "false",
132
135
  OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED: "true",
133
136
  OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED: "true",
137
+ OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY: "reject",
134
138
  OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED: "true",
135
139
  OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED: "false",
136
140
 
@@ -156,6 +160,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
156
160
  // Which adapter fills each slot: the composition root's own section,
157
161
  // read before the modules are chosen.
158
162
  ADAPTERS_RATE_LIMITER: "redis",
163
+ ADAPTERS_ATTEMPT_COUNTER: "redis",
159
164
  ADAPTERS_USER_SESSION_STORES: "redis",
160
165
  ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
161
166
  // The replay seen-set behind private_key_jwt client authentication.
@@ -304,7 +309,9 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
304
309
  */
305
310
  const DELIBERATELY_UNSET: Readonly<Record<string, string>> = {
306
311
  OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS:
307
- "#330 tombstone — any value must fail boot with migration instructions",
312
+ "the variable of a removed key, and only captured — any value fails boot",
313
+ OAUTH_ACCESS_TOKEN_EXPIRES_IN:
314
+ "renamed OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN, and only captured — set alone, or to another value, it fails boot",
308
315
  DEPLOYMENT_MODE:
309
316
  "renamed CORE_DEPLOYMENT_MODE, and only captured — set alone, or to another value, it fails boot",
310
317
  MEMORY_RATE_LIMITER_MAX_BUCKETS:
@@ -327,6 +334,8 @@ const DELIBERATELY_UNSET: Readonly<Record<string, string>> = {
327
334
  "renamed OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED, and only captured — set alone, or to another value, it fails boot",
328
335
  OAUTH_GRANTS_REFRESH_TOKEN_ENABLED:
329
336
  "renamed OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED, and only captured — set alone, or to another value, it fails boot",
337
+ OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY:
338
+ "renamed OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY, and only captured — set alone, or to another value, it fails boot",
330
339
  OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED:
331
340
  "renamed OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED, and only captured — set alone, or to another value, it fails boot",
332
341
  OAUTH_GRANTS_JWT_BEARER_ENABLED:
@@ -437,6 +446,39 @@ const DELIBERATELY_UNSET: Readonly<Record<string, string>> = {
437
446
  "renamed MFA_TOTP_FACTOR_ISSUER, and only captured — set alone, or to another value, it fails boot where MFA is installed",
438
447
  };
439
448
 
449
+ /**
450
+ * The federation variables `config/application.conf` documents on lines it
451
+ * ships commented out — a key an operator binds by uncommenting it — as the
452
+ * string an operator would supply. They are not live substitutions, so they
453
+ * are not in `DOCUMENTED_ENV`; the federation cases below bind them as their
454
+ * comments write them (`commentedFederationBindings`).
455
+ */
456
+ const COMMENTED_FEDERATION_ENV: Readonly<Record<string, string>> = {
457
+ CORE_FEDERATIONS_GOOGLE_SESSION_DOMAIN: ".example.com",
458
+ CORE_FEDERATIONS_GOOGLE_AUTH_CALLBACK_URL: "https://app.example.com/auth/callback",
459
+ CORE_FEDERATIONS_GOOGLE_CLIENT_URL: "https://app.example.com/",
460
+ CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS: "false",
461
+ };
462
+
463
+ /**
464
+ * Each `# <key> = ${?CORE_FEDERATIONS_<NAME>_…}` line of the shipped
465
+ * `config/application.conf`, uncommented as an operator's layer above it:
466
+ * `core.federations.<name>.<key> = ${?…}`.
467
+ */
468
+ function commentedFederationBindings(): string {
469
+ const { applicationConfPath } = resolveConfigPaths(configDir, "production");
470
+ return readFileSync(applicationConfPath, "utf8")
471
+ .split("\n")
472
+ .flatMap((line) => {
473
+ const match =
474
+ /^\s*#\s*([A-Za-z]+) = \$\{\?(CORE_FEDERATIONS_([A-Z0-9]+)_[A-Z0-9_]+)\}\s*$/.exec(line);
475
+ if (match === null) return [];
476
+ const [, key, variable, name] = match as unknown as [string, string, string, string];
477
+ return [`core.federations.${name.toLowerCase()}.${key} = \${?${variable}}`];
478
+ })
479
+ .join("\n");
480
+ }
481
+
440
482
  /**
441
483
  * The provider environment `o3co/auth`'s `tests/docker-compose.yml` sets,
442
484
  * transcribed: each renamed variable under its old and its new name, the two
@@ -507,34 +549,106 @@ function readShippedSwitches(env: Record<string, string>, configEnv = "productio
507
549
  const FEDERATION_STORES = Object.fromEntries(
508
550
  [
509
551
  "userSessionStore",
510
- "sessionRPRegistry",
511
- "sessionFamilyIndex",
512
- "sessionFederationIndex",
552
+ "sessionLifecycle",
513
553
  "federationTokenStore",
514
554
  "refreshTokenFamilyRevocation",
515
555
  ].map((key) => [key, {}]),
516
556
  );
517
557
 
558
+ /** What core handed a federation type for one entry: its callback, and its own keys as the type's schema parsed them. */
559
+ interface Dispatched {
560
+ readonly callbackURL: string;
561
+ readonly entry: Readonly<Record<string, unknown>>;
562
+ }
563
+
564
+ /** A federation type's declaration, as far as this suite reads it. */
565
+ interface TypeDeclaration {
566
+ readonly factory: (deps: unknown, instance: { name: string } & Dispatched) => unknown;
567
+ }
568
+
518
569
  /**
519
- * The shipped layers under `env`, as `app.mts` hands them to boot, and the
520
- * configuration boot parsed: phase one for what the composition expects of
521
- * session admission, phase two resolved over the reference of every package
522
- * the template's modules come from (the template's own and core's) and
523
- * parsed once by `createApp` — no bridge on the way.
524
- * No module is loaded unless `modules` names one: the parse is what this
570
+ * `module`, a federation type module, with each type's factory recording what
571
+ * core handed it into `dispatched`, by the entry's name, before building the
572
+ * provider as the module does. The schema, the parse and the provider are the
573
+ * module's own.
574
+ */
575
+ function recordingDispatch(module: Module, dispatched: Map<string, Dispatched>): Module {
576
+ const contributes = module.contributes as {
577
+ readonly federationTypes: Readonly<Record<string, TypeDeclaration>>;
578
+ };
579
+ const federationTypes = Object.fromEntries(
580
+ Object.entries(contributes.federationTypes).map(([type, declaration]) => [
581
+ type,
582
+ {
583
+ ...declaration,
584
+ factory: (deps: unknown, instance: { name: string } & Dispatched) => {
585
+ dispatched.set(instance.name, {
586
+ callbackURL: instance.callbackURL,
587
+ entry: instance.entry,
588
+ });
589
+ return declaration.factory(deps, instance);
590
+ },
591
+ },
592
+ ]),
593
+ );
594
+ return { ...module, contributes: { ...contributes, federationTypes } } as Module;
595
+ }
596
+
597
+ /**
598
+ * The upstream the documented OIDC federation's issuer names. Its type
599
+ * discovers the issuer at boot, so the module is handed this fake's `fetch`
600
+ * rather than the network. Made once per file.
601
+ */
602
+ let oidcUpstream: Promise<FakeIdp> | undefined;
603
+ const documentedOidcUpstream = (): Promise<FakeIdp> => {
604
+ oidcUpstream ??= createFakeIdp({
605
+ issuer: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_ISSUER as string,
606
+ discovery: true,
607
+ clientId: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_CLIENT_ID as string,
608
+ });
609
+ return oidcUpstream;
610
+ };
611
+
612
+ /**
613
+ * The shipped layers under `env`, as `app.mts` hands them to boot, booted
614
+ * with the federation types the template bundles: phase one for what the
615
+ * composition expects of session admission, phase two resolved over the
616
+ * reference of every package the template's modules come from (the
617
+ * template's own and core's) and parsed once by `createApp` — no bridge on
618
+ * the way. Core dispatches each enabled federation to its type, which parses
619
+ * the entry with its own schema; `dispatched` is what each type was handed.
620
+ * No other module is loaded unless `modules` names one: the parse is what this
525
621
  * suite asks about, and each key it reads is one core's schema declares, or
526
622
  * the section of a module it loads.
527
623
  */
528
- async function bootParsed(
624
+ /**
625
+ * The oauth module's section, read without its factories: the template always
626
+ * loads the module, and core refuses keys of `oauth {}` where no loaded
627
+ * module's section is `oauth`. What refuses `oauth {}` here is the module's
628
+ * own section — its schema, its removed keys and variables.
629
+ */
630
+ const oauthSectionReader = defineModule({
631
+ name: oauthEndpointsModule.name,
632
+ ...(oauthEndpointsModule.section === undefined ? {} : { section: oauthEndpointsModule.section }),
633
+ });
634
+
635
+ async function bootDispatched(
529
636
  env: Record<string, string>,
530
637
  configEnv = "production",
531
638
  operatorLayer?: string,
532
639
  modules: readonly Module[] = [],
533
- ): Promise<AppConfig> {
640
+ ): Promise<{ readonly parsed: AppConfig; readonly dispatched: ReadonlyMap<string, Dispatched> }> {
534
641
  const own = readOwnLayers(ownFiles(configEnv, operatorLayer), { env });
535
642
  const switches = readSwitches(own);
643
+ const dispatched = new Map<string, Dispatched>();
644
+ const upstream = await documentedOidcUpstream();
536
645
  const handle = await createApp({
537
- modules: [...modules],
646
+ modules: [
647
+ recordingDispatch(googleFederationTypeModule(), dispatched),
648
+ recordingDispatch(oidcFederationTypeModule({ fetch: upstream.fetch }), dispatched),
649
+ oauthSectionReader,
650
+ ...modules,
651
+ ],
538
652
  bootstrapComponents: {
539
653
  config: resolveForBoot(own, buildModules(switches, { environment: configEnv }), switches),
540
654
  pathResolver: (s: string) => s,
@@ -544,7 +658,17 @@ async function bootParsed(
544
658
  const parsed = handle.components.config;
545
659
  await handle.dispose();
546
660
  if (parsed === undefined) throw new Error("createApp booted without the parsed configuration");
547
- return parsed;
661
+ return { parsed, dispatched };
662
+ }
663
+
664
+ /** The configuration boot parsed, as {@link bootDispatched} boots it. */
665
+ async function bootParsed(
666
+ env: Record<string, string>,
667
+ configEnv = "production",
668
+ operatorLayer?: string,
669
+ modules: readonly Module[] = [],
670
+ ): Promise<AppConfig> {
671
+ return (await bootDispatched(env, configEnv, operatorLayer, modules)).parsed;
548
672
  }
549
673
 
550
674
  /** `session-store {}` as the session store's module parses it. */
@@ -560,6 +684,13 @@ function sessionStoreSection(config: AppConfig): {
560
684
  >;
561
685
  }
562
686
 
687
+ /** `federation-grants {}` as the federation-grants module parses it. */
688
+ function federationGrantsSection(config: AppConfig): { readonly enabled?: boolean } {
689
+ const schema = federationGrantsModule.section?.schema;
690
+ if (schema === undefined) throw new Error("the federation-grants module declares no section");
691
+ return schema.parse(config["federation-grants"]) as ReturnType<typeof federationGrantsSection>;
692
+ }
693
+
563
694
  /** `session {}` as the session module parses it. */
564
695
  function sessionSection(config: AppConfig): { readonly csrf?: { readonly ttlSeconds: number } } {
565
696
  const schema = sessionModule.section?.schema;
@@ -578,13 +709,10 @@ function httpSectionOf(config: AppConfig): {
578
709
  }
579
710
 
580
711
  /** `oauth {}` as the oauth module's own schema parses it. */
581
- function oauthSection(config: AppConfig): {
582
- readonly consentPage?: { readonly url: string };
583
- readonly clientIdMetadataDocuments?: Readonly<Record<string, unknown>>;
584
- } {
585
- const schema = oauthModule({ config }).section?.schema;
712
+ function oauthSection(config: AppConfig): OAuthSection {
713
+ const schema = oauthEndpointsModule.section?.schema;
586
714
  if (schema === undefined) throw new Error("the oauth module declares no section");
587
- return schema.parse(config.oauth) as ReturnType<typeof oauthSection>;
715
+ return schema.parse(config.oauth) as OAuthSection;
588
716
  }
589
717
 
590
718
  /** Every `${?VAR}` in a HOCON layer, ignoring commented-out lines. */
@@ -646,14 +774,14 @@ describe("the shipped config boots with every documented override supplied as a
646
774
  // Each of these arrives from HOCON as a string. A leftover string is
647
775
  // not a cosmetic defect: `=== true` is how the runtime reads them.
648
776
  expect(sessionStoreSection(config).secure).toBe(false);
649
- expect(config.oauth.jwt.legacyTypAccept).toBe(true);
650
- expect(config.oauth.requireEmailVerified).toBe(true);
651
- expect(config.oauth.resourceIndicator?.enabled).toBe(true);
777
+ expect(oauthSection(config).jwt.legacyTypAccept).toBe(true);
778
+ expect(oauthSection(config).requireEmailVerified).toBe(true);
779
+ expect(oauthSection(config).resourceIndicator?.enabled).toBe(true);
652
780
  expect(config.core?.federations?.google?.enabled).toBe(true);
653
781
  expect(config.core?.federations?.oidc?.enabled).toBe(true);
654
782
  // A leftover string here would be read as "on" by a truthiness check
655
783
  // and as "off" by `=== true`, for a feature whose whole default is off.
656
- expect(config["federation-grants"]?.enabled).toBe(true);
784
+ expect(federationGrantsSection(config).enabled).toBe(true);
657
785
  });
658
786
 
659
787
  it("turns every non-boolean override into its declared type", async () => {
@@ -665,17 +793,18 @@ describe("the shipped config boots with every documented override supplied as a
665
793
  expect(http.readinessTimeoutMs).toBe(1500);
666
794
  expect(http.trustProxy).toEqual(["10.0.0.0/8", "loopback"]);
667
795
  expect(config.core?.federations?.google?.accessType).toBe("online");
668
- // The new default wins over the deprecated variable, and the parsed
669
- // config mirrors it onto the old key for readers that predate the split.
670
- expect(resolveAccessTokenLifetime(config)).toEqual({
796
+ expect(resolveAccessTokenLifetime({ oauth: oauthSection(config) })).toEqual({
797
+ defaultExpiresIn: 900,
798
+ maxExpiresIn: 7200,
799
+ });
800
+ expect(oauthSection(config).accessToken).toEqual({
671
801
  defaultExpiresIn: 900,
672
802
  maxExpiresIn: 7200,
673
803
  });
674
- expect(config.oauth.accessToken.expiresIn).toBe(900);
675
- expect(config.oauth.refreshToken.expiresIn).toBe(86400);
804
+ expect(oauthSection(config).refreshToken.expiresIn).toBe(86400);
676
805
  expect(sessionStoreSection(config).maxAge).toBe(3600000);
677
806
  expect(sessionSection(config).csrf?.ttlSeconds).toBe(7200);
678
- expect(config.oauth.nonce?.maxLength).toBe(256);
807
+ expect(oauthSection(config).nonce?.maxLength).toBe(256);
679
808
  expect(readShippedSwitches(DOCUMENTED_ENV).adapters.consentStore).toBe("redis");
680
809
  // A Redis store's section, which its module (not loaded here) parses.
681
810
  const sections = config as unknown as Record<string, { keyPrefix?: unknown } | undefined>;
@@ -711,6 +840,133 @@ describe("the shipped config boots with every documented override supplied as a
711
840
  expect(http.cors.allowedOrigins).toEqual(["https://app.example.com", "http://localhost:5173"]);
712
841
  });
713
842
 
843
+ describe("the federations' variables, read by each federation's type", () => {
844
+ it("hands each enabled federation's type the entry its documented variables set", async () => {
845
+ const { dispatched } = await bootDispatched(DOCUMENTED_ENV);
846
+ expect(Object.fromEntries(dispatched)).toEqual({
847
+ google: {
848
+ callbackURL: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CALLBACK_URL,
849
+ entry: {
850
+ clientId: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID,
851
+ clientSecret: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET,
852
+ redirectAllowlist: [],
853
+ accessType: "online",
854
+ },
855
+ },
856
+ oidc: {
857
+ callbackURL: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_CALLBACK_URL,
858
+ entry: {
859
+ issuer: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_ISSUER,
860
+ clientId: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_CLIENT_ID,
861
+ clientSecret: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_CLIENT_SECRET,
862
+ scopes: ["openid", "profile", "email"],
863
+ redirectAllowlist: [],
864
+ },
865
+ },
866
+ });
867
+ });
868
+
869
+ it("reads the Google keys the configuration documents commented out, bound as their comments write them", async () => {
870
+ const { dispatched } = await bootDispatched(
871
+ { ...DOCUMENTED_ENV, ...COMMENTED_FEDERATION_ENV },
872
+ "production",
873
+ commentedFederationBindings(),
874
+ );
875
+ expect(dispatched.get("google")?.entry).toEqual({
876
+ clientId: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID,
877
+ clientSecret: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET,
878
+ redirectAllowlist: [],
879
+ accessType: "online",
880
+ sessionDomain: COMMENTED_FEDERATION_ENV.CORE_FEDERATIONS_GOOGLE_SESSION_DOMAIN,
881
+ authCallbackUrl: COMMENTED_FEDERATION_ENV.CORE_FEDERATIONS_GOOGLE_AUTH_CALLBACK_URL,
882
+ clientUrl: COMMENTED_FEDERATION_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_URL,
883
+ requireAuthorizationResponseIss: false,
884
+ });
885
+ // `toEqual` passes a key present as `undefined`: no variable binds
886
+ // `endSessionEndpoint`, so the entry must not carry the key at all.
887
+ expect(dispatched.get("google")?.entry).not.toHaveProperty("endSessionEndpoint");
888
+ });
889
+
890
+ it("hands the Google type no key the documented variables leave unset, not even as undefined", async () => {
891
+ const { dispatched } = await bootDispatched(DOCUMENTED_ENV);
892
+ const entry = dispatched.get("google")?.entry;
893
+ expect(entry).toBeDefined();
894
+ for (const key of [
895
+ "sessionDomain",
896
+ "authCallbackUrl",
897
+ "clientUrl",
898
+ "requireAuthorizationResponseIss",
899
+ "endSessionEndpoint",
900
+ ]) {
901
+ expect(entry, key).not.toHaveProperty(key);
902
+ }
903
+ });
904
+
905
+ it("binds every variable the configuration documents commented out", () => {
906
+ expect(
907
+ commentedFederationBindings()
908
+ .match(/\$\{\?[A-Z0-9_]+\}/g)
909
+ ?.sort(),
910
+ ).toEqual(
911
+ Object.keys(COMMENTED_FEDERATION_ENV)
912
+ .map((name) => `\${?${name}}`)
913
+ .sort(),
914
+ );
915
+ });
916
+
917
+ /** The entry the Google type was handed under `variables`, the commented keys bound. */
918
+ const googleEntryUnder = async (variables: Record<string, string>) =>
919
+ (
920
+ await bootDispatched(
921
+ { ...DOCUMENTED_ENV, ...variables },
922
+ "production",
923
+ commentedFederationBindings(),
924
+ )
925
+ ).dispatched.get("google")?.entry;
926
+
927
+ for (const [supplied, expected] of [
928
+ ["true", true],
929
+ ["TRUE", true],
930
+ ["1", true],
931
+ ["false", false],
932
+ ["False", false],
933
+ [" false ", false],
934
+ ["0", false],
935
+ ] as const) {
936
+ it(`CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS=${JSON.stringify(supplied)} reads as ${expected}`, async () => {
937
+ const entry = await googleEntryUnder({
938
+ CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS: supplied,
939
+ });
940
+ expect(entry?.requireAuthorizationResponseIss).toBe(expected);
941
+ });
942
+ }
943
+
944
+ it("refuses CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS exported empty or misspelt, naming the key, rather than turning the check off", async () => {
945
+ for (const supplied of ["", "no", "off", "ture"]) {
946
+ await expect(
947
+ googleEntryUnder({
948
+ CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS: supplied,
949
+ }),
950
+ supplied,
951
+ ).rejects.toThrow(/core\.federations\.google\.requireAuthorizationResponseIss/);
952
+ }
953
+ });
954
+
955
+ it("reads CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE as offline or online, exactly", async () => {
956
+ for (const supplied of ["offline", "online"]) {
957
+ expect(
958
+ (await googleEntryUnder({ CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE: supplied }))?.accessType,
959
+ ).toBe(supplied);
960
+ }
961
+ for (const supplied of ["", "Online", "offine", "true"]) {
962
+ await expect(
963
+ googleEntryUnder({ CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE: supplied }),
964
+ supplied,
965
+ ).rejects.toThrow(/core\.federations\.google\.accessType/);
966
+ }
967
+ });
968
+ });
969
+
714
970
  describe("HTTP_CORS_ALLOWED_ORIGINS", () => {
715
971
  it("reads an exported-but-empty variable as no origins, not as an error", async () => {
716
972
  // The .env / compose / ConfigMap shape. "CORS off" is what both the
@@ -802,8 +1058,8 @@ describe("the shipped config boots with every documented override supplied as a
802
1058
  it("parses the environment the umbrella E2E boots, with SESSION_STORE_SECURE=false as a string", async () => {
803
1059
  const config = await bootParsed(UMBRELLA_E2E_ENV);
804
1060
  expect(sessionStoreSection(config).secure).toBe(false);
805
- expect(config.oauth.requireEmailVerified).toBe(true);
806
- expect(config.oauth.resourceIndicator?.enabled).toBe(true);
1061
+ expect(oauthSection(config).requireEmailVerified).toBe(true);
1062
+ expect(oauthSection(config).resourceIndicator?.enabled).toBe(true);
807
1063
  expect(config.core?.deployment?.mode).toBe("multi");
808
1064
  });
809
1065
 
@@ -814,8 +1070,14 @@ describe("the shipped config boots with every documented override supplied as a
814
1070
  // list covers core's modules only, and cannot see the template's own
815
1071
  // memory modules.
816
1072
  const { replicaUnsafeReason } = await import("@o3co/auth-provider-core");
817
- for (const module of buildModules(readShippedSwitches(UMBRELLA_E2E_ENV))) {
818
- expect(replicaUnsafeReason(module), module.name).toBeUndefined();
1073
+ // Each module's section at its name: a declaration made from the
1074
+ // section is answered for it.
1075
+ const own = readOwnLayers(ownFiles("production"), { env: UMBRELLA_E2E_ENV });
1076
+ const switches = readSwitches(own);
1077
+ const modules = buildModules(switches);
1078
+ const sections = resolveForBoot(own, modules, switches) as unknown as Record<string, unknown>;
1079
+ for (const module of modules) {
1080
+ expect(replicaUnsafeReason(module, sections[module.name]), module.name).toBeUndefined();
819
1081
  }
820
1082
  });
821
1083
 
@@ -852,7 +1114,7 @@ describe("the shipped config boots with every documented override supplied as a
852
1114
  ...DOCUMENTED_ENV,
853
1115
  OAUTH_JWT_LEGACY_TYP_ACCEPT: supplied,
854
1116
  });
855
- expect(config.oauth.jwt.legacyTypAccept).toBe(expected);
1117
+ expect(oauthSection(config).jwt.legacyTypAccept).toBe(expected);
856
1118
  });
857
1119
  }
858
1120
 
@@ -916,13 +1178,13 @@ describe("the shipped config boots with every documented override supplied as a
916
1178
  });
917
1179
 
918
1180
  describe("variables whose documented behaviour is to fail boot", () => {
919
- it("refuses OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS, naming allowUnmarkedClients", async () => {
920
- await expect(
921
- bootParsed({
922
- ...DOCUMENTED_ENV,
923
- OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS: "true",
924
- }),
925
- ).rejects.toThrow(/allowUnmarkedClients/);
1181
+ it("refuses OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS as the variable of a removed key, naming allowUnmarkedClients", async () => {
1182
+ const booting = bootParsed({
1183
+ ...DOCUMENTED_ENV,
1184
+ OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS: "true",
1185
+ });
1186
+ await expect(booting).rejects.toMatchObject({ reason: "environment-variable-renamed" });
1187
+ await expect(booting).rejects.toThrow(/oauth\.authorize\.allowUnmarkedClients/);
926
1188
  });
927
1189
 
928
1190
  it("refuses an access-token default above the max, naming both keys", async () => {
@@ -938,30 +1200,63 @@ describe("the shipped config boots with every documented override supplied as a
938
1200
  for (const name of [
939
1201
  "OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
940
1202
  "OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN",
941
- "OAUTH_ACCESS_TOKEN_EXPIRES_IN",
942
1203
  ]) {
943
1204
  it(`refuses an empty ${name} rather than minting already-expired tokens`, async () => {
944
- await expect(bootParsed({ ...DOCUMENTED_ENV, [name]: "" })).rejects.toThrow();
1205
+ await expect(bootParsed({ ...DOCUMENTED_ENV, [name]: "" })).rejects.toMatchObject({
1206
+ reason: "config-validation-failed",
1207
+ });
945
1208
  });
946
1209
  }
947
1210
 
948
1211
  it("refuses MFA_MODE that is none of the three before boot, naming mfaMode", async () => {
949
1212
  const env = { ...DOCUMENTED_ENV, MFA_MODE: "on" };
950
- expect(() => readShippedSwitches(env)).toThrow(RangeError);
1213
+ expect(() => readShippedSwitches(env)).toThrow(BootError);
951
1214
  await expect(bootParsed(env)).rejects.toThrow(/mfaMode/);
952
1215
  });
953
1216
 
954
1217
  for (const mode of ["optional", "required"] as const) {
955
1218
  it(`reads MFA_MODE=${mode} as the switch that installs MFA, expecting mfa`, () => {
956
- const switches = readShippedSwitches({ ...DOCUMENTED_ENV, MFA_MODE: mode });
1219
+ const own = readOwnLayers(ownFiles("production"), {
1220
+ env: { ...DOCUMENTED_ENV, MFA_MODE: mode },
1221
+ });
1222
+ const switches = readSwitches(own);
957
1223
  expect(switches.mfaMode).toBe(mode);
958
- expect(expectedSessionRequirements(switches)).toEqual({
1224
+ const shipped = (resolveLayers(own, []).core as { sessionRequirements?: unknown })
1225
+ .sessionRequirements;
1226
+ expect(expectedSessionRequirements(shipped, switches.mfaMode)).toEqual({
959
1227
  expected: ["mfa"],
960
1228
  secondFactorAuthority: "mfa",
961
1229
  });
962
1230
  });
963
1231
  }
964
1232
 
1233
+ it("refuses OAUTH_ACCESS_TOKEN_EXPIRES_IN set alone, naming OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN", async () => {
1234
+ const { OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: _new, ...env } = DOCUMENTED_ENV;
1235
+ const booting = bootParsed({ ...env, OAUTH_ACCESS_TOKEN_EXPIRES_IN: "900" });
1236
+ await expect(booting).rejects.toMatchObject({ reason: "environment-variable-renamed" });
1237
+ await expect(booting).rejects.toThrow(
1238
+ /OAUTH_ACCESS_TOKEN_EXPIRES_IN was renamed OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN/,
1239
+ );
1240
+ });
1241
+
1242
+ it("refuses OAUTH_ACCESS_TOKEN_EXPIRES_IN beside its new name set to another value", async () => {
1243
+ await expect(
1244
+ bootParsed({ ...DOCUMENTED_ENV, OAUTH_ACCESS_TOKEN_EXPIRES_IN: "3600" }),
1245
+ ).rejects.toMatchObject({ reason: "environment-variable-renamed" });
1246
+ });
1247
+
1248
+ it("boots with OAUTH_ACCESS_TOKEN_EXPIRES_IN beside its new name set to the same value, reading the new name", async () => {
1249
+ const config = await bootParsed({
1250
+ ...DOCUMENTED_ENV,
1251
+ OAUTH_ACCESS_TOKEN_EXPIRES_IN:
1252
+ DOCUMENTED_ENV.OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN as string,
1253
+ });
1254
+ expect(oauthSection(config).accessToken).toEqual({
1255
+ defaultExpiresIn: 900,
1256
+ maxExpiresIn: 7200,
1257
+ });
1258
+ });
1259
+
965
1260
  it("refuses DEPLOYMENT_MODE set alone, naming CORE_DEPLOYMENT_MODE", async () => {
966
1261
  const { CORE_DEPLOYMENT_MODE: _new, ...env } = DOCUMENTED_ENV;
967
1262
  await expect(bootParsed({ ...env, DEPLOYMENT_MODE: "multi" })).rejects.toThrow(