@o3co/create-auth-provider 0.16.0 → 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 (100) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +59 -29
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +324 -169
  14. package/templates/standalone/README.md +618 -208
  15. package/templates/standalone/config/application.conf +211 -352
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +418 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +63 -32
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/adapters.test.mts +419 -0
  28. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
  33. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  34. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  35. package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
  36. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  37. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
  38. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
  39. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
  40. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
  41. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
  42. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  43. package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
  44. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  45. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  46. package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
  47. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  48. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  49. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  50. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  51. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
  52. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  53. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  54. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  55. package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
  56. package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
  57. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
  58. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  59. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
  60. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  61. package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
  62. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  63. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
  64. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  65. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  66. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
  67. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  68. package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
  69. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  70. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  71. package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
  72. package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
  73. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  74. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
  75. package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
  76. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
  77. package/templates/standalone/src/adapters.mts +162 -0
  78. package/templates/standalone/src/app.mts +65 -79
  79. package/templates/standalone/src/bootRefusal.mts +117 -0
  80. package/templates/standalone/src/buildModules.mts +220 -309
  81. package/templates/standalone/src/configPath.mts +449 -29
  82. package/templates/standalone/src/listen.mts +12 -18
  83. package/templates/standalone/src/logger.mts +31 -60
  84. package/templates/standalone/src/metrics.mts +24 -47
  85. package/templates/standalone/src/mfaSwitch.mts +285 -0
  86. package/templates/standalone/src/modules.mts +423 -629
  87. package/templates/standalone/src/rootRenames.mts +96 -0
  88. package/templates/standalone/src/routes.mts +8 -14
  89. package/templates/standalone/src/sections.mts +367 -0
  90. package/templates/standalone/src/shutdown.mts +45 -123
  91. package/templates/standalone/tests/index.test.js +6 -5
  92. package/templates/standalone/tests/package-lock.json +1615 -2223
  93. package/templates/standalone/tests/package.json +2 -2
  94. package/templates/standalone/tests/vitest.config.mjs +26 -0
  95. package/templates/standalone/vitest.config.mts +8 -7
  96. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  97. package/templates/versions.json +18 -15
  98. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
  99. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
  100. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
@@ -14,38 +14,52 @@
14
14
  * limitations under the License.
15
15
  */
16
16
 
17
- import { existsSync, readFileSync } from "node:fs";
17
+ import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
18
+ import { tmpdir } from "node:os";
18
19
  import { join } from "node:path";
19
20
  import { fileURLToPath } from "node:url";
20
21
  import {
21
22
  type AppConfig,
22
- AppConfigSchema,
23
+ BootError,
24
+ createApp,
25
+ defineModule,
26
+ type Module,
27
+ moduleReferences,
23
28
  resolveAccessTokenLifetime,
24
29
  } from "@o3co/auth-provider-core";
25
- import { parseFile } from "@o3co/ts.hocon";
26
- import { validate } from "@o3co/ts.hocon/zod";
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";
35
+ import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
27
36
  import { describe, expect, it } from "vitest";
28
37
  import { buildModules } from "../buildModules.mjs";
29
- import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "../configPath.mjs";
38
+ import {
39
+ expectedSessionRequirements,
40
+ readOwnLayers,
41
+ readSwitches,
42
+ resolveConfigPaths,
43
+ resolveForBoot,
44
+ resolveLayers,
45
+ type Switches,
46
+ } from "../configPath.mjs";
47
+ import { httpModule, keyStoreModule, templateReference } from "../modules.mjs";
30
48
 
31
49
  /**
32
- * #288 — boot the shipped config with EVERY documented override supplied the
33
- * way an operator actually supplies one: as a string.
50
+ * Boots the shipped config with EVERY documented override supplied the way an
51
+ * operator supplies one: as a string.
34
52
  *
35
- * HOCON substitutes `${?VAR}` as a string, always. Whether that string is
36
- * usable depends entirely on the schema leaf it lands on, and the schema had
37
- * two answers to that question — an explicit `coerceBooleanFromEnv` on two
38
- * fields, and a bare `z.boolean()` everywhere else that only worked because
39
- * `@o3co/ts.hocon`'s zod bridge coerces a bare boolean leaf it can *reach*.
40
- * The bridge stops at anything that is not a `ZodObject` shape (or an
41
- * optional/nullable/default/catch/readonly wrapper around one), so wrapping a
42
- * section in `z.preprocess(...)` silently took the override away — which is
43
- * precisely what had happened to `OAUTH_JWT_LEGACY_TYP_ACCEPT`.
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,
55
+ * which boot parses once with plain Zod, so every schema leaf must read the
56
+ * string itself. This suite reads through that path: `readSwitches` for what
57
+ * is read before boot, `resolveForBoot` and `createApp` for the configuration
58
+ * boot parsed.
44
59
  *
45
- * This suite is the thing that stops the two paths diverging again. It is not
46
- * a sample of interesting variables: `covers every documented override` below
47
- * fails when a `${?VAR}` is added to either config layer, or documented in the
48
- * README, without being exercised here.
60
+ * It is not a sample of interesting variables: `covers every documented
61
+ * override` below fails when a `${?VAR}` is added to either config layer, or
62
+ * documented in the README, without being exercised here.
49
63
  */
50
64
 
51
65
  // config/ is two levels above this test file: src/__tests__/ → src/ → standalone/
@@ -55,12 +69,11 @@ const readmePath = fileURLToPath(new URL("../../README.md", import.meta.url));
55
69
  const readmeJaPath = fileURLToPath(new URL("../../README.ja.md", import.meta.url));
56
70
 
57
71
  /**
58
- * Every environment variable the shipped artifact documents, with a value in
59
- * the shape an operator would supply. Strings throughout — that is the whole
60
- * point of the exercise.
72
+ * Every environment variable the shipped artifact documents, as the string an
73
+ * operator would supply.
61
74
  *
62
- * `OAUTH_JWT_ALGORITHM` is `EdDSA` here so the asymmetric key variables can be
63
- * in the same map: the HS256 branch of `signingKey.local` is a `.strict()`
75
+ * `KEY_STORE_LOCAL_ALGORITHM` is `EdDSA` so the asymmetric key variables fit in the
76
+ * same map: the HS256 branch of `signingKey.local` is a `.strict()`
64
77
  * discriminated-union member and refuses `privateKeyPath` and friends by
65
78
  * design. The HS256 shape gets its own test below.
66
79
  */
@@ -71,168 +84,222 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
71
84
  HTTP_READINESS_TIMEOUT_MS: "1500",
72
85
 
73
86
  // --- logging ------------------------------------------------------
74
- LOG_LEVEL: "debug",
87
+ LOGGING_LEVEL: "debug",
75
88
 
76
89
  // --- oauth.jwt ----------------------------------------------------
77
90
  OAUTH_JWT_ISSUER: "https://auth.test",
78
- OAUTH_JWT_SIGNING_KEY_PROVIDER: "local",
79
- OAUTH_JWT_ALGORITHM: "EdDSA",
80
- OAUTH_JWT_KID: "v1",
81
- OAUTH_JWT_SECRET: "documented-env-secret.at-least-32-bytes.ok",
82
- OAUTH_JWT_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nMC4=\n-----END PRIVATE KEY-----",
83
- OAUTH_JWT_PRIVATE_KEY_PATH: "./config/jwt-private.pem",
84
- OAUTH_JWT_PUBLIC_KEY: "-----BEGIN PUBLIC KEY-----\nMCo=\n-----END PUBLIC KEY-----",
85
- OAUTH_JWT_PUBLIC_KEY_PATH: "./config/jwt-public.pem",
86
- // The override this issue was filed over: a bare `z.boolean()` behind a
87
- // `z.preprocess` wrapper the hocon bridge cannot see through.
91
+ KEY_STORE_PROVIDER: "local",
92
+ KEY_STORE_LOCAL_ALGORITHM: "EdDSA",
93
+ KEY_STORE_LOCAL_KID: "v1",
94
+ KEY_STORE_LOCAL_SECRET: "documented-env-secret.at-least-32-bytes.ok",
95
+ KEY_STORE_LOCAL_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nMC4=\n-----END PRIVATE KEY-----",
96
+ KEY_STORE_LOCAL_PRIVATE_KEY_PATH: "./config/jwt-private.pem",
97
+ KEY_STORE_LOCAL_PUBLIC_KEY: "-----BEGIN PUBLIC KEY-----\nMCo=\n-----END PUBLIC KEY-----",
98
+ KEY_STORE_LOCAL_PUBLIC_KEY_PATH: "./config/jwt-public.pem",
88
99
  OAUTH_JWT_LEGACY_TYP_ACCEPT: "true",
89
100
 
90
101
  // --- oauth tokens / policy ----------------------------------------
91
102
  OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "900",
92
103
  OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN: "7200",
93
- // Deprecated alias of the default. Exported alongside the new variable on
94
- // purpose, with a different value: the new variable must win.
95
- OAUTH_ACCESS_TOKEN_EXPIRES_IN: "3600",
96
104
  OAUTH_REFRESH_TOKEN_EXPIRES_IN: "86400",
97
- OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY: "reject",
98
105
  OAUTH_OIDC_MODE: "dual",
99
- OAUTH_CODE_ADAPTER: "redis",
100
106
  OAUTH_REVOCATION_ACCESS_TOKEN: "denylist",
101
107
  OAUTH_REVOCATION_SUBJECT: "unsupported",
102
108
  OAUTH_REQUIRE_EMAIL_VERIFIED: "true",
103
109
  OAUTH_REQUIRE_GRANT_TYPE_ALLOWLIST: "true",
104
- OAUTH_GRANTS_JWT_BEARER_ENABLED: "false",
105
110
  OAUTH_NONCE_MAX_LENGTH: "256",
106
- OAUTH_TOKEN_EXCHANGE_MAX_ACTOR_CHAIN_DEPTH: "3",
107
111
  OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
108
- OAUTH_CIMD_ENABLED: "true",
109
- OAUTH_CIMD_ALLOWED_SCOPES: "read, write",
110
- OAUTH_CIMD_ALLOWED_AUDIENCES: "https://mcp.example",
111
- OAUTH_CIMD_ALLOWED_HOSTS: "client.example, .trusted.example",
112
- OAUTH_CIMD_DENIED_HOSTS: "evil.example",
113
- OAUTH_CIMD_MAX_BYTES: "8192",
114
- OAUTH_CIMD_TIMEOUT_MS: "3000",
115
- OAUTH_CIMD_CACHE_MAX_AGE_MS: "60000",
116
- OAUTH_CIMD_MAX_CACHE_ENTRIES: "128",
117
- OAUTH_CIMD_STALE_IF_ERROR_MS: "120000",
118
- OAUTH_CIMD_NEGATIVE_CACHE_MS: "30000",
119
- OAUTH_CIMD_MAX_CONCURRENT_FETCHES: "4",
120
- OAUTH_TOKEN_BINDING_DISPATCH_POLICY: "intent-explicit",
121
- OAUTH_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS: "true",
122
-
123
- // --- oauth.grants -------------------------------------------------
124
- OAUTH_GRANTS_SESSION_ENABLED: "false",
125
- OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED: "true",
126
- OAUTH_GRANTS_REFRESH_TOKEN_ENABLED: "true",
127
- OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED: "true",
128
- // #273 tombstone: inert, but a still-exported value must reach the boot
129
- // warning rather than failing parse.
130
- OAUTH_GRANTS_AUTHORIZATION_CODE_PKCE_REQUIRE_S256: "true",
131
-
132
- // --- session ------------------------------------------------------
133
- SESSION_SECRET: "documented-env-session-secret.at-least-32-bytes.ok",
134
- SESSION_NAME: "auth.session",
135
- SESSION_MAX_AGE: "3600000",
136
- SESSION_SECURE: "false",
137
- SESSION_SAME_SITE: "lax",
138
- SESSION_DOMAIN: "auth.example.com",
112
+ OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED: "true",
113
+ OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_SCOPES: "read, write",
114
+ OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_AUDIENCES: "https://mcp.example",
115
+ OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_HOSTS: "client.example, .trusted.example",
116
+ OAUTH_CLIENT_ID_METADATA_DOCUMENTS_DENIED_HOSTS: "evil.example",
117
+ OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_BYTES: "8192",
118
+ OAUTH_CLIENT_ID_METADATA_DOCUMENTS_TIMEOUT_MS: "3000",
119
+ OAUTH_CLIENT_ID_METADATA_DOCUMENTS_CACHE_MAX_AGE_MS: "60000",
120
+ OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CACHE_ENTRIES: "128",
121
+ OAUTH_CLIENT_ID_METADATA_DOCUMENTS_STALE_IF_ERROR_MS: "120000",
122
+ OAUTH_CLIENT_ID_METADATA_DOCUMENTS_NEGATIVE_CACHE_MS: "30000",
123
+ OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CONCURRENT_FETCHES: "4",
124
+ CORE_TOKEN_BINDING_DISPATCH_POLICY: "intent-explicit",
125
+ CORE_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS: "true",
126
+ CORE_OUTBOUND_ALLOWED_HOSTS: "rp.example, .partner.example",
127
+ CORE_OUTBOUND_DENIED_HOSTS: "blocked.example",
128
+ CORE_OUTBOUND_INTERNAL_HOSTS: ".corp.internal",
129
+ CORE_OUTBOUND_TIMEOUT_MS: "3000",
130
+ CORE_OUTBOUND_MAX_RESPONSE_BYTES: "32768",
131
+ CORE_OUTBOUND_EGRESS: "direct",
132
+
133
+ // --- the grant switches ------------------------------------------
134
+ OAUTH_SESSION_ENABLED: "false",
135
+ OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED: "true",
136
+ OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED: "true",
137
+ OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY: "reject",
138
+ OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED: "true",
139
+ OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED: "false",
140
+
141
+ // --- session: the login routes -------------------------------------
139
142
  SESSION_CSRF_TTL_SECONDS: "7200",
140
- SESSION_STORAGE_TYPE: "redis",
141
- SESSION_STORAGE_REDIS_URL: "redis://redis:6379",
142
- SESSION_STORAGE_REDIS_PASSWORD: "session-store-password",
143
+
144
+ // --- session-store: the session cookie and its store ---------------
145
+ SESSION_STORE_SECRET: "documented-env-session-secret.at-least-32-bytes.ok",
146
+ SESSION_STORE_NAME: "auth.session",
147
+ SESSION_STORE_MAX_AGE: "3600000",
148
+ SESSION_STORE_SECURE: "false",
149
+ SESSION_STORE_SAME_SITE: "lax",
150
+ SESSION_STORE_DOMAIN: "auth.example.com",
151
+ SESSION_STORE_STORAGE_TYPE: "redis",
152
+ SESSION_STORE_STORAGE_REDIS_URL: "redis://redis:6379",
153
+ SESSION_STORE_STORAGE_REDIS_PASSWORD: "session-store-password",
143
154
 
144
155
  // --- rate limiting ------------------------------------------------
145
- RATE_LIMIT_FAIL_MODE: "open",
146
- RATE_LIMITER_ADAPTER: "redis",
147
- MEMORY_RATE_LIMITER_MAX_BUCKETS: "10000",
156
+ REDIS_RATE_LIMITER_FAIL_MODE: "open",
157
+ CORE_RATE_LIMITER_MEMORY_MAX_BUCKETS: "10000",
158
+
159
+ // --- adapters -----------------------------------------------------
160
+ // Which adapter fills each slot: the composition root's own section,
161
+ // read before the modules are chosen.
162
+ ADAPTERS_RATE_LIMITER: "redis",
163
+ ADAPTERS_ATTEMPT_COUNTER: "redis",
164
+ ADAPTERS_USER_SESSION_STORES: "redis",
165
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
166
+ // The replay seen-set behind private_key_jwt client authentication.
167
+ ADAPTERS_REPLAY_SEEN_SET: "redis",
168
+ ADAPTERS_CONSENT_STORE: "redis",
169
+ ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
170
+ ADAPTERS_FEDERATION_GRANT_STORE: "redis",
171
+ ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "redis",
172
+ ADAPTERS_MFA_FACTOR_STORE: "redis",
173
+ ADAPTERS_MFA_TRANSACTION_STORE: "redis",
174
+ ADAPTERS_CODE_REPOSITORY: "redis",
175
+ ADAPTERS_CLIENT_REPOSITORY: "yaml",
176
+ ADAPTERS_USER_REPOSITORY: "yaml",
177
+ // Selects the sink builder; "console" is the registered builtin. There
178
+ // is deliberately no "none" — an unknown sink fails boot in buildModules
179
+ // (pinned by audit-sink.test.mts), not in phase one, because the schema
180
+ // keeps `adapters.auditSink` an open string so out-of-tree sinks need no
181
+ // schema change here.
182
+ ADAPTERS_AUDIT_SINK: "console",
148
183
 
149
- // --- audit --------------------------------------------------------
150
- // #287: selects the sink builder; "console" is the registered builtin.
151
- // There is deliberately no "none" — an unknown type fails boot in
152
- // buildModules (pinned by audit-sink.test.mts), not at config parse,
153
- // because the schema keeps `audit.sink.type` an open string so
154
- // out-of-tree sinks need no schema change here.
155
- AUDIT_SINK_TYPE: "console",
184
+ // --- JWKS ---------------------------------------------------------
185
+ JWKS_PATH: "/keys/jwks.json",
186
+ JWKS_CACHE_MAX_AGE: "600",
156
187
 
157
188
  // --- shared stores ------------------------------------------------
158
- DEPLOYMENT_MODE: "multi",
159
- USER_SESSION_STORES_ADAPTER: "redis",
160
- ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
161
- // #484: the replay seen-set behind private_key_jwt client authentication.
162
- REPLAY_SEEN_SET_ADAPTER: "redis",
163
- // #561: the consent stores' shared backend and its namespace.
164
- CONSENT_STORE_ADAPTER: "redis",
189
+ CORE_DEPLOYMENT_MODE: "multi",
190
+ // The consent stores' namespace.
165
191
  REDIS_CONSENT_STORE_KEY_PREFIX: "tenant-a:consent:",
166
192
  REDIS_ACCESS_TOKEN_DENYLIST_KEY_PREFIX: "atdeny:",
167
193
  REDIS_SESSION_STORES_KEY_PREFIX: "ss:",
168
- REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX: "rtfam:",
169
- REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT: "3",
170
- REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis:6379",
171
- REFRESH_TOKEN_FAMILY_STORE_REDIS_PASSWORD: "rt-family-password",
172
- // #456: the federation token store's Redis branch, which the README
173
- // documented and which could not boot.
174
- FEDERATION_TOKEN_STORE_TYPE: "redis",
194
+ REDIS_REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX: "rtfam:",
195
+ REDIS_REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT: "3",
196
+ REDIS_CLIENTS_URL: "redis://redis:6379",
197
+ REDIS_CLIENTS_PASSWORD: "rt-family-password",
198
+ // The federation token store's Redis branch.
175
199
  REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX: "ft:",
176
200
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE: "required",
177
201
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
178
- // #593: federation grants — a user's standing consent that a client may
179
- // obtain upstream tokens without them. The overrides `reference.conf`
180
- // declares, and (slice 7) the two adapter switches the template composes
181
- // the feature from.
202
+ // Federation grants: a user's standing consent that a client may obtain
203
+ // upstream tokens without them. The overrides `reference.conf` declares.
182
204
  FEDERATION_GRANTS_ENABLED: "true",
183
- FEDERATION_GRANT_STORE_ADAPTER: "redis",
184
- FEDERATION_GRANT_INTENT_STORE_ADAPTER: "redis",
185
- FEDERATION_GRANTS_ENCRYPTION_MODE: "required",
205
+ REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE: "required",
186
206
  FEDERATION_GRANTS_ALLOW_KEEP_ON_SUBJECT_REVOCATION: "false",
187
- // Slice 6: acquisition's two deployment decisions — whether the callback
207
+ // Acquisition's two deployment decisions — whether the callback
188
208
  // refuses an upstream account linked to another user, and the consent page.
189
209
  FEDERATION_GRANTS_IDENTITY_LOOKUP: "required",
190
210
  FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
191
211
  REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX: "fg:",
212
+ // The intent store's own namespace, which a deployment moves with the grant store's.
213
+ REDIS_FEDERATION_GRANT_INTENT_STORE_KEY_PREFIX: "fg:",
214
+
215
+ // --- multi-factor authentication ----------------------------------
216
+ // The template's switch, `mfaMode`: off here, so the parse below
217
+ // layers nothing of MFA. The rest is what the switch installs — the MFA
218
+ // package's settings, foundation's Store-backed factor store's — and is
219
+ // covered by the substitutions it brings (`liveSubstitutions`).
220
+ MFA_MODE: "off",
221
+ MFA_ENCRYPTION_KEY: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=",
222
+ MFA_PAGE_URL: "/mfa",
223
+ MFA_STORE_TIMEOUT_MS: "5000",
224
+ MFA_ENROLLMENT_REQUIRE_EMAIL_PROOF: "when-mail",
225
+ MFA_TOTP_FACTOR_ENABLED: "true",
226
+ MFA_TOTP_FACTOR_ALGORITHM: "SHA1",
227
+ MFA_TOTP_FACTOR_DIGITS: "6",
228
+ MFA_TOTP_FACTOR_PERIOD: "30",
229
+ MFA_TOTP_FACTOR_WINDOW: "1",
230
+ MFA_TOTP_FACTOR_ISSUER: "auth.test",
231
+ MFA_RECOVERY_CODE_FACTOR_ENABLED: "true",
232
+ MFA_RECOVERY_CODE_FACTOR_COUNT: "10",
233
+ MFA_EMAIL_FACTOR_ENABLED: "false",
234
+ MFA_EMAIL_FACTOR_ADDS_MFA: "false",
235
+ MFA_EMAIL_FACTOR_CODE_TTL_SECONDS: "600",
236
+ FOUNDATION_MFA_FACTOR_STORE_LIST_URL: "https://users.example.com/mfa/factors/list",
237
+ FOUNDATION_MFA_FACTOR_STORE_CREATE_URL: "https://users.example.com/mfa/factors/create",
238
+ FOUNDATION_MFA_FACTOR_STORE_UPDATE_URL: "https://users.example.com/mfa/factors/update",
239
+ FOUNDATION_MFA_FACTOR_STORE_DELETE_URL: "https://users.example.com/mfa/factors/delete",
240
+
241
+ // --- mail ---------------------------------------------------------
242
+ // The SMTP sender's section, which the template installs outside
243
+ // development and builds only where something sends.
244
+ STANDARD_SMTP_MAIL_SENDER_HOST: "smtp.example.com",
245
+ STANDARD_SMTP_MAIL_SENDER_PORT: "587",
246
+ STANDARD_SMTP_MAIL_SENDER_SECURE: "starttls",
247
+ STANDARD_SMTP_MAIL_SENDER_USER: "mailer",
248
+ STANDARD_SMTP_MAIL_SENDER_PASSWORD: "mailer-password",
249
+ STANDARD_SMTP_MAIL_SENDER_FROM: "auth@example.com",
250
+ // …and the Redis stores' key namespaces, which the Redis package's two MFA
251
+ // modules read.
252
+ REDIS_MFA_FACTOR_STORE_KEY_PREFIX: "tenant-a:mfaf:",
253
+ REDIS_MFA_TRANSACTION_STORE_KEY_PREFIX: "tenant-a:mfat:",
192
254
 
193
255
  // --- federation ---------------------------------------------------
194
- FEDERATIONS_GOOGLE_ENABLED: "true",
195
- FEDERATIONS_GOOGLE_CLIENT_ID: "google-client-id",
196
- FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-client-secret",
197
- FEDERATIONS_GOOGLE_CALLBACK_URL: "https://auth.test/session/oauth/federation/google/callback",
198
- FEDERATIONS_GOOGLE_ACCESS_TYPE: "online",
199
- // #524: the generic OIDC federation the template ships disabled.
200
- FEDERATIONS_OIDC_ENABLED: "true",
201
- FEDERATIONS_OIDC_ISSUER: "https://idp.test",
202
- FEDERATIONS_OIDC_CLIENT_ID: "oidc-client-id",
203
- FEDERATIONS_OIDC_CLIENT_SECRET: "oidc-client-secret",
204
- FEDERATIONS_OIDC_CALLBACK_URL: "https://auth.test/session/oauth/federation/oidc/callback",
256
+ CORE_FEDERATIONS_GOOGLE_ENABLED: "true",
257
+ CORE_FEDERATIONS_GOOGLE_CLIENT_ID: "google-client-id",
258
+ CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-client-secret",
259
+ CORE_FEDERATIONS_GOOGLE_CALLBACK_URL:
260
+ "https://auth.test/session/oauth/federation/google/callback",
261
+ CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE: "online",
262
+ // The generic OIDC federation the template ships disabled.
263
+ CORE_FEDERATIONS_OIDC_ENABLED: "true",
264
+ CORE_FEDERATIONS_OIDC_ISSUER: "https://idp.test",
265
+ CORE_FEDERATIONS_OIDC_CLIENT_ID: "oidc-client-id",
266
+ CORE_FEDERATIONS_OIDC_CLIENT_SECRET: "oidc-client-secret",
267
+ CORE_FEDERATIONS_OIDC_CALLBACK_URL: "https://auth.test/session/oauth/federation/oidc/callback",
205
268
 
206
269
  // --- repositories -------------------------------------------------
207
- CLIENT_TYPE: "yaml",
208
- CLIENT_PATH: "./config/clients.yaml",
209
- CLIENT_USER_TYPE: "yaml",
210
- CLIENT_USER_PATH: "./config/users.yaml",
211
- CLIENT_USER_AUTHENTICATE_URL: "https://users.example.com/authenticate",
212
- CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL: "https://users.example.com/authenticate-by-token",
213
- CLIENT_USER_LINK_FEDERATED_IDENTITY_URL: "https://users.example.com/link-federated-identity",
214
- // #613: the Store's identity lookup for federation grants (D7 check 5).
215
- CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL:
270
+ REPOSITORIES_CLIENT_YAML_PATH: "./config/clients.yaml",
271
+ REPOSITORIES_USER_YAML_PATH: "./config/users.yaml",
272
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: "https://users.example.com/authenticate",
273
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL:
274
+ "https://users.example.com/authenticate-by-token",
275
+ REPOSITORIES_USER_HTTP_LINK_FEDERATED_IDENTITY_URL:
276
+ "https://users.example.com/link-federated-identity",
277
+ // The Store's identity lookup for federation grants (see ADR
278
+ // 2026-09-17-federation-grants-offline-delegation).
279
+ REPOSITORIES_USER_HTTP_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL:
216
280
  "https://users.example.com/find-subject-by-federated-identity",
217
- CLIENT_USER_TIMEOUT: "5000",
218
- CLIENT_USER_MAX_RESPONSE_BYTES: "1048576",
281
+ // The Store's MFA enrollment witness endpoint (the MFA ADR's D12).
282
+ REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL: "https://users.example.com/mfa/enrolled",
283
+ REPOSITORIES_USER_HTTP_TIMEOUT: "5000",
284
+ REPOSITORIES_USER_HTTP_MAX_RESPONSE_BYTES: "1048576",
219
285
  // The credential the http user adapter presents to the Store; >= 32 bytes.
220
- CLIENT_USER_BEARER_TOKEN: "0328d706529061d93abd6d826e09ef0f0a1e71a12af813b29e5cd2977b7dc63a",
221
- CLIENT_CODE_TYPE: "redis",
222
- CLIENT_CODE_DEFAULT_EXPIRES_IN: "600",
223
- CLIENT_CODE_ENDPOINT_URI: "redis://redis:6379",
224
- CLIENT_CODE_PASSWORD: "code-store-password",
225
- CLIENT_CODE_KEY_PREFIX: "oauth:code:",
286
+ REPOSITORIES_USER_HTTP_BEARER_TOKEN:
287
+ "0328d706529061d93abd6d826e09ef0f0a1e71a12af813b29e5cd2977b7dc63a",
288
+ // The authorization-code repositories: the Redis one (selected above) and
289
+ // the in-process one.
290
+ REDIS_CODE_REPOSITORY_DEFAULT_EXPIRES_IN: "600",
291
+ REDIS_CODE_REPOSITORY_KEY_PREFIX: "oauth:code:",
292
+ STANDALONE_IN_MEMORY_CODE_REPOSITORY_DEFAULT_EXPIRES_IN: "600",
226
293
 
227
- // --- endpoints ----------------------------------------------------
228
- ENDPOINTS_LOGIN_URL: "/login",
229
- ENDPOINTS_CONSENT_URL: "/consent",
294
+ // --- the login and consent pages ----------------------------------
295
+ SESSION_LOGIN_PAGE_URL: "/login",
296
+ OAUTH_CONSENT_PAGE_URL: "/consent",
230
297
 
231
298
  // --- cors ---------------------------------------------------------
232
- // #500: a list, in the only shape an environment variable can carry one.
299
+ // A list, in the only shape an environment variable can carry one.
233
300
  // The schema splits and validates it; the `turns every non-boolean
234
301
  // override into its declared type` case below pins the result.
235
- CORS_ALLOWED_ORIGINS: "https://app.example.com,http://localhost:5173",
302
+ HTTP_CORS_ALLOWED_ORIGINS: "https://app.example.com,http://localhost:5173",
236
303
  };
237
304
 
238
305
  /**
@@ -242,54 +309,410 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
242
309
  */
243
310
  const DELIBERATELY_UNSET: Readonly<Record<string, string>> = {
244
311
  OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS:
245
- "#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",
315
+ DEPLOYMENT_MODE:
316
+ "renamed CORE_DEPLOYMENT_MODE, and only captured — set alone, or to another value, it fails boot",
317
+ MEMORY_RATE_LIMITER_MAX_BUCKETS:
318
+ "renamed CORE_RATE_LIMITER_MEMORY_MAX_BUCKETS, and only captured — set alone, or to another value, it fails boot",
319
+ RATE_LIMIT_FAIL_MODE:
320
+ "renamed REDIS_RATE_LIMITER_FAIL_MODE, and only captured — set alone, or to another value, it fails boot",
321
+ REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX:
322
+ "renamed REDIS_REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX, and only captured — set alone, or to another value, it fails boot",
323
+ REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT:
324
+ "renamed REDIS_REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT, and only captured — set alone, or to another value, it fails boot",
325
+ FEDERATION_GRANTS_ENCRYPTION_MODE:
326
+ "renamed REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE, and only captured — set alone, or to another value, it fails boot",
327
+ OAUTH_TOKEN_BINDING_DISPATCH_POLICY:
328
+ "renamed CORE_TOKEN_BINDING_DISPATCH_POLICY, and only captured — set alone, or to another value, it fails boot",
329
+ OAUTH_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS:
330
+ "renamed CORE_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS, and only captured — set alone, or to another value, it fails boot",
331
+ OAUTH_GRANTS_SESSION_ENABLED:
332
+ "renamed OAUTH_SESSION_ENABLED, and only captured — set alone, or to another value, it fails boot",
333
+ OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED:
334
+ "renamed OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED, and only captured — set alone, or to another value, it fails boot",
335
+ OAUTH_GRANTS_REFRESH_TOKEN_ENABLED:
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",
339
+ OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED:
340
+ "renamed OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED, and only captured — set alone, or to another value, it fails boot",
341
+ OAUTH_GRANTS_JWT_BEARER_ENABLED:
342
+ "renamed OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED, and only captured — set alone, or to another value, it fails boot",
343
+ ENDPOINTS_CONSENT_URL:
344
+ "renamed OAUTH_CONSENT_PAGE_URL, and only captured — set alone, or to another value, it fails boot",
345
+ OAUTH_CIMD_ENABLED:
346
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED, and only captured — set alone, or to another value, it fails boot",
347
+ OAUTH_CIMD_ALLOWED_SCOPES:
348
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_SCOPES, and only captured — set alone, or to another value, it fails boot",
349
+ OAUTH_CIMD_ALLOWED_AUDIENCES:
350
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_AUDIENCES, and only captured — set alone, or to another value, it fails boot",
351
+ OAUTH_CIMD_ALLOWED_HOSTS:
352
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_HOSTS, and only captured — set alone, or to another value, it fails boot",
353
+ OAUTH_CIMD_DENIED_HOSTS:
354
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_DENIED_HOSTS, and only captured — set alone, or to another value, it fails boot",
355
+ OAUTH_CIMD_MAX_BYTES:
356
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_BYTES, and only captured — set alone, or to another value, it fails boot",
357
+ OAUTH_CIMD_TIMEOUT_MS:
358
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_TIMEOUT_MS, and only captured — set alone, or to another value, it fails boot",
359
+ OAUTH_CIMD_CACHE_MAX_AGE_MS:
360
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_CACHE_MAX_AGE_MS, and only captured — set alone, or to another value, it fails boot",
361
+ OAUTH_CIMD_MAX_CACHE_ENTRIES:
362
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CACHE_ENTRIES, and only captured — set alone, or to another value, it fails boot",
363
+ OAUTH_CIMD_STALE_IF_ERROR_MS:
364
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_STALE_IF_ERROR_MS, and only captured — set alone, or to another value, it fails boot",
365
+ OAUTH_CIMD_NEGATIVE_CACHE_MS:
366
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_NEGATIVE_CACHE_MS, and only captured — set alone, or to another value, it fails boot",
367
+ OAUTH_CIMD_MAX_CONCURRENT_FETCHES:
368
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CONCURRENT_FETCHES, and only captured — set alone, or to another value, it fails boot",
369
+ OAUTH_GRANTS_AUTHORIZATION_CODE_PKCE_REQUIRE_S256:
370
+ "the authorization-code grant's pkce block was removed, and this is only captured — set at all, it fails boot",
371
+ ENDPOINTS_LOGIN_URL:
372
+ "renamed SESSION_LOGIN_PAGE_URL, and only captured — set alone, or to another value, it fails boot",
373
+ SESSION_SECRET:
374
+ "renamed SESSION_STORE_SECRET, and only captured — set alone, or to another value, it fails boot",
375
+ SESSION_NAME:
376
+ "renamed SESSION_STORE_NAME, and only captured — set alone, or to another value, it fails boot",
377
+ SESSION_MAX_AGE:
378
+ "renamed SESSION_STORE_MAX_AGE, and only captured — set alone, or to another value, it fails boot",
379
+ SESSION_SECURE:
380
+ "renamed SESSION_STORE_SECURE, and only captured — set alone, or to another value, it fails boot",
381
+ SESSION_SAME_SITE:
382
+ "renamed SESSION_STORE_SAME_SITE, and only captured — set alone, or to another value, it fails boot",
383
+ SESSION_DOMAIN:
384
+ "renamed SESSION_STORE_DOMAIN, and only captured — set alone, or to another value, it fails boot",
385
+ SESSION_STORAGE_TYPE:
386
+ "renamed SESSION_STORE_STORAGE_TYPE, and only captured — set alone, or to another value, it fails boot",
387
+ SESSION_STORAGE_REDIS_URL:
388
+ "renamed SESSION_STORE_STORAGE_REDIS_URL, and only captured — set alone, or to another value, it fails boot",
389
+ SESSION_STORAGE_REDIS_PASSWORD:
390
+ "renamed SESSION_STORE_STORAGE_REDIS_PASSWORD, and only captured — set alone, or to another value, it fails boot",
391
+ LOG_LEVEL:
392
+ "renamed LOGGING_LEVEL, and only captured — set alone, or to another value, it fails boot",
393
+ CORS_ALLOWED_ORIGINS:
394
+ "renamed HTTP_CORS_ALLOWED_ORIGINS, and only captured — set alone, or to another value, it fails boot",
395
+ OAUTH_JWT_SIGNING_KEY_PROVIDER:
396
+ "renamed KEY_STORE_PROVIDER, and only captured — set alone, or to another value, it fails boot",
397
+ OAUTH_JWT_ALGORITHM:
398
+ "renamed KEY_STORE_LOCAL_ALGORITHM, and only captured — set alone, or to another value, it fails boot",
399
+ OAUTH_JWT_KID:
400
+ "renamed KEY_STORE_LOCAL_KID, and only captured — set alone, or to another value, it fails boot",
401
+ OAUTH_JWT_SECRET:
402
+ "renamed KEY_STORE_LOCAL_SECRET, and only captured — set alone, or to another value, it fails boot",
403
+ OAUTH_JWT_PRIVATE_KEY_PATH:
404
+ "renamed KEY_STORE_LOCAL_PRIVATE_KEY_PATH, and only captured — set alone, or to another value, it fails boot",
405
+ OAUTH_JWT_PUBLIC_KEY_PATH:
406
+ "renamed KEY_STORE_LOCAL_PUBLIC_KEY_PATH, and only captured — set alone, or to another value, it fails boot",
407
+ OAUTH_JWT_PRIVATE_KEY:
408
+ "renamed KEY_STORE_LOCAL_PRIVATE_KEY, and only captured — set alone, or to another value, it fails boot",
409
+ OAUTH_JWT_PUBLIC_KEY:
410
+ "renamed KEY_STORE_LOCAL_PUBLIC_KEY, and only captured — set alone, or to another value, it fails boot",
411
+ REFRESH_TOKEN_FAMILY_STORE_REDIS_URL:
412
+ "renamed REDIS_CLIENTS_URL, and only captured — set alone, or to another value, it fails boot",
413
+ REFRESH_TOKEN_FAMILY_STORE_REDIS_PASSWORD:
414
+ "renamed REDIS_CLIENTS_PASSWORD, and only captured — set alone, or to another value, it fails boot",
415
+ CLIENT_PATH:
416
+ "renamed REPOSITORIES_CLIENT_YAML_PATH, and only captured — set alone, or to another value, it fails boot",
417
+ CLIENT_USER_PATH:
418
+ "renamed REPOSITORIES_USER_YAML_PATH, and only captured — set alone, or to another value, it fails boot",
419
+ CLIENT_USER_AUTHENTICATE_URL:
420
+ "renamed REPOSITORIES_USER_HTTP_AUTHENTICATE_URL, and only captured — set alone, or to another value, it fails boot",
421
+ CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL:
422
+ "renamed REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL, and only captured — set alone, or to another value, it fails boot",
423
+ CLIENT_USER_LINK_FEDERATED_IDENTITY_URL:
424
+ "renamed REPOSITORIES_USER_HTTP_LINK_FEDERATED_IDENTITY_URL, and only captured — set alone, or to another value, it fails boot",
425
+ CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL:
426
+ "renamed REPOSITORIES_USER_HTTP_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL, and only captured — set alone, or to another value, it fails boot",
427
+ CLIENT_USER_BEARER_TOKEN:
428
+ "renamed REPOSITORIES_USER_HTTP_BEARER_TOKEN, and only captured — set alone, or to another value, it fails boot",
429
+ CLIENT_USER_TIMEOUT:
430
+ "renamed REPOSITORIES_USER_HTTP_TIMEOUT, and only captured — set alone, or to another value, it fails boot",
431
+ CLIENT_USER_MAX_RESPONSE_BYTES:
432
+ "renamed REPOSITORIES_USER_HTTP_MAX_RESPONSE_BYTES, and only captured — set alone, or to another value, it fails boot",
433
+ CLIENT_CODE_DEFAULT_EXPIRES_IN:
434
+ "renamed REDIS_CODE_REPOSITORY_DEFAULT_EXPIRES_IN (the Redis code repository) and STANDALONE_IN_MEMORY_CODE_REPOSITORY_DEFAULT_EXPIRES_IN (the in-process one), and only captured — set alone, or to another value, it fails boot",
435
+ CLIENT_CODE_KEY_PREFIX:
436
+ "renamed REDIS_CODE_REPOSITORY_KEY_PREFIX, and only captured — set alone, or to another value, it fails boot",
437
+ CLIENT_CODE_ENDPOINT_URI:
438
+ "removed: the Redis code repository uses the shared redis-clients connection; only captured — set at all, it fails boot",
439
+ CLIENT_CODE_PASSWORD:
440
+ "removed: the Redis code repository uses the shared redis-clients connection; only captured — set at all, it fails boot",
441
+ ENDPOINTS_MFA_URL:
442
+ "renamed MFA_PAGE_URL, and only captured — set alone, or to another value, it fails boot where MFA is installed",
443
+ MFA_TOTP_ENABLED:
444
+ "renamed MFA_TOTP_FACTOR_ENABLED, and only captured — set alone, or to another value, it fails boot where MFA is installed",
445
+ MFA_TOTP_ISSUER:
446
+ "renamed MFA_TOTP_FACTOR_ISSUER, and only captured — set alone, or to another value, it fails boot where MFA is installed",
246
447
  };
247
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
+
248
482
  /**
249
483
  * The provider environment `o3co/auth`'s `tests/docker-compose.yml` sets,
250
- * transcribed. The umbrella E2E boots the shipped template with exactly this,
484
+ * transcribed: each renamed variable under its old and its new name, the two
485
+ * at one value. The umbrella E2E boots the shipped template with exactly this,
251
486
  * so a parse failure here is a red umbrella build that this repository can see
252
- * first. `SESSION_SECURE=false` is the one it cannot run without: the suite
253
- * speaks plain HTTP.
487
+ * first. `SESSION_STORE_SECURE=false` is the one it cannot run without: the
488
+ * suite speaks plain HTTP.
254
489
  *
255
- * The two `FEDERATION_TOKEN_STORE` lines are what #455 / #456 **require the
256
- * umbrella compose to add**: the federation token store defaults to memory,
257
- * and the standalone's memory module now declares itself replica-unsafe, so
258
- * the umbrella's `DEPLOYMENT_MODE=multi` refuses it by name until the compose
259
- * selects the Redis store and supplies its encryption key. Until that lands
260
- * in `o3co/auth`, this transcription is ahead of the file it transcribes on
261
- * exactly those two lines.
490
+ * The two `FEDERATION_TOKEN_STORE` lines are required: the federation token
491
+ * store defaults to memory, the standalone's memory module declares itself
492
+ * replica-unsafe, and `CORE_DEPLOYMENT_MODE=multi` refuses it by name unless the
493
+ * Redis store is selected with its encryption key.
262
494
  */
263
495
  const UMBRELLA_E2E_ENV: Readonly<Record<string, string>> = {
264
496
  OAUTH_JWT_ALGORITHM: "HS256",
497
+ KEY_STORE_LOCAL_ALGORITHM: "HS256",
265
498
  OAUTH_JWT_SECRET: "e2e-shared-hs256-secret.at-least-32-bytes.ok",
499
+ KEY_STORE_LOCAL_SECRET: "e2e-shared-hs256-secret.at-least-32-bytes.ok",
266
500
  OAUTH_JWT_ISSUER: "https://auth.e2e.test",
267
501
  SESSION_SECRET: "lO0QH09fuKSGuViZ9myJbH3jsgai99A2GpC3RYRuy6Y=",
502
+ SESSION_STORE_SECRET: "lO0QH09fuKSGuViZ9myJbH3jsgai99A2GpC3RYRuy6Y=",
268
503
  SESSION_SECURE: "false",
504
+ SESSION_STORE_SECURE: "false",
269
505
  SESSION_NAME: "auth.session",
506
+ SESSION_STORE_NAME: "auth.session",
270
507
  DEPLOYMENT_MODE: "multi",
508
+ CORE_DEPLOYMENT_MODE: "multi",
271
509
  REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis:6379",
510
+ REDIS_CLIENTS_URL: "redis://redis:6379",
272
511
  SESSION_STORAGE_REDIS_URL: "redis://redis:6379",
512
+ SESSION_STORE_STORAGE_REDIS_URL: "redis://redis:6379",
273
513
  USER_SESSION_STORES_ADAPTER: "redis",
514
+ ADAPTERS_USER_SESSION_STORES: "redis",
274
515
  RATE_LIMITER_ADAPTER: "redis",
516
+ ADAPTERS_RATE_LIMITER: "redis",
275
517
  OAUTH_CODE_ADAPTER: "redis",
518
+ ADAPTERS_CODE_REPOSITORY: "redis",
276
519
  FEDERATION_TOKEN_STORE_TYPE: "redis",
520
+ ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
277
521
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
278
522
  CLIENT_USER_TYPE: "yaml",
523
+ ADAPTERS_USER_REPOSITORY: "yaml",
279
524
  OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
280
525
  OAUTH_REQUIRE_EMAIL_VERIFIED: "true",
281
- OAUTH_GRANTS_AUTHORIZATION_CODE_PKCE_REQUIRE_S256: "true",
526
+ OAUTH_GRANTS_SESSION_ENABLED: "true",
527
+ OAUTH_SESSION_ENABLED: "true",
528
+ MFA_MODE: "off",
282
529
  };
283
530
 
284
- function buildResolvedConfig(env: Record<string, string>, configEnv = "production"): AppConfig {
531
+ /** The template's own files for `configEnv`, under `operatorLayer` — HOCON an operator adds above them — when given. */
532
+ function ownFiles(configEnv: string, operatorLayer?: string): string[] {
285
533
  const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
286
- const libraryReferencePath = resolveLibraryReferenceConfPath();
287
- return validate(
288
- parseFile(envConfPath, { env })
289
- .withFallback(parseFile(applicationConfPath, { env }))
290
- .withFallback(parseFile(libraryReferencePath, { env })),
291
- AppConfigSchema,
534
+ if (operatorLayer === undefined) return [envConfPath, applicationConfPath];
535
+ const file = join(mkdtempSync(join(tmpdir(), "documented-env-overrides-")), "operator.conf");
536
+ writeFileSync(file, operatorLayer);
537
+ return [file, envConfPath, applicationConfPath];
538
+ }
539
+
540
+ /** Phase one, as `app.mts` reads it: the switches. */
541
+ function readShippedSwitches(env: Record<string, string>, configEnv = "production"): Switches {
542
+ return readSwitches(readOwnLayers(ownFiles(configEnv), { env }));
543
+ }
544
+
545
+ /**
546
+ * The stores an enabled federation needs beside it, which boot refuses a
547
+ * composition without. What they hold is not this suite's question.
548
+ */
549
+ const FEDERATION_STORES = Object.fromEntries(
550
+ [
551
+ "userSessionStore",
552
+ "sessionLifecycle",
553
+ "federationTokenStore",
554
+ "refreshTokenFamilyRevocation",
555
+ ].map((key) => [key, {}]),
556
+ );
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
+
569
+ /**
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
+ ]),
292
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
621
+ * suite asks about, and each key it reads is one core's schema declares, or
622
+ * the section of a module it loads.
623
+ */
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(
636
+ env: Record<string, string>,
637
+ configEnv = "production",
638
+ operatorLayer?: string,
639
+ modules: readonly Module[] = [],
640
+ ): Promise<{ readonly parsed: AppConfig; readonly dispatched: ReadonlyMap<string, Dispatched> }> {
641
+ const own = readOwnLayers(ownFiles(configEnv, operatorLayer), { env });
642
+ const switches = readSwitches(own);
643
+ const dispatched = new Map<string, Dispatched>();
644
+ const upstream = await documentedOidcUpstream();
645
+ const handle = await createApp({
646
+ modules: [
647
+ recordingDispatch(googleFederationTypeModule(), dispatched),
648
+ recordingDispatch(oidcFederationTypeModule({ fetch: upstream.fetch }), dispatched),
649
+ oauthSectionReader,
650
+ ...modules,
651
+ ],
652
+ bootstrapComponents: {
653
+ config: resolveForBoot(own, buildModules(switches, { environment: configEnv }), switches),
654
+ pathResolver: (s: string) => s,
655
+ ...FEDERATION_STORES,
656
+ } as never,
657
+ });
658
+ const parsed = handle.components.config;
659
+ await handle.dispose();
660
+ if (parsed === undefined) throw new Error("createApp booted without the parsed configuration");
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;
672
+ }
673
+
674
+ /** `session-store {}` as the session store's module parses it. */
675
+ function sessionStoreSection(config: AppConfig): {
676
+ readonly secure: boolean;
677
+ readonly sameSite: string;
678
+ readonly maxAge: number;
679
+ } {
680
+ const schema = sessionStoreModule.section?.schema;
681
+ if (schema === undefined) throw new Error("the session store's module declares no section");
682
+ return schema.parse((config as Record<string, unknown>)["session-store"]) as ReturnType<
683
+ typeof sessionStoreSection
684
+ >;
685
+ }
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
+
694
+ /** `session {}` as the session module parses it. */
695
+ function sessionSection(config: AppConfig): { readonly csrf?: { readonly ttlSeconds: number } } {
696
+ const schema = sessionModule.section?.schema;
697
+ if (schema === undefined) throw new Error("the session module declares no section");
698
+ return schema.parse(config.session) as ReturnType<typeof sessionSection>;
699
+ }
700
+
701
+ /** `http {}` as the `http` module parsed it, in a configuration boot parsed with the module loaded. */
702
+ function httpSectionOf(config: AppConfig): {
703
+ readonly port: number;
704
+ readonly readinessTimeoutMs: number;
705
+ readonly trustProxy: unknown;
706
+ readonly cors: { readonly allowedOrigins: readonly string[] };
707
+ } {
708
+ return (config as unknown as { http: ReturnType<typeof httpSectionOf> }).http;
709
+ }
710
+
711
+ /** `oauth {}` as the oauth module's own schema parses it. */
712
+ function oauthSection(config: AppConfig): OAuthSection {
713
+ const schema = oauthEndpointsModule.section?.schema;
714
+ if (schema === undefined) throw new Error("the oauth module declares no section");
715
+ return schema.parse(config.oauth) as OAuthSection;
293
716
  }
294
717
 
295
718
  /** Every `${?VAR}` in a HOCON layer, ignoring commented-out lines. */
@@ -319,56 +742,76 @@ function documentedInReadme(path: string = readmePath): Set<string> {
319
742
  return found;
320
743
  }
321
744
 
745
+ /**
746
+ * The substitutions of the template's own layers, and of the `reference.conf`
747
+ * of every package the template loads a module from under the documented
748
+ * environment in production (core's among them) — and under it with MFA
749
+ * installed, its factors in the Store, which brings the MFA package's and
750
+ * foundation's.
751
+ */
322
752
  function liveSubstitutions(): Set<string> {
323
753
  const { applicationConfPath } = resolveConfigPaths(configDir, "production");
754
+ const references = [
755
+ DOCUMENTED_ENV,
756
+ { ...DOCUMENTED_ENV, MFA_MODE: "required", ADAPTERS_MFA_FACTOR_STORE: "store" },
757
+ ].flatMap((env) =>
758
+ moduleReferences(buildModules(readShippedSwitches(env), { environment: "production" })),
759
+ );
324
760
  return new Set([
325
- ...substitutionsIn(resolveLibraryReferenceConfPath()),
761
+ ...references.flatMap((reference) => [...substitutionsIn(fileURLToPath(reference))]),
762
+ ...substitutionsIn(fileURLToPath(templateReference())),
326
763
  ...substitutionsIn(applicationConfPath),
327
764
  ]);
328
765
  }
329
766
 
330
- describe("#288: the shipped config boots with every documented override supplied as a string", () => {
331
- it("parses with every documented environment variable set", () => {
332
- expect(() => buildResolvedConfig(DOCUMENTED_ENV)).not.toThrow();
767
+ describe("the shipped config boots with every documented override supplied as a string", () => {
768
+ it("parses with every documented environment variable set", async () => {
769
+ await expect(bootParsed(DOCUMENTED_ENV)).resolves.toBeDefined();
333
770
  });
334
771
 
335
- it("turns every boolean override into an actual boolean", () => {
336
- const config = buildResolvedConfig(DOCUMENTED_ENV);
772
+ it("turns every boolean override into an actual boolean", async () => {
773
+ const config = await bootParsed(DOCUMENTED_ENV);
337
774
  // Each of these arrives from HOCON as a string. A leftover string is
338
775
  // not a cosmetic defect: `=== true` is how the runtime reads them.
339
- expect(config.session.secure).toBe(false);
340
- expect(config.oauth.jwt.legacyTypAccept).toBe(true);
341
- expect(config.oauth.requireEmailVerified).toBe(true);
342
- expect(config.oauth.resourceIndicator?.enabled).toBe(true);
343
- expect(config.federations.google?.enabled).toBe(true);
344
- expect(config.federations.oidc?.enabled).toBe(true);
345
- // #593: the newest one, and the one where a leftover string would be
346
- // read as "on" by a truthiness check and as "off" by `=== true` — a
347
- // feature whose whole default is off.
348
- expect(config.federationGrants?.enabled).toBe(true);
776
+ expect(sessionStoreSection(config).secure).toBe(false);
777
+ expect(oauthSection(config).jwt.legacyTypAccept).toBe(true);
778
+ expect(oauthSection(config).requireEmailVerified).toBe(true);
779
+ expect(oauthSection(config).resourceIndicator?.enabled).toBe(true);
780
+ expect(config.core?.federations?.google?.enabled).toBe(true);
781
+ expect(config.core?.federations?.oidc?.enabled).toBe(true);
782
+ // A leftover string here would be read as "on" by a truthiness check
783
+ // and as "off" by `=== true`, for a feature whose whole default is off.
784
+ expect(federationGrantsSection(config).enabled).toBe(true);
349
785
  });
350
786
 
351
- it("turns every non-boolean override into its declared type", () => {
352
- const config = buildResolvedConfig(DOCUMENTED_ENV);
353
- expect(config.http.port).toBe(3000);
354
- expect(config.http.readinessTimeoutMs).toBe(1500);
355
- expect(config.http.trustProxy).toEqual(["10.0.0.0/8", "loopback"]);
356
- expect(config.federations.google?.accessType).toBe("online");
357
- // The new default wins over the deprecated variable, and the parsed
358
- // config mirrors it onto the old key for readers that predate the split.
359
- expect(resolveAccessTokenLifetime(config)).toEqual({
787
+ it("turns every non-boolean override into its declared type", async () => {
788
+ const config = await bootParsed(DOCUMENTED_ENV);
789
+ const http = httpSectionOf(
790
+ await bootParsed(DOCUMENTED_ENV, "production", undefined, [httpModule]),
791
+ );
792
+ expect(http.port).toBe(3000);
793
+ expect(http.readinessTimeoutMs).toBe(1500);
794
+ expect(http.trustProxy).toEqual(["10.0.0.0/8", "loopback"]);
795
+ expect(config.core?.federations?.google?.accessType).toBe("online");
796
+ expect(resolveAccessTokenLifetime({ oauth: oauthSection(config) })).toEqual({
360
797
  defaultExpiresIn: 900,
361
798
  maxExpiresIn: 7200,
362
799
  });
363
- expect(config.oauth.accessToken.expiresIn).toBe(900);
364
- expect(config.oauth.refreshToken.expiresIn).toBe(86400);
365
- expect(config.session.maxAge).toBe(3600000);
366
- expect(config.session.csrf?.ttlSeconds).toBe(7200);
367
- expect(config.oauth.nonce?.maxLength).toBe(256);
368
- expect(config.consentStore?.adapter).toBe("redis");
369
- expect(config.redisConsentStore?.keyPrefix).toBe("tenant-a:consent:");
370
- // #529: the comma-separated lists become lists, trimmed; the numbers, numbers.
371
- expect(config.oauth.clientIdMetadataDocuments).toEqual({
800
+ expect(oauthSection(config).accessToken).toEqual({
801
+ defaultExpiresIn: 900,
802
+ maxExpiresIn: 7200,
803
+ });
804
+ expect(oauthSection(config).refreshToken.expiresIn).toBe(86400);
805
+ expect(sessionStoreSection(config).maxAge).toBe(3600000);
806
+ expect(sessionSection(config).csrf?.ttlSeconds).toBe(7200);
807
+ expect(oauthSection(config).nonce?.maxLength).toBe(256);
808
+ expect(readShippedSwitches(DOCUMENTED_ENV).adapters.consentStore).toBe("redis");
809
+ // A Redis store's section, which its module (not loaded here) parses.
810
+ const sections = config as unknown as Record<string, { keyPrefix?: unknown } | undefined>;
811
+ expect(sections["redis-consent-store"]?.keyPrefix).toBe("tenant-a:consent:");
812
+ // The comma-separated lists become lists, trimmed; the numbers, numbers:
813
+ // the oauth module's schema reads them (the module is not loaded here).
814
+ expect(oauthSection(config).clientIdMetadataDocuments).toEqual({
372
815
  enabled: true,
373
816
  allowedScopes: ["read", "write"],
374
817
  allowedAudiences: ["https://mcp.example"],
@@ -382,22 +825,162 @@ describe("#288: the shipped config boots with every documented override supplied
382
825
  maxConcurrentFetches: 4,
383
826
  cacheMaxAgeMs: 60000,
384
827
  });
385
- // #500: a comma-separated string becomes a list of origins, trimmed.
386
- expect(config.cors?.allowedOrigins).toEqual([
387
- "https://app.example.com",
388
- "http://localhost:5173",
389
- ]);
828
+ // The MFA switch is read before boot, by the template; off, it hands
829
+ // boot no `mfa` section.
830
+ expect(readShippedSwitches(DOCUMENTED_ENV).mfaMode).toBe("off");
831
+ expect(config).not.toHaveProperty("mfa");
832
+ expect(config).not.toHaveProperty("endpoints.mfa");
833
+ expect(readShippedSwitches(DOCUMENTED_ENV).adapters).toMatchObject({
834
+ mfaFactorStore: "redis",
835
+ mfaTransactionStore: "redis",
836
+ });
837
+ expect(sections["redis-mfa-factor-store"]?.keyPrefix).toBe("tenant-a:mfaf:");
838
+ expect(sections["redis-mfa-transaction-store"]?.keyPrefix).toBe("tenant-a:mfat:");
839
+ // A comma-separated string becomes a list of origins, trimmed.
840
+ expect(http.cors.allowedOrigins).toEqual(["https://app.example.com", "http://localhost:5173"]);
841
+ });
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
+ });
390
968
  });
391
969
 
392
- describe("CORS_ALLOWED_ORIGINS (#500)", () => {
393
- it("reads an exported-but-empty variable as no origins, not as an error", () => {
970
+ describe("HTTP_CORS_ALLOWED_ORIGINS", () => {
971
+ it("reads an exported-but-empty variable as no origins, not as an error", async () => {
394
972
  // The .env / compose / ConfigMap shape. "CORS off" is what both the
395
973
  // unset key and the empty string mean, so they must agree.
396
- const config = buildResolvedConfig({ ...DOCUMENTED_ENV, CORS_ALLOWED_ORIGINS: "" });
397
- expect(config.cors?.allowedOrigins).toEqual([]);
974
+ const config = await bootParsed(
975
+ { ...DOCUMENTED_ENV, HTTP_CORS_ALLOWED_ORIGINS: "" },
976
+ "production",
977
+ undefined,
978
+ [httpModule],
979
+ );
980
+ expect(httpSectionOf(config).cors.allowedOrigins).toEqual([]);
398
981
  });
399
982
 
400
- it("fails boot on an origin that could never match, naming the key", () => {
983
+ it("fails boot on an origin that could never match, naming the key", async () => {
401
984
  // Every one of these parses as a URL and is a real typo: matching is
402
985
  // exact string equality against the Origin header, so each would be
403
986
  // an allowlist that admits nobody with nothing to say so.
@@ -408,56 +991,102 @@ describe("#288: the shipped config boots with every documented override supplied
408
991
  "http://app.example.com", // plaintext off loopback
409
992
  "https://app.example.com/callback", // a URL, not an origin
410
993
  ]) {
411
- expect(() => buildResolvedConfig({ ...DOCUMENTED_ENV, CORS_ALLOWED_ORIGINS: bad })).toThrow(
412
- /cors\.allowedOrigins/,
413
- );
994
+ await expect(
995
+ bootParsed(
996
+ { ...DOCUMENTED_ENV, HTTP_CORS_ALLOWED_ORIGINS: bad },
997
+ "production",
998
+ undefined,
999
+ [httpModule],
1000
+ ),
1001
+ ).rejects.toThrow(/http\.cors\.allowedOrigins/);
414
1002
  }
415
1003
  });
416
1004
 
417
- it("accepts the loopback http carve-out a dev front-end needs", () => {
418
- const config = buildResolvedConfig({
419
- ...DOCUMENTED_ENV,
420
- CORS_ALLOWED_ORIGINS: "http://localhost:5173,http://127.0.0.1:5173,https://app.example.com",
421
- });
422
- expect(config.cors?.allowedOrigins).toHaveLength(3);
1005
+ it("fails boot on a value that is neither a list nor a string, naming the key", async () => {
1006
+ // The variable can only ever carry a string, so this shape comes
1007
+ // from a configuration file. Read as no origins, it would turn CORS
1008
+ // silently off for a key someone wrote.
1009
+ for (const value of ["42", "true", '{ origin = "https://app.example.com" }']) {
1010
+ await expect(
1011
+ bootParsed(DOCUMENTED_ENV, "production", `http.cors.allowedOrigins = ${value}`, [
1012
+ httpModule,
1013
+ ]),
1014
+ value,
1015
+ ).rejects.toThrow(/http\.cors\.allowedOrigins/);
1016
+ }
1017
+ });
1018
+
1019
+ it("accepts the loopback http carve-out a dev front-end needs", async () => {
1020
+ const config = await bootParsed(
1021
+ {
1022
+ ...DOCUMENTED_ENV,
1023
+ HTTP_CORS_ALLOWED_ORIGINS:
1024
+ "http://localhost:5173,http://127.0.0.1:5173,https://app.example.com",
1025
+ },
1026
+ "production",
1027
+ undefined,
1028
+ [httpModule],
1029
+ );
1030
+ expect(httpSectionOf(config).cors.allowedOrigins).toHaveLength(3);
423
1031
  });
424
1032
  });
425
1033
 
426
- it("parses the HS256 shape, whose strict union refuses asymmetric key fields", () => {
427
- // `OAUTH_JWT_ALGORITHM=HS256` is not a variation on the map above: the
428
- // HS256 member of the signingKey union is `.strict()`, so a deployment
429
- // that switches algorithm must also stop exporting the key-file
430
- // variables. Worth pinning — it is the umbrella E2E's shape.
431
- const { OAUTH_JWT_PRIVATE_KEY, OAUTH_JWT_PRIVATE_KEY_PATH, ...rest } = DOCUMENTED_ENV;
432
- void OAUTH_JWT_PRIVATE_KEY;
433
- void OAUTH_JWT_PRIVATE_KEY_PATH;
434
- const { OAUTH_JWT_PUBLIC_KEY, OAUTH_JWT_PUBLIC_KEY_PATH, ...hs256 } = rest;
435
- void OAUTH_JWT_PUBLIC_KEY;
436
- void OAUTH_JWT_PUBLIC_KEY_PATH;
437
- const config = buildResolvedConfig({ ...hs256, OAUTH_JWT_ALGORITHM: "HS256" });
438
- expect(config.oauth.jwt.signingKey.local?.algorithm).toBe("HS256");
1034
+ it("parses the HS256 shape, whose strict union refuses asymmetric key fields", async () => {
1035
+ // `KEY_STORE_LOCAL_ALGORITHM=HS256` is not a variation on the map above:
1036
+ // the HS256 member of the key store's `local` union is strict, so a
1037
+ // deployment that switches algorithm must also stop exporting the
1038
+ // key-file variables. Worth pinning — it is the umbrella E2E's shape.
1039
+ const { KEY_STORE_LOCAL_PRIVATE_KEY, KEY_STORE_LOCAL_PRIVATE_KEY_PATH, ...rest } =
1040
+ DOCUMENTED_ENV;
1041
+ void KEY_STORE_LOCAL_PRIVATE_KEY;
1042
+ void KEY_STORE_LOCAL_PRIVATE_KEY_PATH;
1043
+ const { KEY_STORE_LOCAL_PUBLIC_KEY, KEY_STORE_LOCAL_PUBLIC_KEY_PATH, ...hs256 } = rest;
1044
+ void KEY_STORE_LOCAL_PUBLIC_KEY;
1045
+ void KEY_STORE_LOCAL_PUBLIC_KEY_PATH;
1046
+ const config = await bootParsed(
1047
+ { ...hs256, KEY_STORE_LOCAL_ALGORITHM: "HS256" },
1048
+ "production",
1049
+ undefined,
1050
+ [keyStoreModule],
1051
+ );
1052
+ expect(
1053
+ (config as unknown as { "key-store": { local: { algorithm: string } } })["key-store"].local
1054
+ .algorithm,
1055
+ ).toBe("HS256");
439
1056
  });
440
1057
 
441
- it("parses the environment the umbrella E2E boots, with SESSION_SECURE=false as a string", () => {
442
- const config = buildResolvedConfig(UMBRELLA_E2E_ENV);
443
- expect(config.session.secure).toBe(false);
444
- expect(config.oauth.requireEmailVerified).toBe(true);
445
- expect(config.oauth.resourceIndicator?.enabled).toBe(true);
446
- expect(config.deployment?.mode).toBe("multi");
1058
+ it("parses the environment the umbrella E2E boots, with SESSION_STORE_SECURE=false as a string", async () => {
1059
+ const config = await bootParsed(UMBRELLA_E2E_ENV);
1060
+ expect(sessionStoreSection(config).secure).toBe(false);
1061
+ expect(oauthSection(config).requireEmailVerified).toBe(true);
1062
+ expect(oauthSection(config).resourceIndicator?.enabled).toBe(true);
1063
+ expect(config.core?.deployment?.mode).toBe("multi");
447
1064
  });
448
1065
 
449
1066
  it("wires nothing replica-unsafe for the umbrella E2E environment", async () => {
450
- // `DEPLOYMENT_MODE=multi` makes the provider audit its own store wiring
1067
+ // `CORE_DEPLOYMENT_MODE=multi` makes the provider audit its own store wiring
451
1068
  // at boot, so a config that parses but wires a memory store still fails
452
- // there. Ask each manifest, the way the guard does (#455): the exported
453
- // name list covers core's modules only, and the template's own memory
454
- // modules are precisely the ones it could not see.
1069
+ // there. Ask each manifest, the way the guard does: the exported name
1070
+ // list covers core's modules only, and cannot see the template's own
1071
+ // memory modules.
455
1072
  const { replicaUnsafeReason } = await import("@o3co/auth-provider-core");
456
- for (const module of buildModules(buildResolvedConfig(UMBRELLA_E2E_ENV))) {
457
- 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();
458
1081
  }
459
1082
  });
460
1083
 
1084
+ it("reads MFA_MODE=off before boot, where the shipped configuration expects no session requirement", async () => {
1085
+ expect(readShippedSwitches({ ...DOCUMENTED_ENV, MFA_MODE: "off" }).mfaMode).toBe("off");
1086
+ const parsed = await bootParsed({ ...DOCUMENTED_ENV, MFA_MODE: "off" });
1087
+ expect(parsed.core?.sessionRequirements).toEqual({ expected: [] });
1088
+ });
1089
+
461
1090
  describe("boolean overrides accept the spellings an operator writes", () => {
462
1091
  const cases: ReadonlyArray<[string, boolean]> = [
463
1092
  ["true", true],
@@ -466,53 +1095,50 @@ describe("#288: the shipped config boots with every documented override supplied
466
1095
  ["false", false],
467
1096
  ["0", false],
468
1097
  // An exported-but-empty variable — the `.env` / compose / ConfigMap
469
- // shape. Reads as false, matching what #292 decided for trustProxy.
1098
+ // shape. Reads as false, as it does for trustProxy.
470
1099
  ["", false],
471
1100
  ];
472
1101
 
473
1102
  for (const [supplied, expected] of cases) {
474
- it(`SESSION_SECURE=${JSON.stringify(supplied)} resolves to ${expected}`, () => {
475
- const config = buildResolvedConfig({
1103
+ it(`SESSION_STORE_SECURE=${JSON.stringify(supplied)} resolves to ${expected}`, async () => {
1104
+ const config = await bootParsed({
476
1105
  ...DOCUMENTED_ENV,
477
- SESSION_SECURE: supplied,
478
- SESSION_SAME_SITE: "lax",
1106
+ SESSION_STORE_SECURE: supplied,
1107
+ SESSION_STORE_SAME_SITE: "lax",
479
1108
  });
480
- expect(config.session.secure).toBe(expected);
1109
+ expect(sessionStoreSection(config).secure).toBe(expected);
481
1110
  });
482
1111
 
483
- it(`OAUTH_JWT_LEGACY_TYP_ACCEPT=${JSON.stringify(supplied)} resolves to ${expected}`, () => {
484
- const config = buildResolvedConfig({
1112
+ it(`OAUTH_JWT_LEGACY_TYP_ACCEPT=${JSON.stringify(supplied)} resolves to ${expected}`, async () => {
1113
+ const config = await bootParsed({
485
1114
  ...DOCUMENTED_ENV,
486
1115
  OAUTH_JWT_LEGACY_TYP_ACCEPT: supplied,
487
1116
  });
488
- expect(config.oauth.jwt.legacyTypAccept).toBe(expected);
1117
+ expect(oauthSection(config).jwt.legacyTypAccept).toBe(expected);
489
1118
  });
490
1119
  }
491
1120
 
492
- it("fails boot on a spelling it does not recognise rather than guessing", () => {
493
- expect(() => buildResolvedConfig({ ...DOCUMENTED_ENV, SESSION_SECURE: "ture" })).toThrow(
494
- /true.*false/s,
495
- );
1121
+ it("refuses a spelling it does not recognise rather than guessing", async () => {
1122
+ const config = await bootParsed({ ...DOCUMENTED_ENV, SESSION_STORE_SECURE: "ture" });
1123
+ expect(() => sessionStoreSection(config)).toThrow(/true.*false/s);
496
1124
  });
497
1125
 
498
- it("refuses SESSION_SAME_SITE=none unless SESSION_SECURE is on", () => {
499
- // The #282 guard reads the coerced value, so it has to keep firing
1126
+ it("refuses SESSION_STORE_SAME_SITE=none unless SESSION_STORE_SECURE is on", async () => {
1127
+ // The SameSite=None guard reads the coerced value, so it has to keep firing
500
1128
  // for the string form an environment variable actually delivers.
501
- expect(() =>
502
- buildResolvedConfig({
503
- ...DOCUMENTED_ENV,
504
- SESSION_SAME_SITE: "none",
505
- SESSION_SECURE: "false",
506
- }),
507
- ).toThrow(/SESSION_SECURE=true/);
508
- expect(() =>
509
- buildResolvedConfig({
510
- ...DOCUMENTED_ENV,
511
- SESSION_SAME_SITE: "none",
512
- SESSION_SECURE: "true",
513
- SESSION_NAME: "auth.session",
514
- }),
515
- ).not.toThrow();
1129
+ const insecure = await bootParsed({
1130
+ ...DOCUMENTED_ENV,
1131
+ SESSION_STORE_SAME_SITE: "none",
1132
+ SESSION_STORE_SECURE: "false",
1133
+ });
1134
+ expect(() => sessionStoreSection(insecure)).toThrow(/SESSION_STORE_SECURE=true/);
1135
+ const secure = await bootParsed({
1136
+ ...DOCUMENTED_ENV,
1137
+ SESSION_STORE_SAME_SITE: "none",
1138
+ SESSION_STORE_SECURE: "true",
1139
+ SESSION_STORE_NAME: "auth.session",
1140
+ });
1141
+ expect(sessionStoreSection(secure).sameSite).toBe("none");
516
1142
  });
517
1143
  });
518
1144
 
@@ -552,41 +1178,97 @@ describe("#288: the shipped config boots with every documented override supplied
552
1178
  });
553
1179
 
554
1180
  describe("variables whose documented behaviour is to fail boot", () => {
555
- it("still refuses OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS (#330 tombstone)", () => {
556
- expect(() =>
557
- buildResolvedConfig({
558
- ...DOCUMENTED_ENV,
559
- OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS: "true",
560
- }),
561
- ).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/);
562
1188
  });
563
1189
 
564
- it("refuses an access-token default above the max, naming both keys", () => {
565
- expect(() =>
566
- buildResolvedConfig({
1190
+ it("refuses an access-token default above the max, naming both keys", async () => {
1191
+ await expect(
1192
+ bootParsed({
567
1193
  ...DOCUMENTED_ENV,
568
1194
  OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "7200",
569
1195
  OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN: "3600",
570
1196
  }),
571
- ).toThrow(/defaultExpiresIn.*maxExpiresIn/s);
1197
+ ).rejects.toThrow(/defaultExpiresIn.*maxExpiresIn/s);
572
1198
  });
573
1199
 
574
1200
  for (const name of [
575
1201
  "OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
576
1202
  "OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN",
577
- "OAUTH_ACCESS_TOKEN_EXPIRES_IN",
578
1203
  ]) {
579
- it(`refuses an empty ${name} rather than minting already-expired tokens`, () => {
580
- expect(() => buildResolvedConfig({ ...DOCUMENTED_ENV, [name]: "" })).toThrow();
1204
+ it(`refuses an empty ${name} rather than minting already-expired tokens`, async () => {
1205
+ await expect(bootParsed({ ...DOCUMENTED_ENV, [name]: "" })).rejects.toMatchObject({
1206
+ reason: "config-validation-failed",
1207
+ });
581
1208
  });
582
1209
  }
583
1210
 
584
- it("still refuses an empty SESSION_CSRF_TTL_SECONDS (#272)", () => {
1211
+ it("refuses MFA_MODE that is none of the three before boot, naming mfaMode", async () => {
1212
+ const env = { ...DOCUMENTED_ENV, MFA_MODE: "on" };
1213
+ expect(() => readShippedSwitches(env)).toThrow(BootError);
1214
+ await expect(bootParsed(env)).rejects.toThrow(/mfaMode/);
1215
+ });
1216
+
1217
+ for (const mode of ["optional", "required"] as const) {
1218
+ it(`reads MFA_MODE=${mode} as the switch that installs MFA, expecting mfa`, () => {
1219
+ const own = readOwnLayers(ownFiles("production"), {
1220
+ env: { ...DOCUMENTED_ENV, MFA_MODE: mode },
1221
+ });
1222
+ const switches = readSwitches(own);
1223
+ expect(switches.mfaMode).toBe(mode);
1224
+ const shipped = (resolveLayers(own, []).core as { sessionRequirements?: unknown })
1225
+ .sessionRequirements;
1226
+ expect(expectedSessionRequirements(shipped, switches.mfaMode)).toEqual({
1227
+ expected: ["mfa"],
1228
+ secondFactorAuthority: "mfa",
1229
+ });
1230
+ });
1231
+ }
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
+
1260
+ it("refuses DEPLOYMENT_MODE set alone, naming CORE_DEPLOYMENT_MODE", async () => {
1261
+ const { CORE_DEPLOYMENT_MODE: _new, ...env } = DOCUMENTED_ENV;
1262
+ await expect(bootParsed({ ...env, DEPLOYMENT_MODE: "multi" })).rejects.toThrow(
1263
+ /DEPLOYMENT_MODE was renamed CORE_DEPLOYMENT_MODE/,
1264
+ );
1265
+ });
1266
+
1267
+ it("still refuses an empty SESSION_CSRF_TTL_SECONDS", async () => {
585
1268
  // Pinned alongside the boolean cases because it is the same trap
586
1269
  // read from the other side: for a *number*, empty means fail loudly.
587
- expect(() =>
588
- buildResolvedConfig({ ...DOCUMENTED_ENV, SESSION_CSRF_TTL_SECONDS: "" }),
589
- ).toThrow();
1270
+ const config = await bootParsed({ ...DOCUMENTED_ENV, SESSION_CSRF_TTL_SECONDS: "" });
1271
+ expect(() => sessionSection(config)).toThrow(/ttlSeconds/);
590
1272
  });
591
1273
  });
592
1274
 
@@ -595,7 +1277,7 @@ describe("#288: the shipped config boots with every documented override supplied
595
1277
  // artifact these tests would pass while testing nothing. Checked by
596
1278
  // what the directory holds, not by what it is called: this file ships
597
1279
  // in every scaffolded project, and none of those lives at
598
- // `templates/standalone` (#512).
1280
+ // `templates/standalone`.
599
1281
  for (const file of ["config/application.conf", "config/production.conf", "src/app.mts"]) {
600
1282
  expect(existsSync(join(standaloneDir, file)), file).toBe(true);
601
1283
  }