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

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 (114) hide show
  1. package/README.md +2 -198
  2. package/main.js +4 -0
  3. package/package.json +8 -30
  4. package/README.ja.md +0 -200
  5. package/dist/cli.d.mts +0 -3
  6. package/dist/cli.d.mts.map +0 -1
  7. package/dist/cli.mjs +0 -18
  8. package/dist/index.d.mts +0 -48
  9. package/dist/index.d.mts.map +0 -1
  10. package/dist/index.mjs +0 -325
  11. package/dist/internal/template-filter.d.mts +0 -14
  12. package/dist/internal/template-filter.d.mts.map +0 -1
  13. package/dist/internal/template-filter.mjs +0 -42
  14. package/templates/standalone/.dockerignore +0 -23
  15. package/templates/standalone/.env.example +0 -152
  16. package/templates/standalone/Dockerfile +0 -125
  17. package/templates/standalone/Makefile +0 -12
  18. package/templates/standalone/README.ja.md +0 -912
  19. package/templates/standalone/README.md +0 -1536
  20. package/templates/standalone/config/application.conf +0 -351
  21. package/templates/standalone/config/clients.yaml +0 -4
  22. package/templates/standalone/config/clients.yaml.example +0 -124
  23. package/templates/standalone/config/development.conf +0 -17
  24. package/templates/standalone/config/mailpit.conf +0 -13
  25. package/templates/standalone/config/production.conf +0 -15
  26. package/templates/standalone/config/reference.conf +0 -427
  27. package/templates/standalone/docker-compose.mailpit.yml +0 -39
  28. package/templates/standalone/docker-compose.production.yml +0 -229
  29. package/templates/standalone/docker-compose.test.yml +0 -16
  30. package/templates/standalone/docker-compose.yml +0 -67
  31. package/templates/standalone/gitignore +0 -23
  32. package/templates/standalone/package.json +0 -56
  33. package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
  34. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
  35. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
  36. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
  37. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
  38. package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
  39. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
  40. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  41. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  42. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  43. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  44. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  45. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  46. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
  47. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
  48. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
  49. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  50. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  51. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
  52. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
  53. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  54. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  55. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  56. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
  57. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  58. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  59. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  60. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  61. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  62. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  63. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  64. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  65. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  66. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
  67. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  68. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
  69. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  70. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  71. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
  72. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  73. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
  74. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  75. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  76. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  77. package/templates/standalone/src/__tests__/sections.test.mts +0 -516
  78. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
  79. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
  80. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  81. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  82. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  83. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  84. package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
  85. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
  86. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
  87. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  88. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  89. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  90. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
  91. package/templates/standalone/src/adapters.mts +0 -162
  92. package/templates/standalone/src/app.mts +0 -127
  93. package/templates/standalone/src/bootRefusal.mts +0 -117
  94. package/templates/standalone/src/buildModules.mts +0 -358
  95. package/templates/standalone/src/configPath.mts +0 -487
  96. package/templates/standalone/src/listen.mts +0 -60
  97. package/templates/standalone/src/logger.mts +0 -80
  98. package/templates/standalone/src/metrics.mts +0 -167
  99. package/templates/standalone/src/mfaSwitch.mts +0 -284
  100. package/templates/standalone/src/modules.mts +0 -705
  101. package/templates/standalone/src/rootRenames.mts +0 -95
  102. package/templates/standalone/src/routes.mts +0 -89
  103. package/templates/standalone/src/sections.mts +0 -378
  104. package/templates/standalone/src/securityHeaders.mts +0 -36
  105. package/templates/standalone/src/shutdown.mts +0 -213
  106. package/templates/standalone/tests/Makefile +0 -5
  107. package/templates/standalone/tests/index.test.js +0 -283
  108. package/templates/standalone/tests/package-lock.json +0 -1617
  109. package/templates/standalone/tests/package.json +0 -16
  110. package/templates/standalone/tests/vitest.config.mjs +0 -26
  111. package/templates/standalone/tsconfig.json +0 -31
  112. package/templates/standalone/vitest.config.mts +0 -31
  113. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  114. package/templates/versions.json +0 -20
@@ -1,1291 +0,0 @@
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
- import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
18
- import { tmpdir } from "node:os";
19
- import { join } from "node:path";
20
- import { fileURLToPath } from "node:url";
21
- import {
22
- type AppConfig,
23
- BootError,
24
- createApp,
25
- defineModule,
26
- type Module,
27
- moduleReferences,
28
- resolveAccessTokenLifetime,
29
- } from "@o3co/auth-provider-core";
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";
36
- import { describe, expect, it } from "vitest";
37
- import { buildModules } from "../buildModules.mjs";
38
- import {
39
- expectedSessionRequirements,
40
- readOwnLayers,
41
- readSwitches,
42
- resolveConfigPaths,
43
- resolveForBoot,
44
- resolveLayers,
45
- type Switches,
46
- } from "../configPath.mjs";
47
- import {
48
- httpModule,
49
- keyStoreModule,
50
- standaloneRedisClientsModule,
51
- templateReference,
52
- } from "../modules.mjs";
53
-
54
- /**
55
- * Boots the shipped config with EVERY documented override supplied the way an
56
- * operator supplies one: as a string.
57
- *
58
- * HOCON substitutes `${?VAR}` as a string, always. `app.mts` reads its own
59
- * switches with the template's schema and hands `createApp` what it resolved,
60
- * which boot parses once with plain Zod, so every schema leaf must read the
61
- * string itself. This suite reads through that path: `readSwitches` for what
62
- * is read before boot, `resolveForBoot` and `createApp` for the configuration
63
- * boot parsed.
64
- *
65
- * It is not a sample of interesting variables: `covers every documented
66
- * override` below fails when a `${?VAR}` is added to either config layer, or
67
- * documented in the README, without being exercised here.
68
- */
69
-
70
- // config/ is two levels above this test file: src/__tests__/ → src/ → standalone/
71
- const standaloneDir = fileURLToPath(new URL("../..", import.meta.url));
72
- const configDir = fileURLToPath(new URL("../../config", import.meta.url));
73
- const readmePath = fileURLToPath(new URL("../../README.md", import.meta.url));
74
- const readmeJaPath = fileURLToPath(new URL("../../README.ja.md", import.meta.url));
75
-
76
- /**
77
- * Every environment variable the shipped artifact documents, as the string an
78
- * operator would supply.
79
- *
80
- * `KEY_STORE_LOCAL_ALGORITHM` is `EdDSA` so the asymmetric key variables fit in the
81
- * same map: the HS256 branch of `signingKey.local` is a `.strict()`
82
- * discriminated-union member and refuses `privateKeyPath` and friends by
83
- * design. The HS256 shape gets its own test below.
84
- */
85
- const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
86
- // --- http ---------------------------------------------------------
87
- HTTP_PORT: "3000",
88
- HTTP_TRUST_PROXY: "10.0.0.0/8,loopback",
89
- HTTP_READINESS_TIMEOUT_MS: "1500",
90
-
91
- // --- logging ------------------------------------------------------
92
- LOGGING_LEVEL: "debug",
93
-
94
- // --- oauth.jwt ----------------------------------------------------
95
- OAUTH_JWT_ISSUER: "https://auth.test",
96
- KEY_STORE_PROVIDER: "local",
97
- KEY_STORE_LOCAL_ALGORITHM: "EdDSA",
98
- KEY_STORE_LOCAL_KID: "v1",
99
- KEY_STORE_LOCAL_SECRET: "documented-env-secret.at-least-32-bytes.ok",
100
- KEY_STORE_LOCAL_PRIVATE_KEY: "-----BEGIN PRIVATE KEY-----\nMC4=\n-----END PRIVATE KEY-----",
101
- KEY_STORE_LOCAL_PRIVATE_KEY_PATH: "./config/jwt-private.pem",
102
- KEY_STORE_LOCAL_PUBLIC_KEY: "-----BEGIN PUBLIC KEY-----\nMCo=\n-----END PUBLIC KEY-----",
103
- KEY_STORE_LOCAL_PUBLIC_KEY_PATH: "./config/jwt-public.pem",
104
-
105
- // --- oauth tokens / policy ----------------------------------------
106
- OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "900",
107
- OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN: "7200",
108
- OAUTH_REFRESH_TOKEN_EXPIRES_IN: "86400",
109
- OAUTH_OIDC_MODE: "dual",
110
- OAUTH_REVOCATION_ACCESS_TOKEN: "denylist",
111
- OAUTH_REVOCATION_SUBJECT: "unsupported",
112
- OAUTH_REQUIRE_EMAIL_VERIFIED: "true",
113
- OAUTH_REQUIRE_GRANT_TYPE_ALLOWLIST: "true",
114
- OAUTH_NONCE_MAX_LENGTH: "256",
115
- OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
116
- OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED: "true",
117
- OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_SCOPES: "read, write",
118
- OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_AUDIENCES: "https://mcp.example",
119
- OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_HOSTS: "client.example, .trusted.example",
120
- OAUTH_CLIENT_ID_METADATA_DOCUMENTS_DENIED_HOSTS: "evil.example",
121
- OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_BYTES: "8192",
122
- OAUTH_CLIENT_ID_METADATA_DOCUMENTS_TIMEOUT_MS: "3000",
123
- OAUTH_CLIENT_ID_METADATA_DOCUMENTS_CACHE_MAX_AGE_MS: "60000",
124
- OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CACHE_ENTRIES: "128",
125
- OAUTH_CLIENT_ID_METADATA_DOCUMENTS_STALE_IF_ERROR_MS: "120000",
126
- OAUTH_CLIENT_ID_METADATA_DOCUMENTS_NEGATIVE_CACHE_MS: "30000",
127
- OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CONCURRENT_FETCHES: "4",
128
- CORE_TOKEN_BINDING_DISPATCH_POLICY: "intent-explicit",
129
- CORE_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS: "true",
130
- CORE_OUTBOUND_ALLOWED_HOSTS: "rp.example, .partner.example",
131
- CORE_OUTBOUND_DENIED_HOSTS: "blocked.example",
132
- CORE_OUTBOUND_INTERNAL_HOSTS: ".corp.internal",
133
- CORE_OUTBOUND_TIMEOUT_MS: "3000",
134
- CORE_OUTBOUND_MAX_RESPONSE_BYTES: "32768",
135
- CORE_OUTBOUND_EGRESS: "direct",
136
-
137
- // --- the grant switches ------------------------------------------
138
- OAUTH_SESSION_ENABLED: "false",
139
- OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED: "true",
140
- OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED: "true",
141
- OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED: "true",
142
- OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED: "false",
143
-
144
- // --- session: the login routes -------------------------------------
145
- SESSION_CSRF_TTL_SECONDS: "7200",
146
-
147
- // --- session-store: the session cookie and its store ---------------
148
- SESSION_STORE_SECRET: "documented-env-session-secret.at-least-32-bytes.ok",
149
- SESSION_STORE_NAME: "auth.session",
150
- SESSION_STORE_MAX_AGE: "3600000",
151
- SESSION_STORE_SECURE: "false",
152
- SESSION_STORE_SAME_SITE: "lax",
153
- SESSION_STORE_DOMAIN: "auth.example.com",
154
- SESSION_STORE_STORAGE_TYPE: "redis",
155
- SESSION_STORE_STORAGE_REDIS_URL: "redis://redis:6379",
156
- SESSION_STORE_STORAGE_REDIS_PASSWORD: "session-store-password",
157
-
158
- // --- rate limiting ------------------------------------------------
159
- REDIS_RATE_LIMITER_FAIL_MODE: "open",
160
- CORE_RATE_LIMITER_MEMORY_MAX_BUCKETS: "10000",
161
-
162
- // --- adapters -----------------------------------------------------
163
- // Which adapter fills each slot: the composition root's own section,
164
- // read before the modules are chosen.
165
- ADAPTERS_RATE_LIMITER: "redis",
166
- ADAPTERS_ATTEMPT_COUNTER: "redis",
167
- ADAPTERS_USER_SESSION_STORES: "redis",
168
- ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
169
- // The replay seen-set behind private_key_jwt client authentication.
170
- ADAPTERS_REPLAY_SEEN_SET: "redis",
171
- ADAPTERS_CONSENT_STORE: "redis",
172
- ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
173
- ADAPTERS_FEDERATION_GRANT_STORE: "redis",
174
- ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "redis",
175
- ADAPTERS_MFA_FACTOR_STORE: "redis",
176
- ADAPTERS_MFA_TRANSACTION_STORE: "redis",
177
- ADAPTERS_CODE_REPOSITORY: "redis",
178
- ADAPTERS_CLIENT_REPOSITORY: "yaml",
179
- ADAPTERS_USER_REPOSITORY: "yaml",
180
- // Selects the sink builder; "console" is the registered builtin. There
181
- // is deliberately no "none" — an unknown sink fails boot in buildModules
182
- // (pinned by audit-sink.test.mts), not in phase one, because the schema
183
- // keeps `adapters.auditSink` an open string so out-of-tree sinks need no
184
- // schema change here.
185
- ADAPTERS_AUDIT_SINK: "console",
186
-
187
- // --- JWKS ---------------------------------------------------------
188
- JWKS_PATH: "/keys/jwks.json",
189
- JWKS_CACHE_MAX_AGE: "600",
190
-
191
- // --- shared stores ------------------------------------------------
192
- CORE_DEPLOYMENT_MODE: "multi",
193
- // The consent stores' namespace.
194
- REDIS_CONSENT_STORE_KEY_PREFIX: "tenant-a:consent:",
195
- REDIS_ACCESS_TOKEN_DENYLIST_KEY_PREFIX: "atdeny:",
196
- REDIS_SESSION_STORES_KEY_PREFIX: "ss:",
197
- REDIS_REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX: "rtfam:",
198
- REDIS_REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT: "3",
199
- REDIS_CLIENTS_URL: "redis://redis:6379",
200
- REDIS_CLIENTS_PASSWORD: "rt-family-password",
201
- REDIS_CLIENTS_ASSUME_NO_EVICTION: "true",
202
- // The federation token store's Redis branch.
203
- REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX: "ft:",
204
- REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE: "required",
205
- REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
206
- // Federation grants: a user's standing consent that a client may obtain
207
- // upstream tokens without them. The overrides `reference.conf` declares.
208
- FEDERATION_GRANTS_ENABLED: "true",
209
- REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE: "required",
210
- FEDERATION_GRANTS_ALLOW_KEEP_ON_SUBJECT_REVOCATION: "false",
211
- // Acquisition's two deployment decisions — whether the callback
212
- // refuses an upstream account linked to another user, and the consent page.
213
- FEDERATION_GRANTS_IDENTITY_LOOKUP: "required",
214
- FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
215
- REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX: "fg:",
216
- // The intent store's own namespace, which a deployment moves with the grant store's.
217
- REDIS_FEDERATION_GRANT_INTENT_STORE_KEY_PREFIX: "fg:",
218
-
219
- // --- multi-factor authentication ----------------------------------
220
- // The template's switch, `mfaMode`: off here, so the parse below
221
- // layers nothing of MFA. The rest is what the switch installs — the MFA
222
- // package's settings, foundation's Store-backed factor store's — and is
223
- // covered by the substitutions it brings (`liveSubstitutions`).
224
- MFA_MODE: "off",
225
- MFA_ENCRYPTION_KEY: "CQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQkJCQk=",
226
- MFA_PAGE_URL: "/mfa",
227
- MFA_STORE_TIMEOUT_MS: "5000",
228
- MFA_ENROLLMENT_REQUIRE_EMAIL_PROOF: "when-mail",
229
- MFA_TOTP_FACTOR_ENABLED: "true",
230
- MFA_TOTP_FACTOR_ALGORITHM: "SHA1",
231
- MFA_TOTP_FACTOR_DIGITS: "6",
232
- MFA_TOTP_FACTOR_PERIOD: "30",
233
- MFA_TOTP_FACTOR_WINDOW: "1",
234
- MFA_TOTP_FACTOR_ISSUER: "auth.test",
235
- MFA_RECOVERY_CODE_FACTOR_ENABLED: "true",
236
- MFA_RECOVERY_CODE_FACTOR_COUNT: "10",
237
- MFA_EMAIL_FACTOR_ENABLED: "false",
238
- MFA_EMAIL_FACTOR_ADDS_MFA: "false",
239
- MFA_EMAIL_FACTOR_CODE_TTL_SECONDS: "600",
240
- FOUNDATION_MFA_FACTOR_STORE_LIST_URL: "https://users.example.com/mfa/factors/list",
241
- FOUNDATION_MFA_FACTOR_STORE_CREATE_URL: "https://users.example.com/mfa/factors/create",
242
- FOUNDATION_MFA_FACTOR_STORE_UPDATE_URL: "https://users.example.com/mfa/factors/update",
243
- FOUNDATION_MFA_FACTOR_STORE_DELETE_URL: "https://users.example.com/mfa/factors/delete",
244
-
245
- // --- mail ---------------------------------------------------------
246
- // The SMTP sender's section, which the template installs outside
247
- // development and builds only where something sends.
248
- STANDARD_SMTP_MAIL_SENDER_HOST: "smtp.example.com",
249
- STANDARD_SMTP_MAIL_SENDER_PORT: "587",
250
- STANDARD_SMTP_MAIL_SENDER_SECURE: "starttls",
251
- STANDARD_SMTP_MAIL_SENDER_USER: "mailer",
252
- STANDARD_SMTP_MAIL_SENDER_PASSWORD: "mailer-password",
253
- STANDARD_SMTP_MAIL_SENDER_FROM: "auth@example.com",
254
- // …and the Redis stores' key namespaces, which the Redis package's two MFA
255
- // modules read.
256
- REDIS_MFA_FACTOR_STORE_KEY_PREFIX: "tenant-a:mfaf:",
257
- REDIS_MFA_TRANSACTION_STORE_KEY_PREFIX: "tenant-a:mfat:",
258
-
259
- // --- federation ---------------------------------------------------
260
- CORE_FEDERATIONS_GOOGLE_ENABLED: "true",
261
- CORE_FEDERATIONS_GOOGLE_CLIENT_ID: "google-client-id",
262
- CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-client-secret",
263
- CORE_FEDERATIONS_GOOGLE_CALLBACK_URL:
264
- "https://auth.test/session/oauth/federation/google/callback",
265
- CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE: "online",
266
- // The generic OIDC federation the template ships disabled.
267
- CORE_FEDERATIONS_OIDC_ENABLED: "true",
268
- CORE_FEDERATIONS_OIDC_ISSUER: "https://idp.test",
269
- CORE_FEDERATIONS_OIDC_CLIENT_ID: "oidc-client-id",
270
- CORE_FEDERATIONS_OIDC_CLIENT_SECRET: "oidc-client-secret",
271
- CORE_FEDERATIONS_OIDC_CALLBACK_URL: "https://auth.test/session/oauth/federation/oidc/callback",
272
-
273
- // --- repositories -------------------------------------------------
274
- REPOSITORIES_CLIENT_YAML_PATH: "./config/clients.yaml",
275
- REPOSITORIES_USER_YAML_PATH: "./config/users.yaml",
276
- REPOSITORIES_USER_HTTP_AUTHENTICATE_URL: "https://users.example.com/authenticate",
277
- REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL:
278
- "https://users.example.com/authenticate-by-token",
279
- REPOSITORIES_USER_HTTP_LINK_FEDERATED_IDENTITY_URL:
280
- "https://users.example.com/link-federated-identity",
281
- // The Store's identity lookup for federation grants (see ADR
282
- // 2026-09-17-federation-grants-offline-delegation).
283
- REPOSITORIES_USER_HTTP_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL:
284
- "https://users.example.com/find-subject-by-federated-identity",
285
- // The Store's MFA enrollment witness endpoint (the MFA ADR's D12).
286
- REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL: "https://users.example.com/mfa/enrolled",
287
- REPOSITORIES_USER_HTTP_TIMEOUT: "5000",
288
- REPOSITORIES_USER_HTTP_MAX_RESPONSE_BYTES: "1048576",
289
- // The credential the http user adapter presents to the Store; >= 32 bytes.
290
- REPOSITORIES_USER_HTTP_BEARER_TOKEN:
291
- "0328d706529061d93abd6d826e09ef0f0a1e71a12af813b29e5cd2977b7dc63a",
292
- // The authorization-code repositories: the Redis one (selected above) and
293
- // the in-process one.
294
- REDIS_CODE_REPOSITORY_DEFAULT_EXPIRES_IN: "600",
295
- REDIS_CODE_REPOSITORY_KEY_PREFIX: "oauth:code:",
296
- STANDALONE_IN_MEMORY_CODE_REPOSITORY_DEFAULT_EXPIRES_IN: "600",
297
-
298
- // --- the login and consent pages ----------------------------------
299
- SESSION_LOGIN_PAGE_URL: "/login",
300
- OAUTH_CONSENT_PAGE_URL: "/consent",
301
-
302
- // --- cors ---------------------------------------------------------
303
- // A list, in the only shape an environment variable can carry one.
304
- // The schema splits and validates it; the `turns every non-boolean
305
- // override into its declared type` case below pins the result.
306
- HTTP_CORS_ALLOWED_ORIGINS: "https://app.example.com,http://localhost:5173",
307
- };
308
-
309
- /**
310
- * Substitutions that must NOT appear in `DOCUMENTED_ENV`, and why. Each one is
311
- * a variable whose documented behaviour is to *fail* boot, so setting it in
312
- * the all-overrides case would assert the opposite of the contract.
313
- */
314
- const DELIBERATELY_UNSET: Readonly<Record<string, string>> = {
315
- OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS:
316
- "the variable of a removed key, and only captured — any value fails boot",
317
- OAUTH_JWT_LEGACY_TYP_ACCEPT:
318
- "the variable of a removed key, and only captured — any value fails boot",
319
- OAUTH_ACCESS_TOKEN_EXPIRES_IN:
320
- "renamed OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN, and only captured — set at any value, beside its new name or not, it fails boot",
321
- DEPLOYMENT_MODE:
322
- "renamed CORE_DEPLOYMENT_MODE, and only captured — set at any value, beside its new name or not, it fails boot",
323
- MEMORY_RATE_LIMITER_MAX_BUCKETS:
324
- "renamed CORE_RATE_LIMITER_MEMORY_MAX_BUCKETS, and only captured — set at any value, beside its new name or not, it fails boot",
325
- RATE_LIMIT_FAIL_MODE:
326
- "renamed REDIS_RATE_LIMITER_FAIL_MODE, and only captured — set at any value, beside its new name or not, it fails boot",
327
- REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX:
328
- "renamed REDIS_REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX, and only captured — set at any value, beside its new name or not, it fails boot",
329
- REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT:
330
- "renamed REDIS_REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT, and only captured — set at any value, beside its new name or not, it fails boot",
331
- FEDERATION_GRANTS_ENCRYPTION_MODE:
332
- "renamed REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE, and only captured — set at any value, beside its new name or not, it fails boot",
333
- OAUTH_TOKEN_BINDING_DISPATCH_POLICY:
334
- "renamed CORE_TOKEN_BINDING_DISPATCH_POLICY, and only captured — set at any value, beside its new name or not, it fails boot",
335
- OAUTH_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS:
336
- "renamed CORE_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS, and only captured — set at any value, beside its new name or not, it fails boot",
337
- OAUTH_GRANTS_SESSION_ENABLED:
338
- "renamed OAUTH_SESSION_ENABLED, and only captured — set at any value, beside its new name or not, it fails boot",
339
- OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED:
340
- "renamed OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED, and only captured — set at any value, beside its new name or not, it fails boot",
341
- OAUTH_GRANTS_REFRESH_TOKEN_ENABLED:
342
- "renamed OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED, and only captured — set at any value, beside its new name or not, it fails boot",
343
- OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY:
344
- "the variable of a removed key, and only captured — any value fails boot",
345
- OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY:
346
- "the variable of a removed key, and only captured — any value fails boot",
347
- OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED:
348
- "renamed OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED, and only captured — set at any value, beside its new name or not, it fails boot",
349
- OAUTH_GRANTS_JWT_BEARER_ENABLED:
350
- "renamed OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED, and only captured — set at any value, beside its new name or not, it fails boot",
351
- ENDPOINTS_CONSENT_URL:
352
- "renamed OAUTH_CONSENT_PAGE_URL, and only captured — set at any value, beside its new name or not, it fails boot",
353
- OAUTH_CIMD_ENABLED:
354
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED, and only captured — set at any value, beside its new name or not, it fails boot",
355
- OAUTH_CIMD_ALLOWED_SCOPES:
356
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_SCOPES, and only captured — set at any value, beside its new name or not, it fails boot",
357
- OAUTH_CIMD_ALLOWED_AUDIENCES:
358
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_AUDIENCES, and only captured — set at any value, beside its new name or not, it fails boot",
359
- OAUTH_CIMD_ALLOWED_HOSTS:
360
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_HOSTS, and only captured — set at any value, beside its new name or not, it fails boot",
361
- OAUTH_CIMD_DENIED_HOSTS:
362
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_DENIED_HOSTS, and only captured — set at any value, beside its new name or not, it fails boot",
363
- OAUTH_CIMD_MAX_BYTES:
364
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_BYTES, and only captured — set at any value, beside its new name or not, it fails boot",
365
- OAUTH_CIMD_TIMEOUT_MS:
366
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_TIMEOUT_MS, and only captured — set at any value, beside its new name or not, it fails boot",
367
- OAUTH_CIMD_CACHE_MAX_AGE_MS:
368
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_CACHE_MAX_AGE_MS, and only captured — set at any value, beside its new name or not, it fails boot",
369
- OAUTH_CIMD_MAX_CACHE_ENTRIES:
370
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CACHE_ENTRIES, and only captured — set at any value, beside its new name or not, it fails boot",
371
- OAUTH_CIMD_STALE_IF_ERROR_MS:
372
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_STALE_IF_ERROR_MS, and only captured — set at any value, beside its new name or not, it fails boot",
373
- OAUTH_CIMD_NEGATIVE_CACHE_MS:
374
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_NEGATIVE_CACHE_MS, and only captured — set at any value, beside its new name or not, it fails boot",
375
- OAUTH_CIMD_MAX_CONCURRENT_FETCHES:
376
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CONCURRENT_FETCHES, and only captured — set at any value, beside its new name or not, it fails boot",
377
- OAUTH_GRANTS_AUTHORIZATION_CODE_PKCE_REQUIRE_S256:
378
- "the authorization-code grant's pkce block was removed, and this is only captured — set at all, it fails boot",
379
- ENDPOINTS_LOGIN_URL:
380
- "renamed SESSION_LOGIN_PAGE_URL, and only captured — set at any value, beside its new name or not, it fails boot",
381
- SESSION_SECRET:
382
- "renamed SESSION_STORE_SECRET, and only captured — set at any value, beside its new name or not, it fails boot",
383
- SESSION_NAME:
384
- "renamed SESSION_STORE_NAME, and only captured — set at any value, beside its new name or not, it fails boot",
385
- SESSION_MAX_AGE:
386
- "renamed SESSION_STORE_MAX_AGE, and only captured — set at any value, beside its new name or not, it fails boot",
387
- SESSION_SECURE:
388
- "renamed SESSION_STORE_SECURE, and only captured — set at any value, beside its new name or not, it fails boot",
389
- SESSION_SAME_SITE:
390
- "renamed SESSION_STORE_SAME_SITE, and only captured — set at any value, beside its new name or not, it fails boot",
391
- SESSION_DOMAIN:
392
- "renamed SESSION_STORE_DOMAIN, and only captured — set at any value, beside its new name or not, it fails boot",
393
- SESSION_STORAGE_TYPE:
394
- "renamed SESSION_STORE_STORAGE_TYPE, and only captured — set at any value, beside its new name or not, it fails boot",
395
- SESSION_STORAGE_REDIS_URL:
396
- "renamed SESSION_STORE_STORAGE_REDIS_URL, and only captured — set at any value, beside its new name or not, it fails boot",
397
- SESSION_STORAGE_REDIS_PASSWORD:
398
- "renamed SESSION_STORE_STORAGE_REDIS_PASSWORD, and only captured — set at any value, beside its new name or not, it fails boot",
399
- LOG_LEVEL:
400
- "renamed LOGGING_LEVEL, and only captured — set at any value, beside its new name or not, it fails boot",
401
- CORS_ALLOWED_ORIGINS:
402
- "renamed HTTP_CORS_ALLOWED_ORIGINS, and only captured — set at any value, beside its new name or not, it fails boot",
403
- OAUTH_JWT_SIGNING_KEY_PROVIDER:
404
- "renamed KEY_STORE_PROVIDER, and only captured — set at any value, beside its new name or not, it fails boot",
405
- OAUTH_JWT_ALGORITHM:
406
- "renamed KEY_STORE_LOCAL_ALGORITHM, and only captured — set at any value, beside its new name or not, it fails boot",
407
- OAUTH_JWT_KID:
408
- "renamed KEY_STORE_LOCAL_KID, and only captured — set at any value, beside its new name or not, it fails boot",
409
- OAUTH_JWT_SECRET:
410
- "renamed KEY_STORE_LOCAL_SECRET, and only captured — set at any value, beside its new name or not, it fails boot",
411
- OAUTH_JWT_PRIVATE_KEY_PATH:
412
- "renamed KEY_STORE_LOCAL_PRIVATE_KEY_PATH, and only captured — set at any value, beside its new name or not, it fails boot",
413
- OAUTH_JWT_PUBLIC_KEY_PATH:
414
- "renamed KEY_STORE_LOCAL_PUBLIC_KEY_PATH, and only captured — set at any value, beside its new name or not, it fails boot",
415
- OAUTH_JWT_PRIVATE_KEY:
416
- "renamed KEY_STORE_LOCAL_PRIVATE_KEY, and only captured — set at any value, beside its new name or not, it fails boot",
417
- OAUTH_JWT_PUBLIC_KEY:
418
- "renamed KEY_STORE_LOCAL_PUBLIC_KEY, and only captured — set at any value, beside its new name or not, it fails boot",
419
- REFRESH_TOKEN_FAMILY_STORE_REDIS_URL:
420
- "renamed REDIS_CLIENTS_URL, and only captured — set at any value, beside its new name or not, it fails boot",
421
- REFRESH_TOKEN_FAMILY_STORE_REDIS_PASSWORD:
422
- "renamed REDIS_CLIENTS_PASSWORD, and only captured — set at any value, beside its new name or not, it fails boot",
423
- CLIENT_PATH:
424
- "renamed REPOSITORIES_CLIENT_YAML_PATH, and only captured — set at any value, beside its new name or not, it fails boot",
425
- CLIENT_USER_PATH:
426
- "renamed REPOSITORIES_USER_YAML_PATH, and only captured — set at any value, beside its new name or not, it fails boot",
427
- CLIENT_USER_AUTHENTICATE_URL:
428
- "renamed REPOSITORIES_USER_HTTP_AUTHENTICATE_URL, and only captured — set at any value, beside its new name or not, it fails boot",
429
- CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL:
430
- "renamed REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL, and only captured — set at any value, beside its new name or not, it fails boot",
431
- CLIENT_USER_LINK_FEDERATED_IDENTITY_URL:
432
- "renamed REPOSITORIES_USER_HTTP_LINK_FEDERATED_IDENTITY_URL, and only captured — set at any value, beside its new name or not, it fails boot",
433
- CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL:
434
- "renamed REPOSITORIES_USER_HTTP_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL, and only captured — set at any value, beside its new name or not, it fails boot",
435
- CLIENT_USER_BEARER_TOKEN:
436
- "renamed REPOSITORIES_USER_HTTP_BEARER_TOKEN, and only captured — set at any value, beside its new name or not, it fails boot",
437
- CLIENT_USER_TIMEOUT:
438
- "renamed REPOSITORIES_USER_HTTP_TIMEOUT, and only captured — set at any value, beside its new name or not, it fails boot",
439
- CLIENT_USER_MAX_RESPONSE_BYTES:
440
- "renamed REPOSITORIES_USER_HTTP_MAX_RESPONSE_BYTES, and only captured — set at any value, beside its new name or not, it fails boot",
441
- CLIENT_CODE_DEFAULT_EXPIRES_IN:
442
- "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 at any value, beside its new name or not, it fails boot",
443
- CLIENT_CODE_KEY_PREFIX:
444
- "renamed REDIS_CODE_REPOSITORY_KEY_PREFIX, and only captured — set at any value, beside its new name or not, it fails boot",
445
- CLIENT_CODE_ENDPOINT_URI:
446
- "removed: the Redis code repository uses the shared redis-clients connection; only captured — set at all, it fails boot",
447
- CLIENT_CODE_PASSWORD:
448
- "removed: the Redis code repository uses the shared redis-clients connection; only captured — set at all, it fails boot",
449
- ENDPOINTS_MFA_URL:
450
- "renamed MFA_PAGE_URL, and only captured — set at any value, beside its new name or not, it fails boot where MFA is installed",
451
- MFA_TOTP_ENABLED:
452
- "renamed MFA_TOTP_FACTOR_ENABLED, and only captured — set at any value, beside its new name or not, it fails boot where MFA is installed",
453
- MFA_TOTP_ISSUER:
454
- "renamed MFA_TOTP_FACTOR_ISSUER, and only captured — set at any value, beside its new name or not, it fails boot where MFA is installed",
455
- };
456
-
457
- /**
458
- * The federation variables `config/application.conf` documents on lines it
459
- * ships commented out — a key an operator binds by uncommenting it — as the
460
- * string an operator would supply. They are not live substitutions, so they
461
- * are not in `DOCUMENTED_ENV`; the federation cases below bind them as their
462
- * comments write them (`commentedFederationBindings`).
463
- */
464
- const COMMENTED_FEDERATION_ENV: Readonly<Record<string, string>> = {
465
- CORE_FEDERATIONS_GOOGLE_SESSION_DOMAIN: ".example.com",
466
- CORE_FEDERATIONS_GOOGLE_AUTH_CALLBACK_URL: "https://app.example.com/auth/callback",
467
- CORE_FEDERATIONS_GOOGLE_CLIENT_URL: "https://app.example.com/",
468
- CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS: "false",
469
- };
470
-
471
- /**
472
- * Each `# <key> = ${?CORE_FEDERATIONS_<NAME>_…}` line of the shipped
473
- * `config/application.conf`, uncommented as an operator's layer above it:
474
- * `core.federations.<name>.<key> = ${?…}`.
475
- */
476
- function commentedFederationBindings(): string {
477
- const { applicationConfPath } = resolveConfigPaths(configDir, "production");
478
- return readFileSync(applicationConfPath, "utf8")
479
- .split("\n")
480
- .flatMap((line) => {
481
- const match =
482
- /^\s*#\s*([A-Za-z]+) = \$\{\?(CORE_FEDERATIONS_([A-Z0-9]+)_[A-Z0-9_]+)\}\s*$/.exec(line);
483
- if (match === null) return [];
484
- const [, key, variable, name] = match as unknown as [string, string, string, string];
485
- return [`core.federations.${name.toLowerCase()}.${key} = \${?${variable}}`];
486
- })
487
- .join("\n");
488
- }
489
-
490
- /**
491
- * The provider environment `o3co/auth`'s `tests/docker-compose.yml` is to
492
- * set, transcribed: each renamed variable under its new name alone, since an
493
- * old name set refuses boot beside its new one too. The umbrella E2E boots
494
- * the shipped template with this, so a parse failure here is a red umbrella
495
- * build that this repository can see first. `SESSION_STORE_SECURE=false` is the one it cannot run without: the
496
- * suite speaks plain HTTP.
497
- *
498
- * The two `FEDERATION_TOKEN_STORE` lines are required: the federation token
499
- * store defaults to memory, the standalone's memory module declares itself
500
- * replica-unsafe, and `CORE_DEPLOYMENT_MODE=multi` refuses it by name unless the
501
- * Redis store is selected with its encryption key.
502
- */
503
- const UMBRELLA_E2E_ENV: Readonly<Record<string, string>> = {
504
- KEY_STORE_LOCAL_ALGORITHM: "HS256",
505
- KEY_STORE_LOCAL_SECRET: "e2e-shared-hs256-secret.at-least-32-bytes.ok",
506
- OAUTH_JWT_ISSUER: "https://auth.e2e.test",
507
- SESSION_STORE_SECRET: "lO0QH09fuKSGuViZ9myJbH3jsgai99A2GpC3RYRuy6Y=",
508
- SESSION_STORE_SECURE: "false",
509
- SESSION_STORE_NAME: "auth.session",
510
- CORE_DEPLOYMENT_MODE: "multi",
511
- REDIS_CLIENTS_URL: "redis://redis:6379",
512
- SESSION_STORE_STORAGE_REDIS_URL: "redis://redis:6379",
513
- ADAPTERS_USER_SESSION_STORES: "redis",
514
- ADAPTERS_RATE_LIMITER: "redis",
515
- ADAPTERS_CODE_REPOSITORY: "redis",
516
- ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
517
- REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
518
- ADAPTERS_USER_REPOSITORY: "yaml",
519
- OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
520
- OAUTH_REQUIRE_EMAIL_VERIFIED: "true",
521
- OAUTH_SESSION_ENABLED: "true",
522
- MFA_MODE: "off",
523
- };
524
-
525
- /** The template's own files for `configEnv`, under `operatorLayer` — HOCON an operator adds above them — when given. */
526
- function ownFiles(configEnv: string, operatorLayer?: string): string[] {
527
- const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, configEnv);
528
- if (operatorLayer === undefined) return [envConfPath, applicationConfPath];
529
- const file = join(mkdtempSync(join(tmpdir(), "documented-env-overrides-")), "operator.conf");
530
- writeFileSync(file, operatorLayer);
531
- return [file, envConfPath, applicationConfPath];
532
- }
533
-
534
- /** Phase one, as `app.mts` reads it: the switches. */
535
- function readShippedSwitches(env: Record<string, string>, configEnv = "production"): Switches {
536
- return readSwitches(readOwnLayers(ownFiles(configEnv), { env }));
537
- }
538
-
539
- /**
540
- * The stores an enabled federation needs beside it, which boot refuses a
541
- * composition without. What they hold is not this suite's question.
542
- */
543
- const FEDERATION_STORES = Object.fromEntries(
544
- [
545
- "userSessionStore",
546
- "sessionLifecycle",
547
- "federationTokenStore",
548
- "refreshTokenFamilyRevocation",
549
- ].map((key) => [key, {}]),
550
- );
551
-
552
- /** What core handed a federation type for one entry: its callback, and its own keys as the type's schema parsed them. */
553
- interface Dispatched {
554
- readonly callbackURL: string;
555
- readonly entry: Readonly<Record<string, unknown>>;
556
- }
557
-
558
- /** A federation type's declaration, as far as this suite reads it. */
559
- interface TypeDeclaration {
560
- readonly factory: (deps: unknown, instance: { name: string } & Dispatched) => unknown;
561
- }
562
-
563
- /**
564
- * `module`, a federation type module, with each type's factory recording what
565
- * core handed it into `dispatched`, by the entry's name, before building the
566
- * provider as the module does. The schema, the parse and the provider are the
567
- * module's own.
568
- */
569
- function recordingDispatch(module: Module, dispatched: Map<string, Dispatched>): Module {
570
- const contributes = module.contributes as {
571
- readonly federationTypes: Readonly<Record<string, TypeDeclaration>>;
572
- };
573
- const federationTypes = Object.fromEntries(
574
- Object.entries(contributes.federationTypes).map(([type, declaration]) => [
575
- type,
576
- {
577
- ...declaration,
578
- factory: (deps: unknown, instance: { name: string } & Dispatched) => {
579
- dispatched.set(instance.name, {
580
- callbackURL: instance.callbackURL,
581
- entry: instance.entry,
582
- });
583
- return declaration.factory(deps, instance);
584
- },
585
- },
586
- ]),
587
- );
588
- return { ...module, contributes: { ...contributes, federationTypes } } as Module;
589
- }
590
-
591
- /**
592
- * The upstream the documented OIDC federation's issuer names. Its type
593
- * discovers the issuer at boot, so the module is handed this fake's `fetch`
594
- * rather than the network. Made once per file.
595
- */
596
- let oidcUpstream: Promise<FakeIdp> | undefined;
597
- const documentedOidcUpstream = (): Promise<FakeIdp> => {
598
- oidcUpstream ??= createFakeIdp({
599
- issuer: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_ISSUER as string,
600
- discovery: true,
601
- clientId: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_CLIENT_ID as string,
602
- });
603
- return oidcUpstream;
604
- };
605
-
606
- /**
607
- * The shipped layers under `env`, as `app.mts` hands them to boot, booted
608
- * with the federation types the template bundles: phase one for what the
609
- * composition expects of session admission, phase two resolved over the
610
- * reference of every package the template's modules come from (the
611
- * template's own and core's) and parsed once by `createApp` — no bridge on
612
- * the way. Core dispatches each enabled federation to its type, which parses
613
- * the entry with its own schema; `dispatched` is what each type was handed.
614
- * No other module is loaded unless `modules` names one: the parse is what this
615
- * suite asks about, and each key it reads is one core's schema declares, or
616
- * the section of a module it loads.
617
- */
618
- /**
619
- * The oauth module's section, read without its factories: the template always
620
- * loads the module, and core refuses keys of `oauth {}` where no loaded
621
- * module's section is `oauth`. What refuses `oauth {}` here is the module's
622
- * own section — its schema, its removed keys and variables.
623
- */
624
- const oauthSectionReader = defineModule({
625
- name: oauthEndpointsModule.name,
626
- ...(oauthEndpointsModule.section === undefined ? {} : { section: oauthEndpointsModule.section }),
627
- });
628
-
629
- async function bootDispatched(
630
- env: Record<string, string>,
631
- configEnv = "production",
632
- operatorLayer?: string,
633
- modules: readonly Module[] = [],
634
- ): Promise<{ readonly parsed: AppConfig; readonly dispatched: ReadonlyMap<string, Dispatched> }> {
635
- const own = readOwnLayers(ownFiles(configEnv, operatorLayer), { env });
636
- const switches = readSwitches(own);
637
- const dispatched = new Map<string, Dispatched>();
638
- const upstream = await documentedOidcUpstream();
639
- const handle = await createApp({
640
- modules: [
641
- recordingDispatch(googleFederationTypeModule(), dispatched),
642
- recordingDispatch(oidcFederationTypeModule({ fetch: upstream.fetch }), dispatched),
643
- oauthSectionReader,
644
- ...modules,
645
- ],
646
- bootstrapComponents: {
647
- config: resolveForBoot(own, buildModules(switches, { environment: configEnv }), switches),
648
- pathResolver: (s: string) => s,
649
- ...FEDERATION_STORES,
650
- } as never,
651
- });
652
- const parsed = handle.components.config;
653
- await handle.dispose();
654
- if (parsed === undefined) throw new Error("createApp booted without the parsed configuration");
655
- return { parsed, dispatched };
656
- }
657
-
658
- /** The configuration boot parsed, as {@link bootDispatched} boots it. */
659
- async function bootParsed(
660
- env: Record<string, string>,
661
- configEnv = "production",
662
- operatorLayer?: string,
663
- modules: readonly Module[] = [],
664
- ): Promise<AppConfig> {
665
- return (await bootDispatched(env, configEnv, operatorLayer, modules)).parsed;
666
- }
667
-
668
- /** `session-store {}` as the session store's module parses it. */
669
- function sessionStoreSection(config: AppConfig): {
670
- readonly secure: boolean;
671
- readonly sameSite: string;
672
- readonly maxAge: number;
673
- } {
674
- const schema = sessionStoreModule.section?.schema;
675
- if (schema === undefined) throw new Error("the session store's module declares no section");
676
- return schema.parse((config as Record<string, unknown>)["session-store"]) as ReturnType<
677
- typeof sessionStoreSection
678
- >;
679
- }
680
-
681
- /** `federation-grants {}` as the federation-grants module parses it. */
682
- function federationGrantsSection(config: AppConfig): { readonly enabled?: boolean } {
683
- const schema = federationGrantsModule.section?.schema;
684
- if (schema === undefined) throw new Error("the federation-grants module declares no section");
685
- return schema.parse(config["federation-grants"]) as ReturnType<typeof federationGrantsSection>;
686
- }
687
-
688
- /** `session {}` as the session module parses it. */
689
- function sessionSection(config: AppConfig): { readonly csrf?: { readonly ttlSeconds: number } } {
690
- const schema = sessionModule.section?.schema;
691
- if (schema === undefined) throw new Error("the session module declares no section");
692
- return schema.parse(config.session) as ReturnType<typeof sessionSection>;
693
- }
694
-
695
- /** `http {}` as the `http` module parsed it, in a configuration boot parsed with the module loaded. */
696
- function httpSectionOf(config: AppConfig): {
697
- readonly port: number;
698
- readonly readinessTimeoutMs: number;
699
- readonly trustProxy: unknown;
700
- readonly cors: { readonly allowedOrigins: readonly string[] };
701
- } {
702
- return (config as unknown as { http: ReturnType<typeof httpSectionOf> }).http;
703
- }
704
-
705
- /** `oauth {}` as the oauth module's own schema parses it. */
706
- function oauthSection(config: AppConfig): OAuthSection {
707
- const schema = oauthEndpointsModule.section?.schema;
708
- if (schema === undefined) throw new Error("the oauth module declares no section");
709
- return schema.parse(config.oauth) as OAuthSection;
710
- }
711
-
712
- /** Every `${?VAR}` in a HOCON layer, ignoring commented-out lines. */
713
- function substitutionsIn(path: string): Set<string> {
714
- const found = new Set<string>();
715
- for (const line of readFileSync(path, "utf8").split("\n")) {
716
- const code = line.replace(/(^|\s)(#|\/\/).*$/, "");
717
- for (const match of code.matchAll(/\$\{\?([A-Z][A-Z0-9_]*)\}/g)) {
718
- found.add(match[1] as string);
719
- }
720
- }
721
- return found;
722
- }
723
-
724
- /**
725
- * Every variable named in a leading `| \`VAR\` |` cell of a README table.
726
- *
727
- * The underscore is what separates an environment variable from the HTTP
728
- * methods in the endpoint table, which share the shouting-case shape.
729
- */
730
- function documentedInReadme(path: string = readmePath): Set<string> {
731
- const found = new Set<string>();
732
- for (const match of readFileSync(path, "utf8").matchAll(/^\|\s*`([A-Z][A-Z0-9_]*)`\s*\|/gm)) {
733
- const name = match[1] as string;
734
- if (name.includes("_")) found.add(name);
735
- }
736
- return found;
737
- }
738
-
739
- /**
740
- * The substitutions of the template's own layers, and of the `reference.conf`
741
- * of every package the template loads a module from under the documented
742
- * environment in production (core's among them) — and under it with MFA
743
- * installed, its factors in the Store, which brings the MFA package's and
744
- * foundation's.
745
- */
746
- function liveSubstitutions(): Set<string> {
747
- const { applicationConfPath } = resolveConfigPaths(configDir, "production");
748
- const references = [
749
- DOCUMENTED_ENV,
750
- { ...DOCUMENTED_ENV, MFA_MODE: "required", ADAPTERS_MFA_FACTOR_STORE: "store" },
751
- ].flatMap((env) =>
752
- moduleReferences(buildModules(readShippedSwitches(env), { environment: "production" })),
753
- );
754
- return new Set([
755
- ...references.flatMap((reference) => [...substitutionsIn(fileURLToPath(reference))]),
756
- ...substitutionsIn(fileURLToPath(templateReference())),
757
- ...substitutionsIn(applicationConfPath),
758
- ]);
759
- }
760
-
761
- describe("the shipped config boots with every documented override supplied as a string", () => {
762
- it("parses with every documented environment variable set", async () => {
763
- await expect(bootParsed(DOCUMENTED_ENV)).resolves.toBeDefined();
764
- });
765
-
766
- it("turns every boolean override into an actual boolean", async () => {
767
- const config = await bootParsed(DOCUMENTED_ENV);
768
- // Each of these arrives from HOCON as a string. A leftover string is
769
- // not a cosmetic defect: `=== true` is how the runtime reads them.
770
- expect(sessionStoreSection(config).secure).toBe(false);
771
- expect(oauthSection(config).requireEmailVerified).toBe(true);
772
- expect(oauthSection(config).resourceIndicator?.enabled).toBe(true);
773
- expect(config.core?.federations?.google?.enabled).toBe(true);
774
- expect(config.core?.federations?.oidc?.enabled).toBe(true);
775
- // A leftover string here would be read as "on" by a truthiness check
776
- // and as "off" by `=== true`, for a feature whose whole default is off.
777
- expect(federationGrantsSection(config).enabled).toBe(true);
778
- expect(
779
- standaloneRedisClientsModule.section?.schema.parse(
780
- (config as Record<string, unknown>)["redis-clients"],
781
- ),
782
- ).toMatchObject({ assumeNoEviction: true });
783
- });
784
-
785
- it("turns every non-boolean override into its declared type", async () => {
786
- const config = await bootParsed(DOCUMENTED_ENV);
787
- const http = httpSectionOf(
788
- await bootParsed(DOCUMENTED_ENV, "production", undefined, [httpModule]),
789
- );
790
- expect(http.port).toBe(3000);
791
- expect(http.readinessTimeoutMs).toBe(1500);
792
- expect(http.trustProxy).toEqual(["10.0.0.0/8", "loopback"]);
793
- expect(config.core?.federations?.google?.accessType).toBe("online");
794
- expect(resolveAccessTokenLifetime({ oauth: oauthSection(config) })).toEqual({
795
- defaultExpiresIn: 900,
796
- maxExpiresIn: 7200,
797
- });
798
- expect(oauthSection(config).accessToken).toEqual({
799
- defaultExpiresIn: 900,
800
- maxExpiresIn: 7200,
801
- });
802
- expect(oauthSection(config).refreshToken.expiresIn).toBe(86400);
803
- expect(sessionStoreSection(config).maxAge).toBe(3600000);
804
- expect(sessionSection(config).csrf?.ttlSeconds).toBe(7200);
805
- expect(oauthSection(config).nonce?.maxLength).toBe(256);
806
- expect(readShippedSwitches(DOCUMENTED_ENV).adapters.consentStore).toBe("redis");
807
- // A Redis store's section, which its module (not loaded here) parses.
808
- const sections = config as unknown as Record<string, { keyPrefix?: unknown } | undefined>;
809
- expect(sections["redis-consent-store"]?.keyPrefix).toBe("tenant-a:consent:");
810
- // The comma-separated lists become lists, trimmed; the numbers, numbers:
811
- // the oauth module's schema reads them (the module is not loaded here).
812
- expect(oauthSection(config).clientIdMetadataDocuments).toEqual({
813
- enabled: true,
814
- allowedScopes: ["read", "write"],
815
- allowedAudiences: ["https://mcp.example"],
816
- allowedHosts: ["client.example", ".trusted.example"],
817
- deniedHosts: ["evil.example"],
818
- maxBytes: 8192,
819
- timeoutMs: 3000,
820
- maxCacheEntries: 128,
821
- staleIfErrorMs: 120000,
822
- negativeCacheMs: 30000,
823
- maxConcurrentFetches: 4,
824
- cacheMaxAgeMs: 60000,
825
- });
826
- // The MFA switch is read before boot, by the template; off, it hands
827
- // boot no `mfa` section.
828
- expect(readShippedSwitches(DOCUMENTED_ENV).mfaMode).toBe("off");
829
- expect(config).not.toHaveProperty("mfa");
830
- expect(config).not.toHaveProperty("endpoints.mfa");
831
- expect(readShippedSwitches(DOCUMENTED_ENV).adapters).toMatchObject({
832
- mfaFactorStore: "redis",
833
- mfaTransactionStore: "redis",
834
- });
835
- expect(sections["redis-mfa-factor-store"]?.keyPrefix).toBe("tenant-a:mfaf:");
836
- expect(sections["redis-mfa-transaction-store"]?.keyPrefix).toBe("tenant-a:mfat:");
837
- // A comma-separated string becomes a list of origins, trimmed.
838
- expect(http.cors.allowedOrigins).toEqual(["https://app.example.com", "http://localhost:5173"]);
839
- });
840
-
841
- describe("the federations' variables, read by each federation's type", () => {
842
- it("hands each enabled federation's type the entry its documented variables set", async () => {
843
- const { dispatched } = await bootDispatched(DOCUMENTED_ENV);
844
- expect(Object.fromEntries(dispatched)).toEqual({
845
- google: {
846
- callbackURL: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CALLBACK_URL,
847
- entry: {
848
- clientId: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID,
849
- clientSecret: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET,
850
- redirectAllowlist: [],
851
- accessType: "online",
852
- },
853
- },
854
- oidc: {
855
- callbackURL: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_CALLBACK_URL,
856
- entry: {
857
- issuer: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_ISSUER,
858
- clientId: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_CLIENT_ID,
859
- clientSecret: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_CLIENT_SECRET,
860
- scopes: ["openid", "profile", "email"],
861
- redirectAllowlist: [],
862
- },
863
- },
864
- });
865
- });
866
-
867
- it("reads the Google keys the configuration documents commented out, bound as their comments write them", async () => {
868
- const { dispatched } = await bootDispatched(
869
- { ...DOCUMENTED_ENV, ...COMMENTED_FEDERATION_ENV },
870
- "production",
871
- commentedFederationBindings(),
872
- );
873
- expect(dispatched.get("google")?.entry).toEqual({
874
- clientId: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID,
875
- clientSecret: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET,
876
- redirectAllowlist: [],
877
- accessType: "online",
878
- sessionDomain: COMMENTED_FEDERATION_ENV.CORE_FEDERATIONS_GOOGLE_SESSION_DOMAIN,
879
- authCallbackUrl: COMMENTED_FEDERATION_ENV.CORE_FEDERATIONS_GOOGLE_AUTH_CALLBACK_URL,
880
- clientUrl: COMMENTED_FEDERATION_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_URL,
881
- requireAuthorizationResponseIss: false,
882
- });
883
- // `toEqual` passes a key present as `undefined`: no variable binds
884
- // `endSessionEndpoint`, so the entry must not carry the key at all.
885
- expect(dispatched.get("google")?.entry).not.toHaveProperty("endSessionEndpoint");
886
- });
887
-
888
- it("hands the Google type no key the documented variables leave unset, not even as undefined", async () => {
889
- const { dispatched } = await bootDispatched(DOCUMENTED_ENV);
890
- const entry = dispatched.get("google")?.entry;
891
- expect(entry).toBeDefined();
892
- for (const key of [
893
- "sessionDomain",
894
- "authCallbackUrl",
895
- "clientUrl",
896
- "requireAuthorizationResponseIss",
897
- "endSessionEndpoint",
898
- ]) {
899
- expect(entry, key).not.toHaveProperty(key);
900
- }
901
- });
902
-
903
- it("binds every variable the configuration documents commented out", () => {
904
- expect(
905
- commentedFederationBindings()
906
- .match(/\$\{\?[A-Z0-9_]+\}/g)
907
- ?.sort(),
908
- ).toEqual(
909
- Object.keys(COMMENTED_FEDERATION_ENV)
910
- .map((name) => `\${?${name}}`)
911
- .sort(),
912
- );
913
- });
914
-
915
- /** The entry the Google type was handed under `variables`, the commented keys bound. */
916
- const googleEntryUnder = async (variables: Record<string, string>) =>
917
- (
918
- await bootDispatched(
919
- { ...DOCUMENTED_ENV, ...variables },
920
- "production",
921
- commentedFederationBindings(),
922
- )
923
- ).dispatched.get("google")?.entry;
924
-
925
- for (const [supplied, expected] of [
926
- ["true", true],
927
- ["TRUE", true],
928
- ["1", true],
929
- ["false", false],
930
- ["False", false],
931
- [" false ", false],
932
- ["0", false],
933
- ] as const) {
934
- it(`CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS=${JSON.stringify(supplied)} reads as ${expected}`, async () => {
935
- const entry = await googleEntryUnder({
936
- CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS: supplied,
937
- });
938
- expect(entry?.requireAuthorizationResponseIss).toBe(expected);
939
- });
940
- }
941
-
942
- it("refuses CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS exported empty or misspelt, naming the key, rather than turning the check off", async () => {
943
- for (const supplied of ["", "no", "off", "ture"]) {
944
- await expect(
945
- googleEntryUnder({
946
- CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS: supplied,
947
- }),
948
- supplied,
949
- ).rejects.toThrow(/core\.federations\.google\.requireAuthorizationResponseIss/);
950
- }
951
- });
952
-
953
- it("reads CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE as offline or online, exactly", async () => {
954
- for (const supplied of ["offline", "online"]) {
955
- expect(
956
- (await googleEntryUnder({ CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE: supplied }))?.accessType,
957
- ).toBe(supplied);
958
- }
959
- for (const supplied of ["", "Online", "offine", "true"]) {
960
- await expect(
961
- googleEntryUnder({ CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE: supplied }),
962
- supplied,
963
- ).rejects.toThrow(/core\.federations\.google\.accessType/);
964
- }
965
- });
966
- });
967
-
968
- describe("HTTP_CORS_ALLOWED_ORIGINS", () => {
969
- it("reads an exported-but-empty variable as no origins, not as an error", async () => {
970
- // The .env / compose / ConfigMap shape. "CORS off" is what both the
971
- // unset key and the empty string mean, so they must agree.
972
- const config = await bootParsed(
973
- { ...DOCUMENTED_ENV, HTTP_CORS_ALLOWED_ORIGINS: "" },
974
- "production",
975
- undefined,
976
- [httpModule],
977
- );
978
- expect(httpSectionOf(config).cors.allowedOrigins).toEqual([]);
979
- });
980
-
981
- it("fails boot on an origin that could never match, naming the key", async () => {
982
- // Every one of these parses as a URL and is a real typo: matching is
983
- // exact string equality against the Origin header, so each would be
984
- // an allowlist that admits nobody with nothing to say so.
985
- for (const bad of [
986
- "https://app.example.com/", // trailing slash
987
- "https://app.example.com:443", // explicit default port
988
- "https://*.example.com", // wildcard
989
- "http://app.example.com", // plaintext off loopback
990
- "https://app.example.com/callback", // a URL, not an origin
991
- ]) {
992
- await expect(
993
- bootParsed(
994
- { ...DOCUMENTED_ENV, HTTP_CORS_ALLOWED_ORIGINS: bad },
995
- "production",
996
- undefined,
997
- [httpModule],
998
- ),
999
- ).rejects.toThrow(/http\.cors\.allowedOrigins/);
1000
- }
1001
- });
1002
-
1003
- it("fails boot on a value that is neither a list nor a string, naming the key", async () => {
1004
- // The variable can only ever carry a string, so this shape comes
1005
- // from a configuration file. Read as no origins, it would turn CORS
1006
- // silently off for a key someone wrote.
1007
- for (const value of ["42", "true", '{ origin = "https://app.example.com" }']) {
1008
- await expect(
1009
- bootParsed(DOCUMENTED_ENV, "production", `http.cors.allowedOrigins = ${value}`, [
1010
- httpModule,
1011
- ]),
1012
- value,
1013
- ).rejects.toThrow(/http\.cors\.allowedOrigins/);
1014
- }
1015
- });
1016
-
1017
- it("accepts the loopback http carve-out a dev front-end needs", async () => {
1018
- const config = await bootParsed(
1019
- {
1020
- ...DOCUMENTED_ENV,
1021
- HTTP_CORS_ALLOWED_ORIGINS:
1022
- "http://localhost:5173,http://127.0.0.1:5173,https://app.example.com",
1023
- },
1024
- "production",
1025
- undefined,
1026
- [httpModule],
1027
- );
1028
- expect(httpSectionOf(config).cors.allowedOrigins).toHaveLength(3);
1029
- });
1030
- });
1031
-
1032
- it("parses the HS256 shape, whose strict union refuses asymmetric key fields", async () => {
1033
- // `KEY_STORE_LOCAL_ALGORITHM=HS256` is not a variation on the map above:
1034
- // the HS256 member of the key store's `local` union is strict, so a
1035
- // deployment that switches algorithm must also stop exporting the
1036
- // key-file variables. Worth pinning — it is the umbrella E2E's shape.
1037
- const { KEY_STORE_LOCAL_PRIVATE_KEY, KEY_STORE_LOCAL_PRIVATE_KEY_PATH, ...rest } =
1038
- DOCUMENTED_ENV;
1039
- void KEY_STORE_LOCAL_PRIVATE_KEY;
1040
- void KEY_STORE_LOCAL_PRIVATE_KEY_PATH;
1041
- const { KEY_STORE_LOCAL_PUBLIC_KEY, KEY_STORE_LOCAL_PUBLIC_KEY_PATH, ...hs256 } = rest;
1042
- void KEY_STORE_LOCAL_PUBLIC_KEY;
1043
- void KEY_STORE_LOCAL_PUBLIC_KEY_PATH;
1044
- const config = await bootParsed(
1045
- { ...hs256, KEY_STORE_LOCAL_ALGORITHM: "HS256" },
1046
- "production",
1047
- undefined,
1048
- [keyStoreModule],
1049
- );
1050
- expect(
1051
- (config as unknown as { "key-store": { local: { algorithm: string } } })["key-store"].local
1052
- .algorithm,
1053
- ).toBe("HS256");
1054
- });
1055
-
1056
- it("parses the environment the umbrella E2E boots, with SESSION_STORE_SECURE=false as a string", async () => {
1057
- const config = await bootParsed(UMBRELLA_E2E_ENV);
1058
- expect(sessionStoreSection(config).secure).toBe(false);
1059
- expect(oauthSection(config).requireEmailVerified).toBe(true);
1060
- expect(oauthSection(config).resourceIndicator?.enabled).toBe(true);
1061
- expect(config.core?.deployment?.mode).toBe("multi");
1062
- });
1063
-
1064
- it("wires nothing replica-unsafe for the umbrella E2E environment", async () => {
1065
- // `CORE_DEPLOYMENT_MODE=multi` makes the provider audit its own store wiring
1066
- // at boot, so a config that parses but wires a memory store still fails
1067
- // there. Ask each manifest, the way the guard does: the exported name
1068
- // list covers core's modules only, and cannot see the template's own
1069
- // memory modules.
1070
- const { replicaUnsafeReason } = await import("@o3co/auth-provider-core");
1071
- // Each module's section at its name: a declaration made from the
1072
- // section is answered for it.
1073
- const own = readOwnLayers(ownFiles("production"), { env: UMBRELLA_E2E_ENV });
1074
- const switches = readSwitches(own);
1075
- const modules = buildModules(switches);
1076
- const sections = resolveForBoot(own, modules, switches) as unknown as Record<string, unknown>;
1077
- for (const module of modules) {
1078
- expect(replicaUnsafeReason(module, sections[module.name]), module.name).toBeUndefined();
1079
- }
1080
- });
1081
-
1082
- it("reads MFA_MODE=off before boot, where the shipped configuration expects no session requirement", async () => {
1083
- expect(readShippedSwitches({ ...DOCUMENTED_ENV, MFA_MODE: "off" }).mfaMode).toBe("off");
1084
- const parsed = await bootParsed({ ...DOCUMENTED_ENV, MFA_MODE: "off" });
1085
- expect(parsed.core?.sessionRequirements).toEqual({ expected: [] });
1086
- });
1087
-
1088
- describe("boolean overrides accept the spellings an operator writes", () => {
1089
- const cases: ReadonlyArray<[string, boolean]> = [
1090
- ["true", true],
1091
- ["TRUE", true],
1092
- ["1", true],
1093
- ["false", false],
1094
- ["0", false],
1095
- // An exported-but-empty variable — the `.env` / compose / ConfigMap
1096
- // shape. Reads as false, as it does for trustProxy.
1097
- ["", false],
1098
- ];
1099
-
1100
- for (const [supplied, expected] of cases) {
1101
- it(`SESSION_STORE_SECURE=${JSON.stringify(supplied)} resolves to ${expected}`, async () => {
1102
- const config = await bootParsed({
1103
- ...DOCUMENTED_ENV,
1104
- SESSION_STORE_SECURE: supplied,
1105
- SESSION_STORE_SAME_SITE: "lax",
1106
- });
1107
- expect(sessionStoreSection(config).secure).toBe(expected);
1108
- });
1109
- }
1110
-
1111
- it("refuses a spelling it does not recognise rather than guessing", async () => {
1112
- const config = await bootParsed({ ...DOCUMENTED_ENV, SESSION_STORE_SECURE: "ture" });
1113
- expect(() => sessionStoreSection(config)).toThrow(/true.*false/s);
1114
- });
1115
-
1116
- it("refuses SESSION_STORE_SAME_SITE=none unless SESSION_STORE_SECURE is on", async () => {
1117
- // The SameSite=None guard reads the coerced value, so it has to keep firing
1118
- // for the string form an environment variable actually delivers.
1119
- const insecure = await bootParsed({
1120
- ...DOCUMENTED_ENV,
1121
- SESSION_STORE_SAME_SITE: "none",
1122
- SESSION_STORE_SECURE: "false",
1123
- });
1124
- expect(() => sessionStoreSection(insecure)).toThrow(/SESSION_STORE_SECURE=true/);
1125
- const secure = await bootParsed({
1126
- ...DOCUMENTED_ENV,
1127
- SESSION_STORE_SAME_SITE: "none",
1128
- SESSION_STORE_SECURE: "true",
1129
- SESSION_STORE_NAME: "auth.session",
1130
- });
1131
- expect(sessionStoreSection(secure).sameSite).toBe("none");
1132
- });
1133
- });
1134
-
1135
- describe("the documented surface and this suite cannot drift apart", () => {
1136
- it("covers every substitution the shipped config layers declare", () => {
1137
- const uncovered = [...liveSubstitutions()].filter(
1138
- (name) => !(name in DOCUMENTED_ENV) && !(name in DELIBERATELY_UNSET),
1139
- );
1140
- expect(uncovered).toEqual([]);
1141
- });
1142
-
1143
- it("covers every environment variable the README documents", () => {
1144
- const uncovered = [...documentedInReadme(), ...documentedInReadme(readmeJaPath)].filter(
1145
- (name) => !(name in DOCUMENTED_ENV) && !(name in DELIBERATELY_UNSET),
1146
- );
1147
- expect(uncovered).toEqual([]);
1148
- });
1149
-
1150
- it("documents the same variables in README.md and README.ja.md", () => {
1151
- // The Japanese README carries the same facts (AGENTS.md): a row added
1152
- // to one and not the other is a variable one audience never hears of.
1153
- const en = documentedInReadme();
1154
- const ja = documentedInReadme(readmeJaPath);
1155
- expect({
1156
- onlyInEnglish: [...en].filter((name) => !ja.has(name)).sort(),
1157
- onlyInJapanese: [...ja].filter((name) => !en.has(name)).sort(),
1158
- }).toEqual({ onlyInEnglish: [], onlyInJapanese: [] });
1159
- });
1160
-
1161
- it("exercises no variable the config layers no longer substitute", () => {
1162
- // Catches the reverse drift: a README row (and a test entry) left
1163
- // behind by a key that was removed from the HOCON.
1164
- const live = liveSubstitutions();
1165
- const dead = Object.keys(DOCUMENTED_ENV).filter((name) => !live.has(name));
1166
- expect(dead).toEqual([]);
1167
- });
1168
- });
1169
-
1170
- describe("variables whose documented behaviour is to fail boot", () => {
1171
- it("refuses OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS as the variable of a removed key, naming allowUnmarkedClients", async () => {
1172
- const booting = bootParsed({
1173
- ...DOCUMENTED_ENV,
1174
- OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS: "true",
1175
- });
1176
- await expect(booting).rejects.toMatchObject({ reason: "environment-variable-renamed" });
1177
- await expect(booting).rejects.toThrow(/oauth\.authorize\.allowUnmarkedClients/);
1178
- });
1179
-
1180
- it("refuses OAUTH_JWT_LEGACY_TYP_ACCEPT as the variable of a removed key, naming oauth.jwt.legacyTypAccept and the upgrade guide", async () => {
1181
- for (const value of ["true", "false"]) {
1182
- const booting = bootParsed({ ...DOCUMENTED_ENV, OAUTH_JWT_LEGACY_TYP_ACCEPT: value });
1183
- await expect(booting).rejects.toMatchObject({ reason: "environment-variable-renamed" });
1184
- await expect(booting).rejects.toThrow(/oauth\.jwt\.legacyTypAccept/);
1185
- await expect(booting).rejects.toThrow(/docs\/upgrading-from-v0\.16\.0\.md/);
1186
- }
1187
- });
1188
-
1189
- it("refuses an access-token default above the max, naming both keys", async () => {
1190
- await expect(
1191
- bootParsed({
1192
- ...DOCUMENTED_ENV,
1193
- OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "7200",
1194
- OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN: "3600",
1195
- }),
1196
- ).rejects.toThrow(/defaultExpiresIn.*maxExpiresIn/s);
1197
- });
1198
-
1199
- for (const name of [
1200
- "OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
1201
- "OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN",
1202
- ]) {
1203
- it(`refuses an empty ${name} rather than minting already-expired tokens`, async () => {
1204
- await expect(bootParsed({ ...DOCUMENTED_ENV, [name]: "" })).rejects.toMatchObject({
1205
- reason: "config-validation-failed",
1206
- });
1207
- });
1208
- }
1209
-
1210
- it("refuses MFA_MODE that is none of the three before boot, naming mfaMode", async () => {
1211
- const env = { ...DOCUMENTED_ENV, MFA_MODE: "on" };
1212
- expect(() => readShippedSwitches(env)).toThrow(BootError);
1213
- await expect(bootParsed(env)).rejects.toThrow(/mfaMode/);
1214
- });
1215
-
1216
- for (const mode of ["optional", "required"] as const) {
1217
- it(`reads MFA_MODE=${mode} as the switch that installs MFA, expecting mfa`, () => {
1218
- const own = readOwnLayers(ownFiles("production"), {
1219
- env: { ...DOCUMENTED_ENV, MFA_MODE: mode },
1220
- });
1221
- const switches = readSwitches(own);
1222
- expect(switches.mfaMode).toBe(mode);
1223
- const shipped = (resolveLayers(own, []).core as { sessionRequirements?: unknown })
1224
- .sessionRequirements;
1225
- expect(expectedSessionRequirements(shipped, switches.mfaMode)).toEqual({
1226
- expected: ["mfa"],
1227
- secondFactorAuthority: "mfa",
1228
- });
1229
- });
1230
- }
1231
-
1232
- it("refuses OAUTH_ACCESS_TOKEN_EXPIRES_IN set alone, naming OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN", async () => {
1233
- const { OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: _new, ...env } = DOCUMENTED_ENV;
1234
- const booting = bootParsed({ ...env, OAUTH_ACCESS_TOKEN_EXPIRES_IN: "900" });
1235
- await expect(booting).rejects.toMatchObject({ reason: "environment-variable-renamed" });
1236
- await expect(booting).rejects.toThrow(
1237
- /OAUTH_ACCESS_TOKEN_EXPIRES_IN was renamed OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN/,
1238
- );
1239
- });
1240
-
1241
- it("refuses OAUTH_ACCESS_TOKEN_EXPIRES_IN beside its new name set to another value", async () => {
1242
- await expect(
1243
- bootParsed({ ...DOCUMENTED_ENV, OAUTH_ACCESS_TOKEN_EXPIRES_IN: "3600" }),
1244
- ).rejects.toMatchObject({ reason: "environment-variable-renamed" });
1245
- });
1246
-
1247
- it("refuses OAUTH_ACCESS_TOKEN_EXPIRES_IN beside its new name set to the same value", async () => {
1248
- const booting = bootParsed({
1249
- ...DOCUMENTED_ENV,
1250
- OAUTH_ACCESS_TOKEN_EXPIRES_IN:
1251
- DOCUMENTED_ENV.OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN as string,
1252
- });
1253
- await expect(booting).rejects.toMatchObject({ reason: "environment-variable-renamed" });
1254
- await expect(booting).rejects.toThrow(/unset OAUTH_ACCESS_TOKEN_EXPIRES_IN/);
1255
- });
1256
-
1257
- it("boots with OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN alone, reading it", async () => {
1258
- const config = await bootParsed(DOCUMENTED_ENV);
1259
- expect(oauthSection(config).accessToken).toEqual({
1260
- defaultExpiresIn: 900,
1261
- maxExpiresIn: 7200,
1262
- });
1263
- });
1264
-
1265
- it("refuses DEPLOYMENT_MODE set alone, naming CORE_DEPLOYMENT_MODE", async () => {
1266
- const { CORE_DEPLOYMENT_MODE: _new, ...env } = DOCUMENTED_ENV;
1267
- await expect(bootParsed({ ...env, DEPLOYMENT_MODE: "multi" })).rejects.toThrow(
1268
- /DEPLOYMENT_MODE was renamed CORE_DEPLOYMENT_MODE/,
1269
- );
1270
- });
1271
-
1272
- it("still refuses an empty SESSION_CSRF_TTL_SECONDS", async () => {
1273
- // Pinned alongside the boolean cases because it is the same trap
1274
- // read from the other side: for a *number*, empty means fail loudly.
1275
- const config = await bootParsed({ ...DOCUMENTED_ENV, SESSION_CSRF_TTL_SECONDS: "" });
1276
- expect(() => sessionSection(config)).toThrow(/ttlSeconds/);
1277
- });
1278
- });
1279
-
1280
- it("resolves against the shipped standalone artifact, not a fixture", () => {
1281
- // Guards the guard: if the paths above ever stop pointing at the real
1282
- // artifact these tests would pass while testing nothing. Checked by
1283
- // what the directory holds, not by what it is called: this file ships
1284
- // in every scaffolded project, and none of those lives at
1285
- // `templates/standalone`.
1286
- for (const file of ["config/application.conf", "config/production.conf", "src/app.mts"]) {
1287
- expect(existsSync(join(standaloneDir, file)), file).toBe(true);
1288
- }
1289
- expect(liveSubstitutions().size).toBeGreaterThan(40);
1290
- });
1291
- });