@o3co/create-auth-provider 0.17.0-rc.1 → 0.17.0-rc.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.ja.md +3 -1
  2. package/README.md +5 -2
  3. package/package.json +1 -1
  4. package/templates/standalone/.env.example +7 -5
  5. package/templates/standalone/README.ja.md +53 -25
  6. package/templates/standalone/README.md +140 -58
  7. package/templates/standalone/config/application.conf +41 -36
  8. package/templates/standalone/config/clients.yaml.example +8 -0
  9. package/templates/standalone/config/development.conf +2 -2
  10. package/templates/standalone/config/mailpit.conf +10 -5
  11. package/templates/standalone/config/reference.conf +30 -5
  12. package/templates/standalone/docker-compose.mailpit.yml +3 -0
  13. package/templates/standalone/docker-compose.production.yml +5 -2
  14. package/templates/standalone/package.json +1 -1
  15. package/templates/standalone/src/__tests__/adapters.test.mts +92 -25
  16. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  17. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +112 -98
  18. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +40 -11
  19. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +86 -51
  20. package/templates/standalone/src/__tests__/audit-sink.test.mts +10 -9
  21. package/templates/standalone/src/__tests__/config-notices.test.mts +157 -0
  22. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +48 -0
  23. package/templates/standalone/src/__tests__/core-federations.test.mts +57 -31
  24. package/templates/standalone/src/__tests__/core-sections.test.mts +7 -5
  25. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
  26. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +434 -136
  27. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +19 -21
  28. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +9 -15
  29. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +10 -4
  30. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  31. package/templates/standalone/src/__tests__/google-session.fixture.mts +60 -67
  32. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  33. package/templates/standalone/src/__tests__/library-references.fixture.mts +51 -46
  34. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  35. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +81 -16
  36. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  37. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +8 -4
  38. package/templates/standalone/src/__tests__/mfa-switch.test.mts +149 -46
  39. package/templates/standalone/src/__tests__/own-modules.test.mts +78 -23
  40. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
  41. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +214 -0
  42. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
  43. package/templates/standalone/src/__tests__/replica-safety.test.mts +131 -19
  44. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +25 -23
  45. package/templates/standalone/src/__tests__/sections.test.mts +24 -0
  46. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  47. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +3 -7
  48. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  49. package/templates/standalone/src/__tests__/session-requirements.test.mts +114 -31
  50. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +274 -0
  51. package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
  52. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +148 -0
  53. package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
  54. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +58 -38
  55. package/templates/standalone/src/__tests__/two-phase-config.test.mts +243 -76
  56. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +23 -31
  57. package/templates/standalone/src/adapters.mts +31 -18
  58. package/templates/standalone/src/app.mts +6 -5
  59. package/templates/standalone/src/bootRefusal.mts +117 -0
  60. package/templates/standalone/src/buildModules.mts +46 -96
  61. package/templates/standalone/src/configPath.mts +180 -153
  62. package/templates/standalone/src/mfaSwitch.mts +49 -27
  63. package/templates/standalone/src/modules.mts +37 -219
  64. package/templates/standalone/src/rootRenames.mts +38 -22
  65. package/templates/standalone/src/sections.mts +13 -2
  66. package/templates/standalone/tests/index.test.js +4 -3
  67. package/templates/versions.json +18 -18
  68. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -134
  69. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -202
  70. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -144
@@ -34,11 +34,10 @@
34
34
  */
35
35
 
36
36
  import { readdirSync, readFileSync } from "node:fs";
37
- import { federationsOf } from "@o3co/auth-provider-core";
37
+ import { type AppConfig, federationsOf } from "@o3co/auth-provider-core";
38
38
  import type express from "express";
39
39
  import request from "supertest";
40
40
  import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
41
- import type { Switches } from "#/configPath.mjs";
42
41
  import {
43
42
  ALICE,
44
43
  AS_LISTED,
@@ -107,17 +106,17 @@ async function boot(options: Parameters<typeof compose>[0] = {}): Promise<Compos
107
106
  const TEMPLATE_PACKAGES: Readonly<Record<string, string>> = {
108
107
  "@o3co/auth-provider-core":
109
108
  "jwksModule, the memory stores, the refresh-token family rotation and revocation",
110
- "@o3co/auth-provider-federation-google": "googleFederationModule",
109
+ "@o3co/auth-provider-federation-google": "googleFederationTypeModule, the google type",
111
110
  "@o3co/auth-provider-federation-grants": "federationGrantsModules",
112
- "@o3co/auth-provider-federation-oidc": "oidcFederationModule, one per `oidc` section",
111
+ "@o3co/auth-provider-federation-oidc": "oidcFederationTypeModule, the oidc type",
113
112
  "@o3co/auth-provider-foundation":
114
113
  "the HTTP user repository adapter; foundationMfaFactorStoreModule under MFA_MODE with adapters.mfaFactorStore = store (tools/composition boots it)",
115
114
  "@o3co/auth-provider-mfa":
116
115
  "mfaModules (mfaTotpFactorModule, mfaRecoveryCodeFactorModule, mfaEmailFactorModule, mfaModule) and mfaResetModule, under MFA_MODE (mfa-switch.test.mts)",
117
116
  "@o3co/auth-provider-oauth":
118
- "oauthModule, oauthSessionModule, oauthAuthorizationModule, subjectRevocationServiceModule",
117
+ "oauthEndpointsModule, oauthSessionGrantModule, oauthAuthorizationGrantsModule, subjectRevocationServiceModule",
119
118
  "@o3co/auth-provider-redis": "the Redis stores (all-modules-composition.multi.test.mts)",
120
- "@o3co/auth-provider-session": "sessionModule, sessionStoreModuleFor",
119
+ "@o3co/auth-provider-session": "sessionModule, sessionStoreModule",
121
120
  "@o3co/auth-provider-standard":
122
121
  "standardSmtpMailSenderModule; standardDevelopmentMailSenderModule in development",
123
122
  };
@@ -131,10 +130,10 @@ const TEMPLATE_PACKAGES: Readonly<Record<string, string>> = {
131
130
  * here, through this file's fixture.
132
131
  */
133
132
  const NOT_IN_TEMPLATE: Readonly<Record<string, string>> = {
134
- "@o3co/auth-provider-device-grant": "deviceGrantModule",
133
+ "@o3co/auth-provider-device-grant": "deviceAuthorizationGrantModule",
135
134
  "@o3co/auth-provider-dpop": "dpopModule",
136
- "@o3co/auth-provider-federation-apple": "appleFederationModule",
137
- "@o3co/auth-provider-federation-github": "githubFederationModule",
135
+ "@o3co/auth-provider-federation-apple": "appleFederationTypeModule",
136
+ "@o3co/auth-provider-federation-github": "githubFederationTypeModule",
138
137
  "@o3co/auth-provider-mtls": "mtlsModule",
139
138
  "@o3co/auth-provider-oauth-token-exchange": "tokenExchangeModule",
140
139
  "@o3co/auth-provider-test-kit": "no module: contract suites and fakes, for tests",
@@ -184,10 +183,8 @@ const ALL_ON_MODULES = [
184
183
  "oauth-authorization",
185
184
  "jwks",
186
185
  "session",
187
- "federation-google",
188
- "google-federation-config",
189
- "oidc-federation-config",
190
- "federation-oidc-oidc",
186
+ "federation-google-type",
187
+ "federation-oidc",
191
188
  "logging",
192
189
  "http",
193
190
  "key-store",
@@ -198,6 +195,7 @@ const ALL_ON_MODULES = [
198
195
  "core-federation-grant-store-memory",
199
196
  "core-federation-grant-intent-store-memory",
200
197
  "standalone-in-memory-session-stores",
198
+ "core-session-lifecycle",
201
199
  "core-rate-limiter-memory",
202
200
  "standalone-in-memory-code-repository",
203
201
  "core-access-token-denylist-memory",
@@ -237,13 +235,18 @@ describe("every module the template can turn on boots together", () => {
237
235
  );
238
236
  });
239
237
 
240
- it("registers every federation and every grant a module contributes", async () => {
238
+ it("registers a federation for every enabled entry of a type a module contributes, and every grant", async () => {
241
239
  const { modules, handle, app } = await boot();
242
- const federations = modules.flatMap((m) => contributionNames(m, "federations")).sort();
243
- expect(federations).toEqual(["google", "oidc"]);
244
- expect([...(handle.components.federationProviders?.keys() ?? [])].sort()).toEqual(federations);
245
-
246
- const grants = modules.flatMap((m) => contributionNames(m, "grants")).sort();
240
+ const types = modules.flatMap((m) => contributionNames(m, "federationTypes")).sort();
241
+ expect(types).toEqual(["google", "oidc"]);
242
+ // The shipped entries are named after their types.
243
+ expect([...(handle.components.federationProviders?.keys() ?? [])].sort()).toEqual(types);
244
+
245
+ // What boot registered: a module's grant switched off by its section
246
+ // answers `null` and registers nothing.
247
+ const grants = [...(handle.components.grantHandlerResolver?.entries() ?? [])]
248
+ .map(([grantType]) => grantType)
249
+ .sort();
247
250
  expect(grants).toEqual(ENABLED_GRANTS);
248
251
  const discovery = await request(app).get("/.well-known/openid-configuration");
249
252
  expect([...discovery.body.grant_types_supported].sort()).toEqual(grants);
@@ -387,18 +390,21 @@ describe("discovery", () => {
387
390
  });
388
391
 
389
392
  /** The grant connections dropped: they name the OIDC federation. */
390
- const withoutConnections = (config: Switches): Switches =>
393
+ const withoutConnections = (config: AppConfig): AppConfig =>
391
394
  ({
392
395
  ...config,
393
- "federation-grants": { ...config["federation-grants"], connections: {} },
394
- }) as Switches;
396
+ "federation-grants": {
397
+ ...(config["federation-grants"] as Record<string, unknown>),
398
+ connections: {},
399
+ },
400
+ }) as AppConfig;
395
401
 
396
402
  const FEATURE_SWITCHES: ReadonlyArray<
397
403
  readonly [
398
404
  feature: string,
399
405
  variable: string,
400
406
  path: string,
401
- config: ((config: Switches) => Switches) | undefined,
407
+ config: ((config: AppConfig) => AppConfig) | undefined,
402
408
  ]
403
409
  > = [
404
410
  ["federation grants", "FEDERATION_GRANTS_ENABLED", "/oauth/federation-grants", undefined],
@@ -579,13 +585,13 @@ describe("every module's primary route answers in the one app", () => {
579
585
  knownDefect(
580
586
  "a federation enabled from the documented variables alone either refuses to boot or completes a login",
581
587
  async () => {
582
- const withoutLanding = (config: Switches): Switches => {
588
+ const withoutLanding = (config: AppConfig): AppConfig => {
583
589
  const federations = federationsOf(config) as Record<string, Record<string, unknown>>;
584
590
  const { clientUrl: _dropped, ...oidc } = federations.oidc ?? {};
585
591
  return {
586
592
  ...config,
587
593
  core: { ...config.core, federations: { ...federations, oidc } },
588
- } as unknown as Switches;
594
+ } as unknown as AppConfig;
589
595
  };
590
596
  let composed: Composition;
591
597
  try {
@@ -609,7 +615,7 @@ describe("every module's primary route answers in the one app", () => {
609
615
  },
610
616
  );
611
617
 
612
- it("a federation grant is lodged, beside oauthModule under /oauth", async () => {
618
+ it("a federation grant is lodged, beside oauthEndpointsModule under /oauth", async () => {
613
619
  const { app } = await boot();
614
620
  const res = await lodgeGrant(app);
615
621
  expect(res.status).toBe(201);
@@ -651,7 +657,7 @@ describe.each([AS_LISTED, REVERSED] satisfies ModuleOrder[])(
651
657
  });
652
658
 
653
659
  it.each(TRANSFERS)(
654
- "federation grants keep their 16 KiB bound beneath oauthModule's /oauth, a body sent %s",
660
+ "federation grants keep their 16 KiB bound beneath oauthEndpointsModule's /oauth, a body sent %s",
655
661
  async (_transfer, send) => {
656
662
  for (const type of [JSON_TYPE, FORM_TYPE]) {
657
663
  const body =
@@ -823,6 +829,16 @@ const oidcCallback = async (app: express.Express, outage: Outage, c: Composition
823
829
  const VERIFIER_WARN =
824
830
  "core's verifier (`verifyJwt`, `packages/core/src/jwt/verify.mts`) writes its own `jwt_verify_rejected` warn (`reason: \"revocation_unavailable\"`) beside the route's error line: two lines for one outage (the runbook's outage table documents both)";
825
831
 
832
+ /** The CSRF token the session middleware set in `cookies`, for a request that mutates. */
833
+ const csrfTokenOf = (cookies: readonly string[]): string => {
834
+ const cookie = cookies.find((c) => /^[^=]*\.csrf=/.test(c));
835
+ if (cookie === undefined) throw new Error("the login reissued no CSRF cookie");
836
+ return decodeURIComponent(cookie.slice(cookie.indexOf("=") + 1).split(";")[0] ?? "");
837
+ };
838
+
839
+ const ROLLBACK_CLOSE_LOGS =
840
+ "a login that fails after its record's open closes that record (`packages/session/src/establish-session.mts`), and the close's own work meets the same outage: core's session lifecycle (`packages/core/src/session-lifecycle/service.mts`) warns `session_close_item_failed` for the item and keeps the close pending for its sweep, a second line for one outage";
841
+
826
842
  const OUTAGES: readonly OutageCase[] = [
827
843
  {
828
844
  module: "oauth-authorization",
@@ -832,22 +848,6 @@ const OUTAGES: readonly OutageCase[] = [
832
848
  answer: { status: 503, error: "temporarily_unavailable" },
833
849
  event: "authorization_grant_store_unavailable",
834
850
  },
835
- {
836
- module: "oauth-authorization",
837
- slot: "sessionFamilyIndex",
838
- surface: "the code exchange",
839
- run: codeExchange,
840
- answer: { status: 503, error: "temporarily_unavailable" },
841
- event: "authorization_grant_store_unavailable",
842
- },
843
- {
844
- module: "oauth-authorization",
845
- slot: "sessionRPRegistry",
846
- surface: "the code exchange",
847
- run: codeExchange,
848
- answer: { status: 503, error: "temporarily_unavailable" },
849
- event: "authorization_grant_store_unavailable",
850
- },
851
851
  {
852
852
  module: "oauth",
853
853
  slot: "codeRepository",
@@ -925,6 +925,47 @@ const OUTAGES: readonly OutageCase[] = [
925
925
  event: "userinfo_store_unavailable",
926
926
  unrelatedWarns: ["jwt_verify_aud_skipped"],
927
927
  },
928
+ {
929
+ module: "oauth",
930
+ slot: "sessionLifecycleStore",
931
+ surface: "/oauth/userinfo",
932
+ run: async (app, outage) => {
933
+ const { access_token } = await webTokens(app);
934
+ outage.down = true;
935
+ return request(app).get("/oauth/userinfo").set("Authorization", `Bearer ${access_token}`);
936
+ },
937
+ answer: { status: 503, error: "temporarily_unavailable" },
938
+ event: "userinfo_store_unavailable",
939
+ unrelatedWarns: ["jwt_verify_aud_skipped"],
940
+ },
941
+ {
942
+ module: "oauth",
943
+ slot: "sessionLifecycleStore",
944
+ surface: "POST /oauth/logout",
945
+ run: async (app, outage) => {
946
+ const { id_token } = await webTokens(app);
947
+ outage.down = true;
948
+ return request(app).post("/oauth/logout").type("form").send({ id_token_hint: id_token });
949
+ },
950
+ answer: { status: 503, error: "temporarily_unavailable" },
951
+ event: "logout_store_unavailable",
952
+ unrelatedWarns: ["jwt_verify_aud_skipped"],
953
+ },
954
+ {
955
+ module: "session",
956
+ slot: "sessionLifecycleStore",
957
+ surface: "POST /session/logout",
958
+ run: async (app, outage) => {
959
+ const { cookies } = await login(app);
960
+ outage.down = true;
961
+ return request(app)
962
+ .post("/session/logout")
963
+ .set("Cookie", cookies)
964
+ .set("x-csrf-token", csrfTokenOf(cookies));
965
+ },
966
+ answer: { status: 503, error: "temporarily_unavailable" },
967
+ event: "session_logout_store_unavailable",
968
+ },
928
969
  {
929
970
  module: "oauth-session",
930
971
  slot: "userSessionStore",
@@ -981,6 +1022,7 @@ const OUTAGES: readonly OutageCase[] = [
981
1022
  },
982
1023
  answer: { status: 503, error: "temporarily_unavailable" },
983
1024
  event: "login_store_unavailable",
1025
+ defects: { "no-warn": ROLLBACK_CLOSE_LOGS },
984
1026
  },
985
1027
  {
986
1028
  module: "session",
@@ -1041,14 +1083,7 @@ const OUTAGES: readonly OutageCase[] = [
1041
1083
  run: oidcCallback,
1042
1084
  answer: { status: 503, error: "temporarily_unavailable" },
1043
1085
  event: "federation_callback_store_unavailable",
1044
- },
1045
- {
1046
- module: "session",
1047
- slot: "sessionFederationIndex",
1048
- surface: "the OIDC federation callback",
1049
- run: oidcCallback,
1050
- answer: { status: 503, error: "temporarily_unavailable" },
1051
- event: "federation_callback_store_unavailable",
1086
+ defects: { "no-warn": ROLLBACK_CLOSE_LOGS },
1052
1087
  },
1053
1088
  {
1054
1089
  module: "core (rate-limit guard)",
@@ -28,6 +28,7 @@
28
28
  import { generateKeyPairSync } from "node:crypto";
29
29
  import { readFileSync } from "node:fs";
30
30
  import {
31
+ type AppConfig,
31
32
  type AuditEvent,
32
33
  type AuditSink,
33
34
  createApp,
@@ -54,8 +55,12 @@ const keyPair = generateKeyPairSync("ed25519", {
54
55
  privateKeyEncoding: { type: "pkcs8", format: "pem" },
55
56
  });
56
57
 
57
- /** The template modules' sections sit beside core's, so the configuration is wider than `AppConfig`. */
58
- const baseConfig: Switches & Record<string, unknown> = {
58
+ /**
59
+ * Phase one's switches beside the configuration boot is handed; the template
60
+ * modules' sections sit beside core's, so the configuration is wider than
61
+ * `AppConfig`.
62
+ */
63
+ const baseConfig: Switches & AppConfig & Record<string, unknown> = {
59
64
  // The shipped selections, every store in this process.
60
65
  adapters: {
61
66
  ...shippedAdapters(),
@@ -85,17 +90,13 @@ const baseConfig: Switches & Record<string, unknown> = {
85
90
  },
86
91
  // The shipped `application.conf` expects no session requirement (ADR
87
92
  // 2026-09-28-session-admission).
88
- ...coreConfigForTests({ federations: { google: { enabled: false } } }),
93
+ ...coreConfigForTests({ federations: { google: { enabled: false, type: "google" } } }),
89
94
  oauth: {
90
95
  jwt: {
91
96
  issuer: "https://auth.test",
92
97
  },
93
- accessToken: { expiresIn: 3600 },
94
- refreshToken: {
95
- expiresIn: 86400,
96
- unknownFamilyPolicy: "reject" as const,
97
- legacyRtPolicy: "reject" as const,
98
- },
98
+ accessToken: { defaultExpiresIn: 3600 },
99
+ refreshToken: { expiresIn: 86400 },
99
100
  grants: {},
100
101
  oidcMode: "oidc-required",
101
102
  },
@@ -0,0 +1,157 @@
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
+ * Core's notices about configuration nothing loaded reads, through the
19
+ * template's own reading: the template hands boot the configuration's
20
+ * defaults (`configDefaultsFor`), and core alone decides what is named. A
21
+ * section the template's `reference.conf` sets for a module the composition
22
+ * does not load is silent while it is as that file sets it, and
23
+ * `config_sections_not_loaded` once the operator's layer or the environment
24
+ * changes it; a section no loaded package sets is `config_sections_ignored`;
25
+ * a renamed variable no loaded module declares is
26
+ * `environment_variables_not_applied`.
27
+ */
28
+
29
+ import { fileURLToPath } from "node:url";
30
+ import { moduleReferences } from "@o3co/auth-provider-core";
31
+ import { parseFile } from "@o3co/ts.hocon";
32
+ import { afterEach, describe, expect, it, vi } from "vitest";
33
+ import { configDefaultsFor } from "#/configPath.mjs";
34
+ import {
35
+ type ComposeOptions,
36
+ type Composition,
37
+ compose,
38
+ composedModules,
39
+ type RecordingLogger,
40
+ resolveConfig,
41
+ SINGLE_ENV,
42
+ } from "./all-modules-composition.fixture.mjs";
43
+
44
+ let current: Composition | undefined;
45
+
46
+ afterEach(async () => {
47
+ await current?.handle.dispose();
48
+ current = undefined;
49
+ vi.unstubAllEnvs();
50
+ });
51
+
52
+ const boot = async (options: ComposeOptions): Promise<Composition> => {
53
+ current = await compose(options);
54
+ return current;
55
+ };
56
+
57
+ /** What each warn line named `event` names: its `sections` or `variables`. */
58
+ const named = (logger: RecordingLogger, event: string): unknown[] =>
59
+ logger.lines
60
+ .filter((line) => line.level === "warn" && line.args[1] === event)
61
+ .map((line) => line.args[0]);
62
+
63
+ /** `redis-clients` is the template's module, which no store on Redis loads under SINGLE_ENV. */
64
+ const NOT_LOADED = "redis-clients";
65
+
66
+ describe("a section the template's reference.conf sets for a module the composition does not load", () => {
67
+ it("is handed to boot and named by nothing while it is as that file sets it", async () => {
68
+ const { logger, resolved, modules } = await boot({ env: SINGLE_ENV });
69
+ expect(modules.map((m) => m.name)).not.toContain(NOT_LOADED);
70
+ expect(resolved).toHaveProperty([NOT_LOADED]);
71
+ expect(named(logger, "config_sections_not_loaded")).toEqual([]);
72
+ expect(named(logger, "config_sections_ignored")).toEqual([]);
73
+ });
74
+
75
+ it("is config_sections_not_loaded once the operator's layer changes it", async () => {
76
+ const { logger } = await boot({
77
+ env: SINGLE_ENV,
78
+ operatorHocon: `${NOT_LOADED}.url = "redis://elsewhere.test:6379"\n`,
79
+ });
80
+ expect(named(logger, "config_sections_not_loaded")).toEqual([{ sections: [NOT_LOADED] }]);
81
+ expect(named(logger, "config_sections_ignored")).toEqual([]);
82
+ });
83
+
84
+ it("is config_sections_not_loaded once the environment changes it, and the variable environment_variables_not_applied", async () => {
85
+ const { logger } = await boot({
86
+ env: { ...SINGLE_ENV, REDIS_CLIENTS_URL: "redis://elsewhere.test:6379" },
87
+ });
88
+ expect(named(logger, "config_sections_not_loaded")).toEqual([{ sections: [NOT_LOADED] }]);
89
+ expect(named(logger, "config_sections_ignored")).toEqual([]);
90
+ expect(named(logger, "environment_variables_not_applied")).toEqual([
91
+ { variables: ["REDIS_CLIENTS_URL"] },
92
+ ]);
93
+ });
94
+ });
95
+
96
+ describe("the limiter sections the template's reference.conf writes for both limiters", () => {
97
+ it.each(["memory", "redis"] as const)(
98
+ "are named by nothing with the %s limiter wired",
99
+ async (rateLimiter) => {
100
+ const { logger, modules } = await boot({
101
+ env: { ...SINGLE_ENV, ADAPTERS_RATE_LIMITER: rateLimiter },
102
+ });
103
+ expect(modules.map((m) => m.name)).toContain(
104
+ rateLimiter === "redis" ? "redis-rate-limiter" : "core-rate-limiter-memory",
105
+ );
106
+ expect(named(logger, "config_sections_not_loaded")).toEqual([]);
107
+ expect(named(logger, "config_sections_ignored")).toEqual([]);
108
+ },
109
+ );
110
+ });
111
+
112
+ describe("a section no loaded package's reference.conf sets", () => {
113
+ it("is config_sections_ignored: a misspelt section name", async () => {
114
+ const { logger } = await boot({
115
+ env: SINGLE_ENV,
116
+ operatorHocon: 'redis-client.url = "redis://elsewhere.test:6379"\n',
117
+ });
118
+ expect(named(logger, "config_sections_ignored")).toEqual([{ sections: ["redis-client"] }]);
119
+ expect(named(logger, "config_sections_not_loaded")).toEqual([]);
120
+ });
121
+ });
122
+
123
+ describe("a renamed variable a loaded package captures and no loaded module declares", () => {
124
+ it("is environment_variables_not_applied: RATE_LIMIT_FAIL_MODE with the Redis package loaded but not its rate limiter", async () => {
125
+ const { logger, modules } = await boot({
126
+ env: { ...SINGLE_ENV, ADAPTERS_CODE_REPOSITORY: "redis", RATE_LIMIT_FAIL_MODE: "closed" },
127
+ });
128
+ expect(modules.map((m) => m.name)).not.toContain("redis-rate-limiter");
129
+ expect(named(logger, "environment_variables_not_applied")).toEqual([
130
+ { variables: ["RATE_LIMIT_FAIL_MODE"] },
131
+ ]);
132
+ expect(named(logger, "config_sections_not_loaded")).toEqual([]);
133
+ expect(named(logger, "config_sections_ignored")).toEqual([]);
134
+ });
135
+ });
136
+
137
+ describe("configDefaultsFor", () => {
138
+ const modules = composedModules(resolveConfig(SINGLE_ENV));
139
+
140
+ it("is every loaded package's reference.conf, core's last, resolved with no environment", () => {
141
+ const references = moduleReferences(modules).map((reference) =>
142
+ parseFile(fileURLToPath(reference), { env: {} }),
143
+ );
144
+ const expected = references
145
+ .slice(1)
146
+ .reduce((config, next) => config.withFallback(next), references[0]);
147
+ expect(configDefaultsFor(modules)).toEqual(expected?.toObject());
148
+ });
149
+
150
+ it("takes nothing from the process's environment", () => {
151
+ vi.stubEnv("REDIS_CLIENTS_URL", "redis://operator.test:6379");
152
+ expect(configDefaultsFor(modules)[NOT_LOADED]).toEqual({
153
+ url: "redis://localhost:6379",
154
+ assumeNoEviction: false,
155
+ });
156
+ });
157
+ });
@@ -0,0 +1,48 @@
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 switch, `mfaMode`, as this project's own configuration files write
19
+ * it with `MFA_MODE` unset: `{environment}.conf` over `application.conf` over
20
+ * the template's `config/reference.conf`. The template ships it `required`;
21
+ * a project `create-auth-provider --no-mfa` scaffolds writes it `off` at the
22
+ * end of its `application.conf`. A suite whose subject depends on the switch
23
+ * asserts what MFA on (`required`, as the template ships it) or off (as
24
+ * `--no-mfa` writes it) implies, so the suite that ships with a scaffold holds
25
+ * in either project. A project whose files write another mode, such as
26
+ * `optional`, is not what these suites claim to cover.
27
+ *
28
+ * Read with the HOCON parser alone, not through phase one (`readSwitches`),
29
+ * so that a suite about phase one can state what phase one should answer.
30
+ */
31
+
32
+ import { join } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+ import { type Config, empty, parseFile } from "@o3co/ts.hocon";
35
+ import { type MfaSwitch, mfaSwitchSchema } from "#/sections.mjs";
36
+
37
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
38
+
39
+ /** `mfaMode` as the files selected by `environment` write it, with no variable set. */
40
+ export function configuredMfaMode(environment: string): MfaSwitch {
41
+ const resolved = [`${environment}.conf`, "application.conf", "reference.conf"]
42
+ .reduce<Config>(
43
+ (layered, file) => layered.withFallback(parseFile(join(configDir, file), { env: {} })),
44
+ empty(),
45
+ )
46
+ .toObject() as Record<string, unknown>;
47
+ return mfaSwitchSchema.parse(resolved.mfaMode);
48
+ }
@@ -16,13 +16,14 @@
16
16
 
17
17
  /**
18
18
  * The federations the template ships, in core's own section:
19
- * `core.federations.google` and `core.federations.oidc`, each bound to the
20
- * variables named after its paths (`CORE_FEDERATIONS_<NAME>_<KEY>`). Phase
21
- * one chooses the federation modules from the map there. A variable the
19
+ * `core.federations.google` and `core.federations.oidc`, each naming its
20
+ * type and bound to the variables named after its paths
21
+ * (`CORE_FEDERATIONS_<NAME>_<KEY>`). The federation types the template
22
+ * bundles are listed whatever the map says (`federation-types.test.mts`
23
+ * boots them). A variable the
22
24
  * template bound before, `FEDERATIONS_<NAME>_<KEY>`, set alone or beside its
23
- * new name at a different value is refused before any module is chosen,
24
- * naming the new variable and path and never a value; the two at one value
25
- * are accepted. The map written at the top level refuses boot, naming its
25
+ * new name, at any value, is refused before any module is chosen, naming the
26
+ * new variable and path and never a value. The map written at the top level refuses boot, naming its
26
27
  * paths under `core.federations`.
27
28
  */
28
29
 
@@ -30,7 +31,7 @@ import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
30
31
  import { tmpdir } from "node:os";
31
32
  import { join } from "node:path";
32
33
  import { fileURLToPath } from "node:url";
33
- import { createApp } from "@o3co/auth-provider-core";
34
+ import { BootError, createApp } from "@o3co/auth-provider-core";
34
35
  import { afterAll, describe, expect, it } from "vitest";
35
36
  import { buildModules } from "#/buildModules.mjs";
36
37
  import { readOwnLayers, readSwitches, resolveConfigPaths, resolveForBoot } from "#/configPath.mjs";
@@ -71,10 +72,16 @@ function refusal(env: Record<string, string>): Error {
71
72
  throw new Error("phase one read the switches");
72
73
  }
73
74
 
74
- /** The federations phase one reads, by name. */
75
- const federationsOf = (env: Record<string, string> = {}) =>
76
- (switchesFrom(env).core as { federations?: Record<string, Record<string, unknown>> } | undefined)
77
- ?.federations ?? {};
75
+ /** The federations the composition hands boot under `env`, by name: phase one, then phase two. */
76
+ function federationsOf(env: Record<string, string> = {}) {
77
+ const own = readOwnLayers(ownFiles(), { env: { ...MFA_OFF, ...env } });
78
+ const switches = readSwitches(own);
79
+ const modules = buildModules(switches, { environment: "production" });
80
+ const resolved = resolveForBoot(own, modules, switches) as unknown as {
81
+ readonly core?: { readonly federations?: Record<string, Record<string, unknown>> };
82
+ };
83
+ return resolved.core?.federations ?? {};
84
+ }
78
85
 
79
86
  /** The modules phase one chooses under `env`, by name. */
80
87
  const moduleNames = (env: Record<string, string>) =>
@@ -131,9 +138,9 @@ const RENAMED = [
131
138
  ] as const;
132
139
 
133
140
  describe("the federations the template ships, under core.federations", () => {
134
- it("ships google and oidc disabled, oidc of type oidc", () => {
141
+ it("ships google and oidc disabled, each of the type of its name", () => {
135
142
  const federations = federationsOf();
136
- expect(federations.google?.enabled).toBe(false);
143
+ expect(federations.google).toMatchObject({ enabled: false, type: "google" });
137
144
  expect(federations.oidc).toMatchObject({ enabled: false, type: "oidc" });
138
145
  });
139
146
 
@@ -144,20 +151,16 @@ describe("the federations the template ships, under core.federations", () => {
144
151
  },
145
152
  );
146
153
 
147
- it("chooses the Google federation's modules when CORE_FEDERATIONS_GOOGLE_ENABLED is true, and none while it is not", () => {
148
- expect(moduleNames({ CORE_FEDERATIONS_GOOGLE_ENABLED: "true" })).toContain("federation-google");
149
- expect(moduleNames({})).not.toContain("federation-google");
150
- });
151
-
152
- it("chooses an OIDC federation's module for an entry of type oidc written under core.federations", () => {
153
- const names = buildModules(
154
- switchesFrom(
155
- {},
156
- 'core.federations.okta { enabled = true, type = "oidc", issuer = "https://okta.test", clientId = "c", clientSecret = "s", callbackURL = "https://auth.test/session/oauth/federation/okta/callback" }\n',
157
- ),
158
- { environment: "production" },
159
- ).map((module) => module.name);
160
- expect(names).toContain("federation-oidc-okta");
154
+ it("lists the same federation modules whether CORE_FEDERATIONS_GOOGLE_ENABLED is true or not", () => {
155
+ const federationModules = (env: Record<string, string>) =>
156
+ moduleNames(env).filter((name) => name === "federation-oidc" || name.endsWith("-type"));
157
+ expect(federationModules({ CORE_FEDERATIONS_GOOGLE_ENABLED: "true" })).toEqual([
158
+ "federation-google-type",
159
+ "federation-oidc",
160
+ ]);
161
+ expect(federationModules({})).toEqual(
162
+ federationModules({ CORE_FEDERATIONS_GOOGLE_ENABLED: "true" }),
163
+ );
161
164
  });
162
165
  });
163
166
 
@@ -166,9 +169,24 @@ describe("a variable the template bound before, FEDERATIONS_<NAME>_<KEY>", () =>
166
169
  "core.federations.%s.%s: %s set alone is refused before any module is chosen, naming %s",
167
170
  (name, key, old, variable, value) => {
168
171
  const err = refusal({ [old]: value });
169
- expect(err).toBeInstanceOf(RangeError);
172
+ expect(err).toBeInstanceOf(BootError);
170
173
  expect(err.message).toContain(`${old} was renamed ${variable}`);
171
174
  expect(err.message).toContain(`core.federations.${name}.${key}`);
175
+ expect(err).toMatchObject({
176
+ reason: "environment-variable-renamed",
177
+ details: {
178
+ reason: "environment-variable-renamed",
179
+ renamed: [
180
+ {
181
+ module: "core",
182
+ from: old,
183
+ to: variable,
184
+ path: `core.federations.${name}.${key}`,
185
+ state: "unset",
186
+ },
187
+ ],
188
+ },
189
+ });
172
190
  },
173
191
  );
174
192
 
@@ -176,6 +194,10 @@ describe("a variable the template bound before, FEDERATIONS_<NAME>_<KEY>", () =>
176
194
  "core.federations.%s.%s: %s beside its new name at a different value is refused, quoting neither",
177
195
  (_name, _key, old, variable) => {
178
196
  const err = refusal({ [old]: "old-value-5e2d", [variable]: "new-value-c81a" });
197
+ expect(err).toMatchObject({
198
+ reason: "environment-variable-renamed",
199
+ details: { renamed: [{ from: old, state: "different" }] },
200
+ });
179
201
  expect(err.message).toContain(old);
180
202
  expect(err.message).not.toContain("old-value-5e2d");
181
203
  expect(err.message).not.toContain("new-value-c81a");
@@ -183,9 +205,13 @@ describe("a variable the template bound before, FEDERATIONS_<NAME>_<KEY>", () =>
183
205
  );
184
206
 
185
207
  it.each(RENAMED)(
186
- "core.federations.%s.%s: %s beside its new name at the same value is read",
187
- (name, key, old, variable, value) => {
188
- expect(String(federationsOf({ [old]: value, [variable]: value })[name]?.[key])).toBe(value);
208
+ "core.federations.%s.%s: %s beside its new name at the same value is refused all the same",
209
+ (_name, _key, old, variable, value) => {
210
+ const err = refusal({ [old]: value, [variable]: value });
211
+ expect(err).toMatchObject({
212
+ reason: "environment-variable-renamed",
213
+ details: { renamed: [{ from: old, state: "different" }] },
214
+ });
189
215
  },
190
216
  );
191
217
  });