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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/README.ja.md +3 -1
  2. package/README.md +5 -2
  3. package/package.json +1 -1
  4. package/templates/standalone/.env.example +7 -5
  5. package/templates/standalone/README.ja.md +53 -25
  6. package/templates/standalone/README.md +140 -58
  7. package/templates/standalone/config/application.conf +41 -36
  8. package/templates/standalone/config/clients.yaml.example +8 -0
  9. package/templates/standalone/config/development.conf +2 -2
  10. package/templates/standalone/config/mailpit.conf +10 -5
  11. package/templates/standalone/config/reference.conf +30 -5
  12. package/templates/standalone/docker-compose.mailpit.yml +3 -0
  13. package/templates/standalone/docker-compose.production.yml +5 -2
  14. package/templates/standalone/package.json +1 -1
  15. package/templates/standalone/src/__tests__/adapters.test.mts +92 -25
  16. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  17. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +112 -98
  18. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +40 -11
  19. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +86 -51
  20. package/templates/standalone/src/__tests__/audit-sink.test.mts +10 -9
  21. package/templates/standalone/src/__tests__/config-notices.test.mts +157 -0
  22. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +48 -0
  23. package/templates/standalone/src/__tests__/core-federations.test.mts +57 -31
  24. package/templates/standalone/src/__tests__/core-sections.test.mts +7 -5
  25. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
  26. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +434 -136
  27. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +19 -21
  28. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +9 -15
  29. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +10 -4
  30. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  31. package/templates/standalone/src/__tests__/google-session.fixture.mts +60 -67
  32. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  33. package/templates/standalone/src/__tests__/library-references.fixture.mts +51 -46
  34. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  35. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +81 -16
  36. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  37. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +8 -4
  38. package/templates/standalone/src/__tests__/mfa-switch.test.mts +149 -46
  39. package/templates/standalone/src/__tests__/own-modules.test.mts +78 -23
  40. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
  41. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +214 -0
  42. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
  43. package/templates/standalone/src/__tests__/replica-safety.test.mts +131 -19
  44. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +25 -23
  45. package/templates/standalone/src/__tests__/sections.test.mts +24 -0
  46. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  47. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +3 -7
  48. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  49. package/templates/standalone/src/__tests__/session-requirements.test.mts +114 -31
  50. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +274 -0
  51. package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
  52. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +148 -0
  53. package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
  54. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +58 -38
  55. package/templates/standalone/src/__tests__/two-phase-config.test.mts +243 -76
  56. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +23 -31
  57. package/templates/standalone/src/adapters.mts +31 -18
  58. package/templates/standalone/src/app.mts +6 -5
  59. package/templates/standalone/src/bootRefusal.mts +117 -0
  60. package/templates/standalone/src/buildModules.mts +46 -96
  61. package/templates/standalone/src/configPath.mts +180 -153
  62. package/templates/standalone/src/mfaSwitch.mts +49 -27
  63. package/templates/standalone/src/modules.mts +37 -219
  64. package/templates/standalone/src/rootRenames.mts +38 -22
  65. package/templates/standalone/src/sections.mts +13 -2
  66. package/templates/standalone/tests/index.test.js +4 -3
  67. package/templates/versions.json +18 -18
  68. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -134
  69. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -202
  70. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -144
@@ -20,12 +20,18 @@ import { join } from "node:path";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import {
22
22
  type AppConfig,
23
+ BootError,
23
24
  createApp,
25
+ defineModule,
24
26
  type Module,
25
27
  moduleReferences,
26
28
  resolveAccessTokenLifetime,
27
29
  } from "@o3co/auth-provider-core";
28
- import { oauthModule } from "@o3co/auth-provider-oauth";
30
+ import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
31
+ import { googleFederationTypeModule } from "@o3co/auth-provider-federation-google";
32
+ import { federationGrantsModule } from "@o3co/auth-provider-federation-grants";
33
+ import { oidcFederationTypeModule } from "@o3co/auth-provider-federation-oidc";
34
+ import { type OAuthSection, oauthEndpointsModule } from "@o3co/auth-provider-oauth";
29
35
  import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
30
36
  import { describe, expect, it } from "vitest";
31
37
  import { buildModules } from "../buildModules.mjs";
@@ -35,16 +41,22 @@ import {
35
41
  readSwitches,
36
42
  resolveConfigPaths,
37
43
  resolveForBoot,
44
+ resolveLayers,
38
45
  type Switches,
39
46
  } from "../configPath.mjs";
40
- import { httpModule, keyStoreModule, templateReference } from "../modules.mjs";
47
+ import {
48
+ httpModule,
49
+ keyStoreModule,
50
+ standaloneRedisClientsModule,
51
+ templateReference,
52
+ } from "../modules.mjs";
41
53
 
42
54
  /**
43
55
  * Boots the shipped config with EVERY documented override supplied the way an
44
56
  * operator supplies one: as a string.
45
57
  *
46
- * HOCON substitutes `${?VAR}` as a string, always. `app.mts` reads its switches
47
- * with core's transitional reader and hands `createApp` what it resolved,
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,
48
60
  * which boot parses once with plain Zod, so every schema leaf must read the
49
61
  * string itself. This suite reads through that path: `readSwitches` for what
50
62
  * is read before boot, `resolveForBoot` and `createApp` for the configuration
@@ -94,11 +106,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
94
106
  // --- oauth tokens / policy ----------------------------------------
95
107
  OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "900",
96
108
  OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN: "7200",
97
- // Deprecated alias of the default. Exported alongside the new variable on
98
- // purpose, with a different value: the new variable must win.
99
- OAUTH_ACCESS_TOKEN_EXPIRES_IN: "3600",
100
109
  OAUTH_REFRESH_TOKEN_EXPIRES_IN: "86400",
101
- OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY: "reject",
102
110
  OAUTH_OIDC_MODE: "dual",
103
111
  OAUTH_REVOCATION_ACCESS_TOKEN: "denylist",
104
112
  OAUTH_REVOCATION_SUBJECT: "unsupported",
@@ -131,6 +139,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
131
139
  OAUTH_SESSION_ENABLED: "false",
132
140
  OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED: "true",
133
141
  OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED: "true",
142
+ OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY: "reject",
134
143
  OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED: "true",
135
144
  OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED: "false",
136
145
 
@@ -156,6 +165,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
156
165
  // Which adapter fills each slot: the composition root's own section,
157
166
  // read before the modules are chosen.
158
167
  ADAPTERS_RATE_LIMITER: "redis",
168
+ ADAPTERS_ATTEMPT_COUNTER: "redis",
159
169
  ADAPTERS_USER_SESSION_STORES: "redis",
160
170
  ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
161
171
  // The replay seen-set behind private_key_jwt client authentication.
@@ -190,6 +200,7 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
190
200
  REDIS_REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT: "3",
191
201
  REDIS_CLIENTS_URL: "redis://redis:6379",
192
202
  REDIS_CLIENTS_PASSWORD: "rt-family-password",
203
+ REDIS_CLIENTS_ASSUME_NO_EVICTION: "true",
193
204
  // The federation token store's Redis branch.
194
205
  REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX: "ft:",
195
206
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE: "required",
@@ -304,145 +315,182 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
304
315
  */
305
316
  const DELIBERATELY_UNSET: Readonly<Record<string, string>> = {
306
317
  OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS:
307
- "#330 tombstone — any value must fail boot with migration instructions",
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",
308
321
  DEPLOYMENT_MODE:
309
- "renamed CORE_DEPLOYMENT_MODE, and only captured — set alone, or to another value, it fails boot",
322
+ "renamed CORE_DEPLOYMENT_MODE, and only captured — set at any value, beside its new name or not, it fails boot",
310
323
  MEMORY_RATE_LIMITER_MAX_BUCKETS:
311
- "renamed CORE_RATE_LIMITER_MEMORY_MAX_BUCKETS, and only captured — set alone, or to another value, it fails boot",
324
+ "renamed CORE_RATE_LIMITER_MEMORY_MAX_BUCKETS, and only captured — set at any value, beside its new name or not, it fails boot",
312
325
  RATE_LIMIT_FAIL_MODE:
313
- "renamed REDIS_RATE_LIMITER_FAIL_MODE, and only captured — set alone, or to another value, it fails boot",
326
+ "renamed REDIS_RATE_LIMITER_FAIL_MODE, and only captured — set at any value, beside its new name or not, it fails boot",
314
327
  REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX:
315
- "renamed REDIS_REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX, and only captured — set alone, or to another value, it fails boot",
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",
316
329
  REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT:
317
- "renamed REDIS_REFRESH_TOKEN_FAMILY_STORE_CAS_RETRY_LIMIT, and only captured — set alone, or to another value, it fails boot",
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",
318
331
  FEDERATION_GRANTS_ENCRYPTION_MODE:
319
- "renamed REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE, and only captured — set alone, or to another value, it fails boot",
332
+ "renamed REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE, and only captured — set at any value, beside its new name or not, it fails boot",
320
333
  OAUTH_TOKEN_BINDING_DISPATCH_POLICY:
321
- "renamed CORE_TOKEN_BINDING_DISPATCH_POLICY, and only captured — set alone, or to another value, it fails boot",
334
+ "renamed CORE_TOKEN_BINDING_DISPATCH_POLICY, and only captured — set at any value, beside its new name or not, it fails boot",
322
335
  OAUTH_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS:
323
- "renamed CORE_TOKEN_BINDING_BIND_CONFIDENTIAL_CLIENT_REFRESH_TOKENS, and only captured — set alone, or to another value, it fails boot",
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",
324
337
  OAUTH_GRANTS_SESSION_ENABLED:
325
- "renamed OAUTH_SESSION_ENABLED, and only captured — set alone, or to another value, it fails boot",
338
+ "renamed OAUTH_SESSION_ENABLED, and only captured — set at any value, beside its new name or not, it fails boot",
326
339
  OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED:
327
- "renamed OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED, and only captured — set alone, or to another value, it fails boot",
340
+ "renamed OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED, and only captured — set at any value, beside its new name or not, it fails boot",
328
341
  OAUTH_GRANTS_REFRESH_TOKEN_ENABLED:
329
- "renamed OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED, and only captured — set alone, or to another value, it fails boot",
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
+ "renamed OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY, and only captured — set at any value, beside its new name or not, it fails boot",
330
345
  OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED:
331
- "renamed OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED, and only captured — set alone, or to another value, it fails boot",
346
+ "renamed OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED, and only captured — set at any value, beside its new name or not, it fails boot",
332
347
  OAUTH_GRANTS_JWT_BEARER_ENABLED:
333
- "renamed OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED, and only captured — set alone, or to another value, it fails boot",
348
+ "renamed OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED, and only captured — set at any value, beside its new name or not, it fails boot",
334
349
  ENDPOINTS_CONSENT_URL:
335
- "renamed OAUTH_CONSENT_PAGE_URL, and only captured — set alone, or to another value, it fails boot",
350
+ "renamed OAUTH_CONSENT_PAGE_URL, and only captured — set at any value, beside its new name or not, it fails boot",
336
351
  OAUTH_CIMD_ENABLED:
337
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED, and only captured — set alone, or to another value, it fails boot",
352
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED, and only captured — set at any value, beside its new name or not, it fails boot",
338
353
  OAUTH_CIMD_ALLOWED_SCOPES:
339
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_SCOPES, and only captured — set alone, or to another value, it fails boot",
354
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_SCOPES, and only captured — set at any value, beside its new name or not, it fails boot",
340
355
  OAUTH_CIMD_ALLOWED_AUDIENCES:
341
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_AUDIENCES, and only captured — set alone, or to another value, it fails boot",
356
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_AUDIENCES, and only captured — set at any value, beside its new name or not, it fails boot",
342
357
  OAUTH_CIMD_ALLOWED_HOSTS:
343
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_HOSTS, and only captured — set alone, or to another value, it fails boot",
358
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_HOSTS, and only captured — set at any value, beside its new name or not, it fails boot",
344
359
  OAUTH_CIMD_DENIED_HOSTS:
345
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_DENIED_HOSTS, and only captured — set alone, or to another value, it fails boot",
360
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_DENIED_HOSTS, and only captured — set at any value, beside its new name or not, it fails boot",
346
361
  OAUTH_CIMD_MAX_BYTES:
347
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_BYTES, and only captured — set alone, or to another value, it fails boot",
362
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_BYTES, and only captured — set at any value, beside its new name or not, it fails boot",
348
363
  OAUTH_CIMD_TIMEOUT_MS:
349
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_TIMEOUT_MS, and only captured — set alone, or to another value, it fails boot",
364
+ "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_TIMEOUT_MS, and only captured — set at any value, beside its new name or not, it fails boot",
350
365
  OAUTH_CIMD_CACHE_MAX_AGE_MS:
351
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_CACHE_MAX_AGE_MS, and only captured — set alone, or to another value, it fails boot",
366
+ "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",
352
367
  OAUTH_CIMD_MAX_CACHE_ENTRIES:
353
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CACHE_ENTRIES, and only captured — set alone, or to another value, it fails boot",
368
+ "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",
354
369
  OAUTH_CIMD_STALE_IF_ERROR_MS:
355
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_STALE_IF_ERROR_MS, and only captured — set alone, or to another value, it fails boot",
370
+ "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",
356
371
  OAUTH_CIMD_NEGATIVE_CACHE_MS:
357
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_NEGATIVE_CACHE_MS, and only captured — set alone, or to another value, it fails boot",
372
+ "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",
358
373
  OAUTH_CIMD_MAX_CONCURRENT_FETCHES:
359
- "renamed OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CONCURRENT_FETCHES, and only captured — set alone, or to another value, it fails boot",
374
+ "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",
360
375
  OAUTH_GRANTS_AUTHORIZATION_CODE_PKCE_REQUIRE_S256:
361
376
  "the authorization-code grant's pkce block was removed, and this is only captured — set at all, it fails boot",
362
377
  ENDPOINTS_LOGIN_URL:
363
- "renamed SESSION_LOGIN_PAGE_URL, and only captured — set alone, or to another value, it fails boot",
378
+ "renamed SESSION_LOGIN_PAGE_URL, and only captured — set at any value, beside its new name or not, it fails boot",
364
379
  SESSION_SECRET:
365
- "renamed SESSION_STORE_SECRET, and only captured — set alone, or to another value, it fails boot",
380
+ "renamed SESSION_STORE_SECRET, and only captured — set at any value, beside its new name or not, it fails boot",
366
381
  SESSION_NAME:
367
- "renamed SESSION_STORE_NAME, and only captured — set alone, or to another value, it fails boot",
382
+ "renamed SESSION_STORE_NAME, and only captured — set at any value, beside its new name or not, it fails boot",
368
383
  SESSION_MAX_AGE:
369
- "renamed SESSION_STORE_MAX_AGE, and only captured — set alone, or to another value, it fails boot",
384
+ "renamed SESSION_STORE_MAX_AGE, and only captured — set at any value, beside its new name or not, it fails boot",
370
385
  SESSION_SECURE:
371
- "renamed SESSION_STORE_SECURE, and only captured — set alone, or to another value, it fails boot",
386
+ "renamed SESSION_STORE_SECURE, and only captured — set at any value, beside its new name or not, it fails boot",
372
387
  SESSION_SAME_SITE:
373
- "renamed SESSION_STORE_SAME_SITE, and only captured — set alone, or to another value, it fails boot",
388
+ "renamed SESSION_STORE_SAME_SITE, and only captured — set at any value, beside its new name or not, it fails boot",
374
389
  SESSION_DOMAIN:
375
- "renamed SESSION_STORE_DOMAIN, and only captured — set alone, or to another value, it fails boot",
390
+ "renamed SESSION_STORE_DOMAIN, and only captured — set at any value, beside its new name or not, it fails boot",
376
391
  SESSION_STORAGE_TYPE:
377
- "renamed SESSION_STORE_STORAGE_TYPE, and only captured — set alone, or to another value, it fails boot",
392
+ "renamed SESSION_STORE_STORAGE_TYPE, and only captured — set at any value, beside its new name or not, it fails boot",
378
393
  SESSION_STORAGE_REDIS_URL:
379
- "renamed SESSION_STORE_STORAGE_REDIS_URL, and only captured — set alone, or to another value, it fails boot",
394
+ "renamed SESSION_STORE_STORAGE_REDIS_URL, and only captured — set at any value, beside its new name or not, it fails boot",
380
395
  SESSION_STORAGE_REDIS_PASSWORD:
381
- "renamed SESSION_STORE_STORAGE_REDIS_PASSWORD, and only captured — set alone, or to another value, it fails boot",
396
+ "renamed SESSION_STORE_STORAGE_REDIS_PASSWORD, and only captured — set at any value, beside its new name or not, it fails boot",
382
397
  LOG_LEVEL:
383
- "renamed LOGGING_LEVEL, and only captured — set alone, or to another value, it fails boot",
398
+ "renamed LOGGING_LEVEL, and only captured — set at any value, beside its new name or not, it fails boot",
384
399
  CORS_ALLOWED_ORIGINS:
385
- "renamed HTTP_CORS_ALLOWED_ORIGINS, and only captured — set alone, or to another value, it fails boot",
400
+ "renamed HTTP_CORS_ALLOWED_ORIGINS, and only captured — set at any value, beside its new name or not, it fails boot",
386
401
  OAUTH_JWT_SIGNING_KEY_PROVIDER:
387
- "renamed KEY_STORE_PROVIDER, and only captured — set alone, or to another value, it fails boot",
402
+ "renamed KEY_STORE_PROVIDER, and only captured — set at any value, beside its new name or not, it fails boot",
388
403
  OAUTH_JWT_ALGORITHM:
389
- "renamed KEY_STORE_LOCAL_ALGORITHM, and only captured — set alone, or to another value, it fails boot",
404
+ "renamed KEY_STORE_LOCAL_ALGORITHM, and only captured — set at any value, beside its new name or not, it fails boot",
390
405
  OAUTH_JWT_KID:
391
- "renamed KEY_STORE_LOCAL_KID, and only captured — set alone, or to another value, it fails boot",
406
+ "renamed KEY_STORE_LOCAL_KID, and only captured — set at any value, beside its new name or not, it fails boot",
392
407
  OAUTH_JWT_SECRET:
393
- "renamed KEY_STORE_LOCAL_SECRET, and only captured — set alone, or to another value, it fails boot",
408
+ "renamed KEY_STORE_LOCAL_SECRET, and only captured — set at any value, beside its new name or not, it fails boot",
394
409
  OAUTH_JWT_PRIVATE_KEY_PATH:
395
- "renamed KEY_STORE_LOCAL_PRIVATE_KEY_PATH, and only captured — set alone, or to another value, it fails boot",
410
+ "renamed KEY_STORE_LOCAL_PRIVATE_KEY_PATH, and only captured — set at any value, beside its new name or not, it fails boot",
396
411
  OAUTH_JWT_PUBLIC_KEY_PATH:
397
- "renamed KEY_STORE_LOCAL_PUBLIC_KEY_PATH, and only captured — set alone, or to another value, it fails boot",
412
+ "renamed KEY_STORE_LOCAL_PUBLIC_KEY_PATH, and only captured — set at any value, beside its new name or not, it fails boot",
398
413
  OAUTH_JWT_PRIVATE_KEY:
399
- "renamed KEY_STORE_LOCAL_PRIVATE_KEY, and only captured — set alone, or to another value, it fails boot",
414
+ "renamed KEY_STORE_LOCAL_PRIVATE_KEY, and only captured — set at any value, beside its new name or not, it fails boot",
400
415
  OAUTH_JWT_PUBLIC_KEY:
401
- "renamed KEY_STORE_LOCAL_PUBLIC_KEY, and only captured — set alone, or to another value, it fails boot",
416
+ "renamed KEY_STORE_LOCAL_PUBLIC_KEY, and only captured — set at any value, beside its new name or not, it fails boot",
402
417
  REFRESH_TOKEN_FAMILY_STORE_REDIS_URL:
403
- "renamed REDIS_CLIENTS_URL, and only captured — set alone, or to another value, it fails boot",
418
+ "renamed REDIS_CLIENTS_URL, and only captured — set at any value, beside its new name or not, it fails boot",
404
419
  REFRESH_TOKEN_FAMILY_STORE_REDIS_PASSWORD:
405
- "renamed REDIS_CLIENTS_PASSWORD, and only captured — set alone, or to another value, it fails boot",
420
+ "renamed REDIS_CLIENTS_PASSWORD, and only captured — set at any value, beside its new name or not, it fails boot",
406
421
  CLIENT_PATH:
407
- "renamed REPOSITORIES_CLIENT_YAML_PATH, and only captured — set alone, or to another value, it fails boot",
422
+ "renamed REPOSITORIES_CLIENT_YAML_PATH, and only captured — set at any value, beside its new name or not, it fails boot",
408
423
  CLIENT_USER_PATH:
409
- "renamed REPOSITORIES_USER_YAML_PATH, and only captured — set alone, or to another value, it fails boot",
424
+ "renamed REPOSITORIES_USER_YAML_PATH, and only captured — set at any value, beside its new name or not, it fails boot",
410
425
  CLIENT_USER_AUTHENTICATE_URL:
411
- "renamed REPOSITORIES_USER_HTTP_AUTHENTICATE_URL, and only captured — set alone, or to another value, it fails boot",
426
+ "renamed REPOSITORIES_USER_HTTP_AUTHENTICATE_URL, and only captured — set at any value, beside its new name or not, it fails boot",
412
427
  CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL:
413
- "renamed REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL, and only captured — set alone, or to another value, it fails boot",
428
+ "renamed REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL, and only captured — set at any value, beside its new name or not, it fails boot",
414
429
  CLIENT_USER_LINK_FEDERATED_IDENTITY_URL:
415
- "renamed REPOSITORIES_USER_HTTP_LINK_FEDERATED_IDENTITY_URL, and only captured — set alone, or to another value, it fails boot",
430
+ "renamed REPOSITORIES_USER_HTTP_LINK_FEDERATED_IDENTITY_URL, and only captured — set at any value, beside its new name or not, it fails boot",
416
431
  CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL:
417
- "renamed REPOSITORIES_USER_HTTP_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL, and only captured — set alone, or to another value, it fails boot",
432
+ "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",
418
433
  CLIENT_USER_BEARER_TOKEN:
419
- "renamed REPOSITORIES_USER_HTTP_BEARER_TOKEN, and only captured — set alone, or to another value, it fails boot",
434
+ "renamed REPOSITORIES_USER_HTTP_BEARER_TOKEN, and only captured — set at any value, beside its new name or not, it fails boot",
420
435
  CLIENT_USER_TIMEOUT:
421
- "renamed REPOSITORIES_USER_HTTP_TIMEOUT, and only captured — set alone, or to another value, it fails boot",
436
+ "renamed REPOSITORIES_USER_HTTP_TIMEOUT, and only captured — set at any value, beside its new name or not, it fails boot",
422
437
  CLIENT_USER_MAX_RESPONSE_BYTES:
423
- "renamed REPOSITORIES_USER_HTTP_MAX_RESPONSE_BYTES, and only captured — set alone, or to another value, it fails boot",
438
+ "renamed REPOSITORIES_USER_HTTP_MAX_RESPONSE_BYTES, and only captured — set at any value, beside its new name or not, it fails boot",
424
439
  CLIENT_CODE_DEFAULT_EXPIRES_IN:
425
- "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",
440
+ "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",
426
441
  CLIENT_CODE_KEY_PREFIX:
427
- "renamed REDIS_CODE_REPOSITORY_KEY_PREFIX, and only captured — set alone, or to another value, it fails boot",
442
+ "renamed REDIS_CODE_REPOSITORY_KEY_PREFIX, and only captured — set at any value, beside its new name or not, it fails boot",
428
443
  CLIENT_CODE_ENDPOINT_URI:
429
444
  "removed: the Redis code repository uses the shared redis-clients connection; only captured — set at all, it fails boot",
430
445
  CLIENT_CODE_PASSWORD:
431
446
  "removed: the Redis code repository uses the shared redis-clients connection; only captured — set at all, it fails boot",
432
447
  ENDPOINTS_MFA_URL:
433
- "renamed MFA_PAGE_URL, and only captured — set alone, or to another value, it fails boot where MFA is installed",
448
+ "renamed MFA_PAGE_URL, and only captured — set at any value, beside its new name or not, it fails boot where MFA is installed",
434
449
  MFA_TOTP_ENABLED:
435
- "renamed MFA_TOTP_FACTOR_ENABLED, and only captured — set alone, or to another value, it fails boot where MFA is installed",
450
+ "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",
436
451
  MFA_TOTP_ISSUER:
437
- "renamed MFA_TOTP_FACTOR_ISSUER, and only captured — set alone, or to another value, it fails boot where MFA is installed",
452
+ "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",
438
453
  };
439
454
 
440
455
  /**
441
- * The provider environment `o3co/auth`'s `tests/docker-compose.yml` sets,
442
- * transcribed: each renamed variable under its old and its new name, the two
443
- * at one value. The umbrella E2E boots the shipped template with exactly this,
444
- * so a parse failure here is a red umbrella build that this repository can see
445
- * first. `SESSION_STORE_SECURE=false` is the one it cannot run without: the
456
+ * The federation variables `config/application.conf` documents on lines it
457
+ * ships commented out — a key an operator binds by uncommenting it — as the
458
+ * string an operator would supply. They are not live substitutions, so they
459
+ * are not in `DOCUMENTED_ENV`; the federation cases below bind them as their
460
+ * comments write them (`commentedFederationBindings`).
461
+ */
462
+ const COMMENTED_FEDERATION_ENV: Readonly<Record<string, string>> = {
463
+ CORE_FEDERATIONS_GOOGLE_SESSION_DOMAIN: ".example.com",
464
+ CORE_FEDERATIONS_GOOGLE_AUTH_CALLBACK_URL: "https://app.example.com/auth/callback",
465
+ CORE_FEDERATIONS_GOOGLE_CLIENT_URL: "https://app.example.com/",
466
+ CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS: "false",
467
+ };
468
+
469
+ /**
470
+ * Each `# <key> = ${?CORE_FEDERATIONS_<NAME>_…}` line of the shipped
471
+ * `config/application.conf`, uncommented as an operator's layer above it:
472
+ * `core.federations.<name>.<key> = ${?…}`.
473
+ */
474
+ function commentedFederationBindings(): string {
475
+ const { applicationConfPath } = resolveConfigPaths(configDir, "production");
476
+ return readFileSync(applicationConfPath, "utf8")
477
+ .split("\n")
478
+ .flatMap((line) => {
479
+ const match =
480
+ /^\s*#\s*([A-Za-z]+) = \$\{\?(CORE_FEDERATIONS_([A-Z0-9]+)_[A-Z0-9_]+)\}\s*$/.exec(line);
481
+ if (match === null) return [];
482
+ const [, key, variable, name] = match as unknown as [string, string, string, string];
483
+ return [`core.federations.${name.toLowerCase()}.${key} = \${?${variable}}`];
484
+ })
485
+ .join("\n");
486
+ }
487
+
488
+ /**
489
+ * The provider environment `o3co/auth`'s `tests/docker-compose.yml` is to
490
+ * set, transcribed: each renamed variable under its new name alone, since an
491
+ * old name set refuses boot beside its new one too. The umbrella E2E boots
492
+ * the shipped template with this, so a parse failure here is a red umbrella
493
+ * build that this repository can see first. `SESSION_STORE_SECURE=false` is the one it cannot run without: the
446
494
  * suite speaks plain HTTP.
447
495
  *
448
496
  * The two `FEDERATION_TOKEN_STORE` lines are required: the federation token
@@ -451,37 +499,23 @@ const DELIBERATELY_UNSET: Readonly<Record<string, string>> = {
451
499
  * Redis store is selected with its encryption key.
452
500
  */
453
501
  const UMBRELLA_E2E_ENV: Readonly<Record<string, string>> = {
454
- OAUTH_JWT_ALGORITHM: "HS256",
455
502
  KEY_STORE_LOCAL_ALGORITHM: "HS256",
456
- OAUTH_JWT_SECRET: "e2e-shared-hs256-secret.at-least-32-bytes.ok",
457
503
  KEY_STORE_LOCAL_SECRET: "e2e-shared-hs256-secret.at-least-32-bytes.ok",
458
504
  OAUTH_JWT_ISSUER: "https://auth.e2e.test",
459
- SESSION_SECRET: "lO0QH09fuKSGuViZ9myJbH3jsgai99A2GpC3RYRuy6Y=",
460
505
  SESSION_STORE_SECRET: "lO0QH09fuKSGuViZ9myJbH3jsgai99A2GpC3RYRuy6Y=",
461
- SESSION_SECURE: "false",
462
506
  SESSION_STORE_SECURE: "false",
463
- SESSION_NAME: "auth.session",
464
507
  SESSION_STORE_NAME: "auth.session",
465
- DEPLOYMENT_MODE: "multi",
466
508
  CORE_DEPLOYMENT_MODE: "multi",
467
- REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis:6379",
468
509
  REDIS_CLIENTS_URL: "redis://redis:6379",
469
- SESSION_STORAGE_REDIS_URL: "redis://redis:6379",
470
510
  SESSION_STORE_STORAGE_REDIS_URL: "redis://redis:6379",
471
- USER_SESSION_STORES_ADAPTER: "redis",
472
511
  ADAPTERS_USER_SESSION_STORES: "redis",
473
- RATE_LIMITER_ADAPTER: "redis",
474
512
  ADAPTERS_RATE_LIMITER: "redis",
475
- OAUTH_CODE_ADAPTER: "redis",
476
513
  ADAPTERS_CODE_REPOSITORY: "redis",
477
- FEDERATION_TOKEN_STORE_TYPE: "redis",
478
514
  ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
479
515
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: "BwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwcHBwc=",
480
- CLIENT_USER_TYPE: "yaml",
481
516
  ADAPTERS_USER_REPOSITORY: "yaml",
482
517
  OAUTH_RESOURCE_INDICATOR_ENABLED: "true",
483
518
  OAUTH_REQUIRE_EMAIL_VERIFIED: "true",
484
- OAUTH_GRANTS_SESSION_ENABLED: "true",
485
519
  OAUTH_SESSION_ENABLED: "true",
486
520
  MFA_MODE: "off",
487
521
  };
@@ -507,34 +541,106 @@ function readShippedSwitches(env: Record<string, string>, configEnv = "productio
507
541
  const FEDERATION_STORES = Object.fromEntries(
508
542
  [
509
543
  "userSessionStore",
510
- "sessionRPRegistry",
511
- "sessionFamilyIndex",
512
- "sessionFederationIndex",
544
+ "sessionLifecycle",
513
545
  "federationTokenStore",
514
546
  "refreshTokenFamilyRevocation",
515
547
  ].map((key) => [key, {}]),
516
548
  );
517
549
 
550
+ /** What core handed a federation type for one entry: its callback, and its own keys as the type's schema parsed them. */
551
+ interface Dispatched {
552
+ readonly callbackURL: string;
553
+ readonly entry: Readonly<Record<string, unknown>>;
554
+ }
555
+
556
+ /** A federation type's declaration, as far as this suite reads it. */
557
+ interface TypeDeclaration {
558
+ readonly factory: (deps: unknown, instance: { name: string } & Dispatched) => unknown;
559
+ }
560
+
561
+ /**
562
+ * `module`, a federation type module, with each type's factory recording what
563
+ * core handed it into `dispatched`, by the entry's name, before building the
564
+ * provider as the module does. The schema, the parse and the provider are the
565
+ * module's own.
566
+ */
567
+ function recordingDispatch(module: Module, dispatched: Map<string, Dispatched>): Module {
568
+ const contributes = module.contributes as {
569
+ readonly federationTypes: Readonly<Record<string, TypeDeclaration>>;
570
+ };
571
+ const federationTypes = Object.fromEntries(
572
+ Object.entries(contributes.federationTypes).map(([type, declaration]) => [
573
+ type,
574
+ {
575
+ ...declaration,
576
+ factory: (deps: unknown, instance: { name: string } & Dispatched) => {
577
+ dispatched.set(instance.name, {
578
+ callbackURL: instance.callbackURL,
579
+ entry: instance.entry,
580
+ });
581
+ return declaration.factory(deps, instance);
582
+ },
583
+ },
584
+ ]),
585
+ );
586
+ return { ...module, contributes: { ...contributes, federationTypes } } as Module;
587
+ }
588
+
518
589
  /**
519
- * The shipped layers under `env`, as `app.mts` hands them to boot, and the
520
- * configuration boot parsed: phase one for what the composition expects of
521
- * session admission, phase two resolved over the reference of every package
522
- * the template's modules come from (the template's own and core's) and
523
- * parsed once by `createApp` — no bridge on the way.
524
- * No module is loaded unless `modules` names one: the parse is what this
590
+ * The upstream the documented OIDC federation's issuer names. Its type
591
+ * discovers the issuer at boot, so the module is handed this fake's `fetch`
592
+ * rather than the network. Made once per file.
593
+ */
594
+ let oidcUpstream: Promise<FakeIdp> | undefined;
595
+ const documentedOidcUpstream = (): Promise<FakeIdp> => {
596
+ oidcUpstream ??= createFakeIdp({
597
+ issuer: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_ISSUER as string,
598
+ discovery: true,
599
+ clientId: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_CLIENT_ID as string,
600
+ });
601
+ return oidcUpstream;
602
+ };
603
+
604
+ /**
605
+ * The shipped layers under `env`, as `app.mts` hands them to boot, booted
606
+ * with the federation types the template bundles: phase one for what the
607
+ * composition expects of session admission, phase two resolved over the
608
+ * reference of every package the template's modules come from (the
609
+ * template's own and core's) and parsed once by `createApp` — no bridge on
610
+ * the way. Core dispatches each enabled federation to its type, which parses
611
+ * the entry with its own schema; `dispatched` is what each type was handed.
612
+ * No other module is loaded unless `modules` names one: the parse is what this
525
613
  * suite asks about, and each key it reads is one core's schema declares, or
526
614
  * the section of a module it loads.
527
615
  */
528
- async function bootParsed(
616
+ /**
617
+ * The oauth module's section, read without its factories: the template always
618
+ * loads the module, and core refuses keys of `oauth {}` where no loaded
619
+ * module's section is `oauth`. What refuses `oauth {}` here is the module's
620
+ * own section — its schema, its removed keys and variables.
621
+ */
622
+ const oauthSectionReader = defineModule({
623
+ name: oauthEndpointsModule.name,
624
+ ...(oauthEndpointsModule.section === undefined ? {} : { section: oauthEndpointsModule.section }),
625
+ });
626
+
627
+ async function bootDispatched(
529
628
  env: Record<string, string>,
530
629
  configEnv = "production",
531
630
  operatorLayer?: string,
532
631
  modules: readonly Module[] = [],
533
- ): Promise<AppConfig> {
632
+ ): Promise<{ readonly parsed: AppConfig; readonly dispatched: ReadonlyMap<string, Dispatched> }> {
534
633
  const own = readOwnLayers(ownFiles(configEnv, operatorLayer), { env });
535
634
  const switches = readSwitches(own);
635
+ const dispatched = new Map<string, Dispatched>();
636
+ const upstream = await documentedOidcUpstream();
536
637
  const handle = await createApp({
537
- modules: [...modules],
638
+ modules: [
639
+ recordingDispatch(googleFederationTypeModule(), dispatched),
640
+ recordingDispatch(oidcFederationTypeModule({ fetch: upstream.fetch }), dispatched),
641
+ oauthSectionReader,
642
+ ...modules,
643
+ ],
538
644
  bootstrapComponents: {
539
645
  config: resolveForBoot(own, buildModules(switches, { environment: configEnv }), switches),
540
646
  pathResolver: (s: string) => s,
@@ -544,7 +650,17 @@ async function bootParsed(
544
650
  const parsed = handle.components.config;
545
651
  await handle.dispose();
546
652
  if (parsed === undefined) throw new Error("createApp booted without the parsed configuration");
547
- return parsed;
653
+ return { parsed, dispatched };
654
+ }
655
+
656
+ /** The configuration boot parsed, as {@link bootDispatched} boots it. */
657
+ async function bootParsed(
658
+ env: Record<string, string>,
659
+ configEnv = "production",
660
+ operatorLayer?: string,
661
+ modules: readonly Module[] = [],
662
+ ): Promise<AppConfig> {
663
+ return (await bootDispatched(env, configEnv, operatorLayer, modules)).parsed;
548
664
  }
549
665
 
550
666
  /** `session-store {}` as the session store's module parses it. */
@@ -560,6 +676,13 @@ function sessionStoreSection(config: AppConfig): {
560
676
  >;
561
677
  }
562
678
 
679
+ /** `federation-grants {}` as the federation-grants module parses it. */
680
+ function federationGrantsSection(config: AppConfig): { readonly enabled?: boolean } {
681
+ const schema = federationGrantsModule.section?.schema;
682
+ if (schema === undefined) throw new Error("the federation-grants module declares no section");
683
+ return schema.parse(config["federation-grants"]) as ReturnType<typeof federationGrantsSection>;
684
+ }
685
+
563
686
  /** `session {}` as the session module parses it. */
564
687
  function sessionSection(config: AppConfig): { readonly csrf?: { readonly ttlSeconds: number } } {
565
688
  const schema = sessionModule.section?.schema;
@@ -578,13 +701,10 @@ function httpSectionOf(config: AppConfig): {
578
701
  }
579
702
 
580
703
  /** `oauth {}` as the oauth module's own schema parses it. */
581
- function oauthSection(config: AppConfig): {
582
- readonly consentPage?: { readonly url: string };
583
- readonly clientIdMetadataDocuments?: Readonly<Record<string, unknown>>;
584
- } {
585
- const schema = oauthModule({ config }).section?.schema;
704
+ function oauthSection(config: AppConfig): OAuthSection {
705
+ const schema = oauthEndpointsModule.section?.schema;
586
706
  if (schema === undefined) throw new Error("the oauth module declares no section");
587
- return schema.parse(config.oauth) as ReturnType<typeof oauthSection>;
707
+ return schema.parse(config.oauth) as OAuthSection;
588
708
  }
589
709
 
590
710
  /** Every `${?VAR}` in a HOCON layer, ignoring commented-out lines. */
@@ -646,14 +766,19 @@ describe("the shipped config boots with every documented override supplied as a
646
766
  // Each of these arrives from HOCON as a string. A leftover string is
647
767
  // not a cosmetic defect: `=== true` is how the runtime reads them.
648
768
  expect(sessionStoreSection(config).secure).toBe(false);
649
- expect(config.oauth.jwt.legacyTypAccept).toBe(true);
650
- expect(config.oauth.requireEmailVerified).toBe(true);
651
- expect(config.oauth.resourceIndicator?.enabled).toBe(true);
769
+ expect(oauthSection(config).jwt.legacyTypAccept).toBe(true);
770
+ expect(oauthSection(config).requireEmailVerified).toBe(true);
771
+ expect(oauthSection(config).resourceIndicator?.enabled).toBe(true);
652
772
  expect(config.core?.federations?.google?.enabled).toBe(true);
653
773
  expect(config.core?.federations?.oidc?.enabled).toBe(true);
654
774
  // A leftover string here would be read as "on" by a truthiness check
655
775
  // and as "off" by `=== true`, for a feature whose whole default is off.
656
- expect(config["federation-grants"]?.enabled).toBe(true);
776
+ expect(federationGrantsSection(config).enabled).toBe(true);
777
+ expect(
778
+ standaloneRedisClientsModule.section?.schema.parse(
779
+ (config as Record<string, unknown>)["redis-clients"],
780
+ ),
781
+ ).toMatchObject({ assumeNoEviction: true });
657
782
  });
658
783
 
659
784
  it("turns every non-boolean override into its declared type", async () => {
@@ -665,17 +790,18 @@ describe("the shipped config boots with every documented override supplied as a
665
790
  expect(http.readinessTimeoutMs).toBe(1500);
666
791
  expect(http.trustProxy).toEqual(["10.0.0.0/8", "loopback"]);
667
792
  expect(config.core?.federations?.google?.accessType).toBe("online");
668
- // The new default wins over the deprecated variable, and the parsed
669
- // config mirrors it onto the old key for readers that predate the split.
670
- expect(resolveAccessTokenLifetime(config)).toEqual({
793
+ expect(resolveAccessTokenLifetime({ oauth: oauthSection(config) })).toEqual({
794
+ defaultExpiresIn: 900,
795
+ maxExpiresIn: 7200,
796
+ });
797
+ expect(oauthSection(config).accessToken).toEqual({
671
798
  defaultExpiresIn: 900,
672
799
  maxExpiresIn: 7200,
673
800
  });
674
- expect(config.oauth.accessToken.expiresIn).toBe(900);
675
- expect(config.oauth.refreshToken.expiresIn).toBe(86400);
801
+ expect(oauthSection(config).refreshToken.expiresIn).toBe(86400);
676
802
  expect(sessionStoreSection(config).maxAge).toBe(3600000);
677
803
  expect(sessionSection(config).csrf?.ttlSeconds).toBe(7200);
678
- expect(config.oauth.nonce?.maxLength).toBe(256);
804
+ expect(oauthSection(config).nonce?.maxLength).toBe(256);
679
805
  expect(readShippedSwitches(DOCUMENTED_ENV).adapters.consentStore).toBe("redis");
680
806
  // A Redis store's section, which its module (not loaded here) parses.
681
807
  const sections = config as unknown as Record<string, { keyPrefix?: unknown } | undefined>;
@@ -711,6 +837,133 @@ describe("the shipped config boots with every documented override supplied as a
711
837
  expect(http.cors.allowedOrigins).toEqual(["https://app.example.com", "http://localhost:5173"]);
712
838
  });
713
839
 
840
+ describe("the federations' variables, read by each federation's type", () => {
841
+ it("hands each enabled federation's type the entry its documented variables set", async () => {
842
+ const { dispatched } = await bootDispatched(DOCUMENTED_ENV);
843
+ expect(Object.fromEntries(dispatched)).toEqual({
844
+ google: {
845
+ callbackURL: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CALLBACK_URL,
846
+ entry: {
847
+ clientId: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID,
848
+ clientSecret: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET,
849
+ redirectAllowlist: [],
850
+ accessType: "online",
851
+ },
852
+ },
853
+ oidc: {
854
+ callbackURL: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_CALLBACK_URL,
855
+ entry: {
856
+ issuer: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_ISSUER,
857
+ clientId: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_CLIENT_ID,
858
+ clientSecret: DOCUMENTED_ENV.CORE_FEDERATIONS_OIDC_CLIENT_SECRET,
859
+ scopes: ["openid", "profile", "email"],
860
+ redirectAllowlist: [],
861
+ },
862
+ },
863
+ });
864
+ });
865
+
866
+ it("reads the Google keys the configuration documents commented out, bound as their comments write them", async () => {
867
+ const { dispatched } = await bootDispatched(
868
+ { ...DOCUMENTED_ENV, ...COMMENTED_FEDERATION_ENV },
869
+ "production",
870
+ commentedFederationBindings(),
871
+ );
872
+ expect(dispatched.get("google")?.entry).toEqual({
873
+ clientId: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID,
874
+ clientSecret: DOCUMENTED_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET,
875
+ redirectAllowlist: [],
876
+ accessType: "online",
877
+ sessionDomain: COMMENTED_FEDERATION_ENV.CORE_FEDERATIONS_GOOGLE_SESSION_DOMAIN,
878
+ authCallbackUrl: COMMENTED_FEDERATION_ENV.CORE_FEDERATIONS_GOOGLE_AUTH_CALLBACK_URL,
879
+ clientUrl: COMMENTED_FEDERATION_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_URL,
880
+ requireAuthorizationResponseIss: false,
881
+ });
882
+ // `toEqual` passes a key present as `undefined`: no variable binds
883
+ // `endSessionEndpoint`, so the entry must not carry the key at all.
884
+ expect(dispatched.get("google")?.entry).not.toHaveProperty("endSessionEndpoint");
885
+ });
886
+
887
+ it("hands the Google type no key the documented variables leave unset, not even as undefined", async () => {
888
+ const { dispatched } = await bootDispatched(DOCUMENTED_ENV);
889
+ const entry = dispatched.get("google")?.entry;
890
+ expect(entry).toBeDefined();
891
+ for (const key of [
892
+ "sessionDomain",
893
+ "authCallbackUrl",
894
+ "clientUrl",
895
+ "requireAuthorizationResponseIss",
896
+ "endSessionEndpoint",
897
+ ]) {
898
+ expect(entry, key).not.toHaveProperty(key);
899
+ }
900
+ });
901
+
902
+ it("binds every variable the configuration documents commented out", () => {
903
+ expect(
904
+ commentedFederationBindings()
905
+ .match(/\$\{\?[A-Z0-9_]+\}/g)
906
+ ?.sort(),
907
+ ).toEqual(
908
+ Object.keys(COMMENTED_FEDERATION_ENV)
909
+ .map((name) => `\${?${name}}`)
910
+ .sort(),
911
+ );
912
+ });
913
+
914
+ /** The entry the Google type was handed under `variables`, the commented keys bound. */
915
+ const googleEntryUnder = async (variables: Record<string, string>) =>
916
+ (
917
+ await bootDispatched(
918
+ { ...DOCUMENTED_ENV, ...variables },
919
+ "production",
920
+ commentedFederationBindings(),
921
+ )
922
+ ).dispatched.get("google")?.entry;
923
+
924
+ for (const [supplied, expected] of [
925
+ ["true", true],
926
+ ["TRUE", true],
927
+ ["1", true],
928
+ ["false", false],
929
+ ["False", false],
930
+ [" false ", false],
931
+ ["0", false],
932
+ ] as const) {
933
+ it(`CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS=${JSON.stringify(supplied)} reads as ${expected}`, async () => {
934
+ const entry = await googleEntryUnder({
935
+ CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS: supplied,
936
+ });
937
+ expect(entry?.requireAuthorizationResponseIss).toBe(expected);
938
+ });
939
+ }
940
+
941
+ it("refuses CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS exported empty or misspelt, naming the key, rather than turning the check off", async () => {
942
+ for (const supplied of ["", "no", "off", "ture"]) {
943
+ await expect(
944
+ googleEntryUnder({
945
+ CORE_FEDERATIONS_GOOGLE_REQUIRE_AUTHORIZATION_RESPONSE_ISS: supplied,
946
+ }),
947
+ supplied,
948
+ ).rejects.toThrow(/core\.federations\.google\.requireAuthorizationResponseIss/);
949
+ }
950
+ });
951
+
952
+ it("reads CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE as offline or online, exactly", async () => {
953
+ for (const supplied of ["offline", "online"]) {
954
+ expect(
955
+ (await googleEntryUnder({ CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE: supplied }))?.accessType,
956
+ ).toBe(supplied);
957
+ }
958
+ for (const supplied of ["", "Online", "offine", "true"]) {
959
+ await expect(
960
+ googleEntryUnder({ CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE: supplied }),
961
+ supplied,
962
+ ).rejects.toThrow(/core\.federations\.google\.accessType/);
963
+ }
964
+ });
965
+ });
966
+
714
967
  describe("HTTP_CORS_ALLOWED_ORIGINS", () => {
715
968
  it("reads an exported-but-empty variable as no origins, not as an error", async () => {
716
969
  // The .env / compose / ConfigMap shape. "CORS off" is what both the
@@ -802,8 +1055,8 @@ describe("the shipped config boots with every documented override supplied as a
802
1055
  it("parses the environment the umbrella E2E boots, with SESSION_STORE_SECURE=false as a string", async () => {
803
1056
  const config = await bootParsed(UMBRELLA_E2E_ENV);
804
1057
  expect(sessionStoreSection(config).secure).toBe(false);
805
- expect(config.oauth.requireEmailVerified).toBe(true);
806
- expect(config.oauth.resourceIndicator?.enabled).toBe(true);
1058
+ expect(oauthSection(config).requireEmailVerified).toBe(true);
1059
+ expect(oauthSection(config).resourceIndicator?.enabled).toBe(true);
807
1060
  expect(config.core?.deployment?.mode).toBe("multi");
808
1061
  });
809
1062
 
@@ -814,8 +1067,14 @@ describe("the shipped config boots with every documented override supplied as a
814
1067
  // list covers core's modules only, and cannot see the template's own
815
1068
  // memory modules.
816
1069
  const { replicaUnsafeReason } = await import("@o3co/auth-provider-core");
817
- for (const module of buildModules(readShippedSwitches(UMBRELLA_E2E_ENV))) {
818
- expect(replicaUnsafeReason(module), module.name).toBeUndefined();
1070
+ // Each module's section at its name: a declaration made from the
1071
+ // section is answered for it.
1072
+ const own = readOwnLayers(ownFiles("production"), { env: UMBRELLA_E2E_ENV });
1073
+ const switches = readSwitches(own);
1074
+ const modules = buildModules(switches);
1075
+ const sections = resolveForBoot(own, modules, switches) as unknown as Record<string, unknown>;
1076
+ for (const module of modules) {
1077
+ expect(replicaUnsafeReason(module, sections[module.name]), module.name).toBeUndefined();
819
1078
  }
820
1079
  });
821
1080
 
@@ -852,7 +1111,7 @@ describe("the shipped config boots with every documented override supplied as a
852
1111
  ...DOCUMENTED_ENV,
853
1112
  OAUTH_JWT_LEGACY_TYP_ACCEPT: supplied,
854
1113
  });
855
- expect(config.oauth.jwt.legacyTypAccept).toBe(expected);
1114
+ expect(oauthSection(config).jwt.legacyTypAccept).toBe(expected);
856
1115
  });
857
1116
  }
858
1117
 
@@ -916,13 +1175,13 @@ describe("the shipped config boots with every documented override supplied as a
916
1175
  });
917
1176
 
918
1177
  describe("variables whose documented behaviour is to fail boot", () => {
919
- it("refuses OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS, naming allowUnmarkedClients", async () => {
920
- await expect(
921
- bootParsed({
922
- ...DOCUMENTED_ENV,
923
- OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS: "true",
924
- }),
925
- ).rejects.toThrow(/allowUnmarkedClients/);
1178
+ it("refuses OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS as the variable of a removed key, naming allowUnmarkedClients", async () => {
1179
+ const booting = bootParsed({
1180
+ ...DOCUMENTED_ENV,
1181
+ OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS: "true",
1182
+ });
1183
+ await expect(booting).rejects.toMatchObject({ reason: "environment-variable-renamed" });
1184
+ await expect(booting).rejects.toThrow(/oauth\.authorize\.allowUnmarkedClients/);
926
1185
  });
927
1186
 
928
1187
  it("refuses an access-token default above the max, naming both keys", async () => {
@@ -938,30 +1197,69 @@ describe("the shipped config boots with every documented override supplied as a
938
1197
  for (const name of [
939
1198
  "OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
940
1199
  "OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN",
941
- "OAUTH_ACCESS_TOKEN_EXPIRES_IN",
942
1200
  ]) {
943
1201
  it(`refuses an empty ${name} rather than minting already-expired tokens`, async () => {
944
- await expect(bootParsed({ ...DOCUMENTED_ENV, [name]: "" })).rejects.toThrow();
1202
+ await expect(bootParsed({ ...DOCUMENTED_ENV, [name]: "" })).rejects.toMatchObject({
1203
+ reason: "config-validation-failed",
1204
+ });
945
1205
  });
946
1206
  }
947
1207
 
948
1208
  it("refuses MFA_MODE that is none of the three before boot, naming mfaMode", async () => {
949
1209
  const env = { ...DOCUMENTED_ENV, MFA_MODE: "on" };
950
- expect(() => readShippedSwitches(env)).toThrow(RangeError);
1210
+ expect(() => readShippedSwitches(env)).toThrow(BootError);
951
1211
  await expect(bootParsed(env)).rejects.toThrow(/mfaMode/);
952
1212
  });
953
1213
 
954
1214
  for (const mode of ["optional", "required"] as const) {
955
1215
  it(`reads MFA_MODE=${mode} as the switch that installs MFA, expecting mfa`, () => {
956
- const switches = readShippedSwitches({ ...DOCUMENTED_ENV, MFA_MODE: mode });
1216
+ const own = readOwnLayers(ownFiles("production"), {
1217
+ env: { ...DOCUMENTED_ENV, MFA_MODE: mode },
1218
+ });
1219
+ const switches = readSwitches(own);
957
1220
  expect(switches.mfaMode).toBe(mode);
958
- expect(expectedSessionRequirements(switches)).toEqual({
1221
+ const shipped = (resolveLayers(own, []).core as { sessionRequirements?: unknown })
1222
+ .sessionRequirements;
1223
+ expect(expectedSessionRequirements(shipped, switches.mfaMode)).toEqual({
959
1224
  expected: ["mfa"],
960
1225
  secondFactorAuthority: "mfa",
961
1226
  });
962
1227
  });
963
1228
  }
964
1229
 
1230
+ it("refuses OAUTH_ACCESS_TOKEN_EXPIRES_IN set alone, naming OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN", async () => {
1231
+ const { OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: _new, ...env } = DOCUMENTED_ENV;
1232
+ const booting = bootParsed({ ...env, OAUTH_ACCESS_TOKEN_EXPIRES_IN: "900" });
1233
+ await expect(booting).rejects.toMatchObject({ reason: "environment-variable-renamed" });
1234
+ await expect(booting).rejects.toThrow(
1235
+ /OAUTH_ACCESS_TOKEN_EXPIRES_IN was renamed OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN/,
1236
+ );
1237
+ });
1238
+
1239
+ it("refuses OAUTH_ACCESS_TOKEN_EXPIRES_IN beside its new name set to another value", async () => {
1240
+ await expect(
1241
+ bootParsed({ ...DOCUMENTED_ENV, OAUTH_ACCESS_TOKEN_EXPIRES_IN: "3600" }),
1242
+ ).rejects.toMatchObject({ reason: "environment-variable-renamed" });
1243
+ });
1244
+
1245
+ it("refuses OAUTH_ACCESS_TOKEN_EXPIRES_IN beside its new name set to the same value", async () => {
1246
+ const booting = bootParsed({
1247
+ ...DOCUMENTED_ENV,
1248
+ OAUTH_ACCESS_TOKEN_EXPIRES_IN:
1249
+ DOCUMENTED_ENV.OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN as string,
1250
+ });
1251
+ await expect(booting).rejects.toMatchObject({ reason: "environment-variable-renamed" });
1252
+ await expect(booting).rejects.toThrow(/unset OAUTH_ACCESS_TOKEN_EXPIRES_IN/);
1253
+ });
1254
+
1255
+ it("boots with OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN alone, reading it", async () => {
1256
+ const config = await bootParsed(DOCUMENTED_ENV);
1257
+ expect(oauthSection(config).accessToken).toEqual({
1258
+ defaultExpiresIn: 900,
1259
+ maxExpiresIn: 7200,
1260
+ });
1261
+ });
1262
+
965
1263
  it("refuses DEPLOYMENT_MODE set alone, naming CORE_DEPLOYMENT_MODE", async () => {
966
1264
  const { CORE_DEPLOYMENT_MODE: _new, ...env } = DOCUMENTED_ENV;
967
1265
  await expect(bootParsed({ ...env, DEPLOYMENT_MODE: "multi" })).rejects.toThrow(