@o3co/create-auth-provider 0.16.0 → 0.17.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 (90) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +59 -26
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +293 -162
  14. package/templates/standalone/README.md +533 -199
  15. package/templates/standalone/config/application.conf +196 -341
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +19 -20
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +216 -141
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +48 -33
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +77 -58
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +656 -269
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +425 -84
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +91 -70
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +7 -5
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +22 -19
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +53 -42
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +7 -11
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +153 -142
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -252
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +91 -50
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +64 -79
  73. package/templates/standalone/src/buildModules.mts +212 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +12 -18
  76. package/templates/standalone/src/logger.mts +31 -60
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +458 -476
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +8 -14
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +45 -123
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
@@ -19,43 +19,38 @@
19
19
  * template can turn on, switched on together from the shipped HOCON, and
20
20
  * mounted the way `app.mts` mounts it.
21
21
  *
22
- * What is real: the configuration (`config/*.conf` over core's
23
- * `reference.conf`, resolved with the environment a deployment would export),
24
- * `buildModules`, core's `createApp`, every module and store it selects, the
25
- * key store, the audit sink, `helmet` and the terminal error handler.
22
+ * Real: the configuration (`config/*.conf` over every loaded package's
23
+ * `reference.conf`, core's last, under the environment a deployment would
24
+ * export) read in `app.mts`'s two phases (`readSwitches`, then
25
+ * `resolveForBoot` for `createApp`), `buildModules`, core's `createApp`, every
26
+ * module and store it selects, the key store, the audit sink, `helmet` and the
27
+ * terminal error handler.
26
28
  *
27
- * What is substituted, and why:
29
+ * Substituted (the first two are overrides `buildModules` offers):
30
+ * - client and user repositories: in memory, not YAML off disk;
31
+ * - the refresh-token family store under `core.deployment.mode = "single"`: memory,
32
+ * not the shipped Redis, so the single-replica boot opens no sockets;
33
+ * - upstream identity providers: core's fake OpenID Provider, through the
34
+ * `fetch` option the Google and OIDC adapters take; the config bridges'
35
+ * values are read as shipped (`googleFederationConfigModule`, the OIDC
36
+ * package's reader), with only `fetch` added;
37
+ * - configuration with no environment form (grant connections, a key ring, a
38
+ * landing URL): one more HOCON layer above the shipped ones.
28
39
  *
29
- * - The client and user repositories — `repositoriesModule` reads YAML files
30
- * off disk; the in-memory repositories carry the registrations the flows
31
- * need. `buildModules` offers this override for the purpose.
32
- * - The refresh-token family store under `deployment.mode = "single"` — the
33
- * shipped composition always puts it on Redis; the memory store keeps the
34
- * single-replica boot free of sockets. `buildModules` offers this override.
35
- * - The upstream identity providers — core's fake OpenID Provider behind a
36
- * `fetch`, handed to the Google and OIDC adapters through the `fetch` option
37
- * each adapter takes for exactly this. The config bridges' values are read
38
- * through the template's own bridge (`googleFederationConfigModule`) and the
39
- * OIDC package's reader, and only `fetch` is added.
40
- * - Configuration with no environment form (a map of grant connections, a key
41
- * ring, a landing URL): laid over the resolved config, as an operator would
42
- * write it in `application.conf`.
43
- *
44
- * `tools/composition` in the monorepo imports this file and boots the same
40
+ * `tools/composition` in the monorepo imports this file to boot the same
45
41
  * composition with the workspace's other modules added (`ComposeOptions`'s
46
- * `referenceConfs`, `extraModules`, `extraOverrides`, `extraClients` and
47
- * `extraUsers`), so the two suites share one fixture rather than two copies
48
- * that drift.
42
+ * `extra*` options), so the two suites share one fixture.
49
43
  */
50
44
 
51
45
  import { createHash, generateKeyPairSync, randomBytes } from "node:crypto";
52
- import { readFileSync } from "node:fs";
46
+ import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
53
47
  import http from "node:http";
54
48
  import type { AddressInfo } from "node:net";
49
+ import { tmpdir } from "node:os";
50
+ import { join } from "node:path";
55
51
  import { fileURLToPath } from "node:url";
56
52
  import {
57
53
  type AppConfig,
58
- AppConfigSchema,
59
54
  ClientEntrySchema,
60
55
  createApp,
61
56
  defineModule,
@@ -67,14 +62,19 @@ import {
67
62
  terminalErrorHandler,
68
63
  } from "@o3co/auth-provider-core";
69
64
  import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
70
- import { parseFile } from "@o3co/ts.hocon";
71
- import { validate } from "@o3co/ts.hocon/zod";
72
65
  import express from "express";
73
66
  import helmet from "helmet";
74
67
  import request from "supertest";
75
68
  import { beforeAll, describe, expect, it } from "vitest";
76
69
  import { buildModules } from "#/buildModules.mjs";
77
- import { resolveConfigPaths, resolveLibraryReferenceConfPath } from "#/configPath.mjs";
70
+ import {
71
+ type OwnLayers,
72
+ readOwnLayers,
73
+ readSwitches,
74
+ resolveConfigPaths,
75
+ resolveForBoot,
76
+ type Switches,
77
+ } from "#/configPath.mjs";
78
78
  import { googleFederationConfigModule, oidcFederationConfigModule } from "#/modules.mjs";
79
79
 
80
80
  export const ISSUER = "https://auth.test";
@@ -93,47 +93,49 @@ const ENCRYPTION_KEY = Buffer.alloc(32, 7).toString("base64");
93
93
  * One replica, every store in memory, every feature the template can switch
94
94
  * on switched on: the four token grants, the consent step with Client ID
95
95
  * Metadata Documents, federation grants, the Google federation and the
96
- * shipped generic OIDC one.
96
+ * shipped generic OIDC one. MFA, on by default, is switched off: the suites
97
+ * about MFA turn it on with `MFA_MODE`.
97
98
  */
98
99
  export const SINGLE_ENV: Readonly<Record<string, string>> = {
100
+ MFA_MODE: "off",
99
101
  OAUTH_JWT_ISSUER: ISSUER,
100
102
  // The shipped default algorithm (EdDSA), with the key pair inline.
101
- OAUTH_JWT_PRIVATE_KEY: signingKey.privateKey,
102
- OAUTH_JWT_PUBLIC_KEY: signingKey.publicKey,
103
- SESSION_SECRET: "all-modules-composition-session.at-least-32-bytes.ok",
104
- SESSION_SECURE: "false",
105
- SESSION_NAME: "auth.session",
106
- DEPLOYMENT_MODE: "single",
107
- SESSION_STORAGE_TYPE: "memory",
108
- USER_SESSION_STORES_ADAPTER: "memory",
109
- RATE_LIMITER_ADAPTER: "memory",
110
- OAUTH_CODE_ADAPTER: "memory",
111
- ACCESS_TOKEN_DENYLIST_ADAPTER: "memory",
112
- REPLAY_SEEN_SET_ADAPTER: "memory",
113
- FEDERATION_TOKEN_STORE_TYPE: "memory",
114
- CONSENT_STORE_ADAPTER: "memory",
115
- FEDERATION_GRANT_STORE_ADAPTER: "memory",
116
- FEDERATION_GRANT_INTENT_STORE_ADAPTER: "memory",
103
+ KEY_STORE_LOCAL_PRIVATE_KEY: signingKey.privateKey,
104
+ KEY_STORE_LOCAL_PUBLIC_KEY: signingKey.publicKey,
105
+ SESSION_STORE_SECRET: "all-modules-composition-session.at-least-32-bytes.ok",
106
+ SESSION_STORE_SECURE: "false",
107
+ SESSION_STORE_NAME: "auth.session",
108
+ CORE_DEPLOYMENT_MODE: "single",
109
+ SESSION_STORE_STORAGE_TYPE: "memory",
110
+ ADAPTERS_USER_SESSION_STORES: "memory",
111
+ ADAPTERS_RATE_LIMITER: "memory",
112
+ ADAPTERS_CODE_REPOSITORY: "memory",
113
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "memory",
114
+ ADAPTERS_REPLAY_SEEN_SET: "memory",
115
+ ADAPTERS_FEDERATION_TOKEN_STORE: "memory",
116
+ ADAPTERS_CONSENT_STORE: "memory",
117
+ ADAPTERS_FEDERATION_GRANT_STORE: "memory",
118
+ ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "memory",
117
119
  // The user repository is replaced (see the header); `yaml` keeps the
118
120
  // shipped `http` adapter's URL requirements out of config validation.
119
- CLIENT_USER_TYPE: "yaml",
120
- OAUTH_GRANTS_SESSION_ENABLED: "true",
121
- OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED: "true",
122
- OAUTH_GRANTS_REFRESH_TOKEN_ENABLED: "true",
123
- OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED: "true",
124
- OAUTH_CIMD_ENABLED: "true",
121
+ ADAPTERS_USER_REPOSITORY: "yaml",
122
+ OAUTH_SESSION_ENABLED: "true",
123
+ OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED: "true",
124
+ OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED: "true",
125
+ OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED: "true",
126
+ OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED: "true",
125
127
  FEDERATION_GRANTS_ENABLED: "true",
126
128
  FEDERATION_GRANTS_CONSENT_URL: "/consent/grants",
127
129
  // The in-memory user repository covers no registration; `required` is a
128
130
  // Store's statement that it does (see the federation-grants README).
129
131
  FEDERATION_GRANTS_IDENTITY_LOOKUP: "unsupported",
130
- FEDERATIONS_GOOGLE_ENABLED: "true",
131
- FEDERATIONS_GOOGLE_CLIENT_ID: "google-client",
132
- FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-secret",
133
- FEDERATIONS_OIDC_ENABLED: "true",
134
- FEDERATIONS_OIDC_ISSUER: OIDC_ISSUER,
135
- FEDERATIONS_OIDC_CLIENT_ID: "oidc-client",
136
- FEDERATIONS_OIDC_CLIENT_SECRET: "oidc-secret",
132
+ CORE_FEDERATIONS_GOOGLE_ENABLED: "true",
133
+ CORE_FEDERATIONS_GOOGLE_CLIENT_ID: "google-client",
134
+ CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET: "google-secret",
135
+ CORE_FEDERATIONS_OIDC_ENABLED: "true",
136
+ CORE_FEDERATIONS_OIDC_ISSUER: OIDC_ISSUER,
137
+ CORE_FEDERATIONS_OIDC_CLIENT_ID: "oidc-client",
138
+ CORE_FEDERATIONS_OIDC_CLIENT_SECRET: "oidc-secret",
137
139
  };
138
140
 
139
141
  /**
@@ -142,20 +144,20 @@ export const SINGLE_ENV: Readonly<Record<string, string>> = {
142
144
  */
143
145
  export const MULTI_ENV: Readonly<Record<string, string>> = {
144
146
  ...SINGLE_ENV,
145
- DEPLOYMENT_MODE: "multi",
146
- SESSION_STORAGE_TYPE: "redis",
147
- SESSION_STORAGE_REDIS_URL: "redis://redis.test:6379",
148
- REFRESH_TOKEN_FAMILY_STORE_REDIS_URL: "redis://redis.test:6379",
149
- USER_SESSION_STORES_ADAPTER: "redis",
150
- RATE_LIMITER_ADAPTER: "redis",
151
- OAUTH_CODE_ADAPTER: "redis",
152
- ACCESS_TOKEN_DENYLIST_ADAPTER: "redis",
153
- REPLAY_SEEN_SET_ADAPTER: "redis",
154
- FEDERATION_TOKEN_STORE_TYPE: "redis",
147
+ CORE_DEPLOYMENT_MODE: "multi",
148
+ SESSION_STORE_STORAGE_TYPE: "redis",
149
+ SESSION_STORE_STORAGE_REDIS_URL: "redis://redis.test:6379",
150
+ REDIS_CLIENTS_URL: "redis://redis.test:6379",
151
+ ADAPTERS_USER_SESSION_STORES: "redis",
152
+ ADAPTERS_RATE_LIMITER: "redis",
153
+ ADAPTERS_CODE_REPOSITORY: "redis",
154
+ ADAPTERS_ACCESS_TOKEN_DENYLIST: "redis",
155
+ ADAPTERS_REPLAY_SEEN_SET: "redis",
156
+ ADAPTERS_FEDERATION_TOKEN_STORE: "redis",
155
157
  REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY: ENCRYPTION_KEY,
156
- CONSENT_STORE_ADAPTER: "redis",
157
- FEDERATION_GRANT_STORE_ADAPTER: "redis",
158
- FEDERATION_GRANT_INTENT_STORE_ADAPTER: "redis",
158
+ ADAPTERS_CONSENT_STORE: "redis",
159
+ ADAPTERS_FEDERATION_GRANT_STORE: "redis",
160
+ ADAPTERS_FEDERATION_GRANT_INTENT_STORE: "redis",
159
161
  };
160
162
 
161
163
  /** The grant connection the federation-grant flows use, on the shipped `oidc` federation. */
@@ -164,45 +166,61 @@ export const CONNECTION = "calendar";
164
166
  const configDir = fileURLToPath(new URL("../../config", import.meta.url));
165
167
 
166
168
  /**
167
- * The shipped HOCON under `env`, with what has no environment form laid over
168
- * it: the federations' landing page, the grant key ring and one grant
169
- * connection. `referenceConfs` — other packages' `reference.conf` files — are
170
- * layered between `application.conf` and core's, as a deployment layers them.
169
+ * What has no environment form — the federations' landing page, the grant
170
+ * key ring and one grant connection — as an operator writes it: a HOCON
171
+ * file of their own, the highest layer. Written once per process.
172
+ */
173
+ const OPERATOR_LAYER: string = (() => {
174
+ const file = join(mkdtempSync(join(tmpdir(), "all-modules-composition-")), "operator.conf");
175
+ const quoted = (value: string) => JSON.stringify(value);
176
+ writeFileSync(
177
+ file,
178
+ `core.federations.google.clientUrl = ${quoted(FEDERATION_LANDING)}
179
+ core.federations.oidc.clientUrl = ${quoted(FEDERATION_LANDING)}
180
+ redis-federation-grant-store.encryptionKeys = [{ id = "k-test", key = ${quoted(ENCRYPTION_KEY)} }]
181
+ federation-grants {
182
+ connections {
183
+ ${CONNECTION} {
184
+ federation = "oidc"
185
+ scopes = ["openid", "offline_access", "calendar.read"]
186
+ boundary = "production"
187
+ maxAccessTokenLifetime = 3600
188
+ callbackURL = ${quoted(`${ISSUER}/session/federation-grants/callback/${CONNECTION}`)}
189
+ }
190
+ }
191
+ }
192
+ `,
193
+ );
194
+ return file;
195
+ })();
196
+
197
+ /** `text` in a file of its own, for a layer above the composition's files. */
198
+ function hoconFile(text: string): string {
199
+ const file = join(mkdtempSync(join(tmpdir(), "all-modules-operator-")), "operator.conf");
200
+ writeFileSync(file, text);
201
+ return file;
202
+ }
203
+
204
+ /** The composition's own files, highest first: the operator's layer, then the shipped production ones. */
205
+ export function ownFiles(): string[] {
206
+ const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
207
+ return [OPERATOR_LAYER, envConfPath, applicationConfPath];
208
+ }
209
+
210
+ /**
211
+ * Phase one, as `app.mts` reads it: the switches `buildModules` chooses
212
+ * the modules by — and `reads`, what a module added to the composition reads
213
+ * when it is built — from the composition's own files under `env` over core's
214
+ * `reference.conf`. What the composition expects of session admission is
215
+ * derived from it (`resolveForBoot`), after `config` adjusts it as an
216
+ * operator's layer would.
171
217
  */
172
218
  export function resolveConfig(
173
219
  env: Readonly<Record<string, string>>,
174
- referenceConfs: readonly string[] = [],
175
- ): AppConfig {
176
- const { applicationConfPath, envConfPath } = resolveConfigPaths(configDir, "production");
177
- const read = (path: string) => parseFile(path, { env: { ...env } });
178
- const layered = [
179
- applicationConfPath,
180
- ...referenceConfs,
181
- resolveLibraryReferenceConfPath(),
182
- ].reduce((config, path) => config.withFallback(read(path)), read(envConfPath));
183
- const resolved = validate(layered, AppConfigSchema);
184
- const federations = resolved.federations as Record<string, Record<string, unknown>>;
185
- return {
186
- ...resolved,
187
- federations: {
188
- ...federations,
189
- google: { ...federations.google, clientUrl: FEDERATION_LANDING },
190
- oidc: { ...federations.oidc, clientUrl: FEDERATION_LANDING },
191
- },
192
- federationGrants: {
193
- ...resolved.federationGrants,
194
- encryptionKeys: [{ id: "k-test", key: ENCRYPTION_KEY }],
195
- connections: {
196
- [CONNECTION]: {
197
- federation: "oidc",
198
- scopes: ["openid", "offline_access", "calendar.read"],
199
- boundary: "production",
200
- maxAccessTokenLifetime: 3600,
201
- callbackURL: `${ISSUER}/session/federation-grants/callback/${CONNECTION}`,
202
- },
203
- },
204
- },
205
- } as unknown as AppConfig;
220
+ reads: readonly string[] = [],
221
+ own: OwnLayers = readOwnLayers(ownFiles(), { env }),
222
+ ): Switches {
223
+ return readSwitches(own, { reads });
206
224
  }
207
225
 
208
226
  // ---------------------------------------------------------------------------
@@ -337,20 +355,19 @@ export async function sharedUpstreams(): Promise<Upstreams> {
337
355
  }
338
356
 
339
357
  /**
340
- * Snapshots each fake's settings — every property that is not a function —
341
- * and returns what puts them back: primitives reassigned, objects restored in
358
+ * Snapshots each fake's settings (every property that is not a function) and
359
+ * returns what puts them back: primitives reassigned, objects restored in
342
360
  * place (a fake may hold its own reference to one), recorded requests
343
361
  * cleared. A fake's signing key is checked, not restored: a rotated key is
344
362
  * refused.
345
363
  *
346
- * What a fake keeps in its closure is not restored either: core's fake IdP
347
- * remembers whether consent was granted (`consentGranted`), how many codes it
348
- * issued (`codesIssued`) and every authorization it recorded. Codes and
349
- * authorizations are keyed per login, so a later boot's login is unaffected;
350
- * consent is not, and it decides Google's refresh token only under
351
- * `refreshTokenOnlyOnConsent`. That knob is therefore forbidden on a shared
352
- * fake — the next reset refuses to run if a test set it; a test that needs it
353
- * makes a fake of its own with `createFakeIdp`.
364
+ * A fake's closure is not restored: core's fake IdP remembers whether consent
365
+ * was granted (`consentGranted`), how many codes it issued (`codesIssued`) and
366
+ * every authorization it recorded.
367
+ * Codes and authorizations are keyed per login; consent is not, and it decides
368
+ * Google's refresh token only under `refreshTokenOnlyOnConsent`. That knob is
369
+ * therefore forbidden on a shared fake (the next reset refuses to run if a test
370
+ * set it); a test that needs it makes its own fake with `createFakeIdp`.
354
371
  */
355
372
  export function resettable(...fakes: readonly object[]): () => void {
356
373
  const snapshots = fakes.map((fake) => ({
@@ -399,7 +416,7 @@ async function createUpstreams(): Promise<Upstreams> {
399
416
  oidc: await createFakeIdp({
400
417
  issuer: OIDC_ISSUER,
401
418
  discovery: true,
402
- clientId: SINGLE_ENV.FEDERATIONS_OIDC_CLIENT_ID,
419
+ clientId: SINGLE_ENV.CORE_FEDERATIONS_OIDC_CLIENT_ID,
403
420
  sub: OIDC_SUB,
404
421
  }),
405
422
  // Google's endpoints are fixed in the adapter, not discovered.
@@ -409,7 +426,7 @@ async function createUpstreams(): Promise<Upstreams> {
409
426
  tokenEndpoint: "https://oauth2.googleapis.com/token",
410
427
  jwksUri: "https://www.googleapis.com/oauth2/v3/certs",
411
428
  userinfoEndpoint: "https://www.googleapis.com/oauth2/v3/userinfo",
412
- clientId: SINGLE_ENV.FEDERATIONS_GOOGLE_CLIENT_ID,
429
+ clientId: SINGLE_ENV.CORE_FEDERATIONS_GOOGLE_CLIENT_ID,
413
430
  sub: GOOGLE_SUB,
414
431
  }),
415
432
  };
@@ -434,7 +451,7 @@ const bridged = <T,>(module: Module, slot: string, config: AppConfig): T => {
434
451
  * config enables — which is when `buildModules` lists the bridges.
435
452
  */
436
453
  async function federationOverrides(
437
- config: AppConfig,
454
+ config: Switches,
438
455
  upstreams: Upstreams,
439
456
  ): Promise<Record<string, unknown>> {
440
457
  const overrides: Record<string, unknown> = {};
@@ -567,18 +584,36 @@ export type ModuleOrder = typeof AS_LISTED | typeof REVERSED;
567
584
 
568
585
  export interface ComposeOptions {
569
586
  readonly env?: Readonly<Record<string, string>>;
570
- /** Other packages' `reference.conf` files, layered above core's (see `resolveConfig`). */
571
- readonly referenceConfs?: readonly string[];
587
+ /** The name the configuration was selected by, as `app.mts` hands `buildModules` it; `production` by default. */
588
+ readonly environment?: string;
589
+ /** HOCON an operator writes above the composition's own files, read in both phases. */
590
+ readonly operatorHocon?: string;
591
+ /**
592
+ * Paths read before boot beside the template's switches: what a module
593
+ * `extraModules` adds reads when it is built (`readSwitches`'s `reads`).
594
+ */
595
+ readonly reads?: readonly string[];
596
+ /** The deployment's own mail sender modules, handed to `buildModules` as `mailSenderModules`. */
597
+ readonly mailSenderModules?: readonly Module[];
572
598
  /** Modules added after the template's own, before the order and the outage apply. */
573
- readonly extraModules?: (config: AppConfig) => readonly Module[];
599
+ readonly extraModules?: (config: Switches) => readonly Module[];
574
600
  /** Components laid over the boot's, beside the federation config slots. */
575
- readonly extraOverrides?: (config: AppConfig) => Record<string, unknown>;
601
+ readonly extraOverrides?: (config: Switches) => Record<string, unknown>;
576
602
  /** Client registrations beside the fixture's own, as `ClientEntrySchema` input. */
577
603
  readonly extraClients?: Readonly<Record<string, Record<string, unknown>>>;
578
604
  /** Users beside the fixture's own, keyed by username. */
579
605
  readonly extraUsers?: Readonly<Record<string, Record<string, unknown>>>;
580
- /** Adjust the resolved config before anything reads it. */
581
- readonly config?: (config: AppConfig) => AppConfig;
606
+ /**
607
+ * Adjust the configuration before anything reads it, as an operator's own
608
+ * layer would: applied to phase one's switches, and to what `createApp` is
609
+ * handed, as resolved.
610
+ */
611
+ readonly config?: (config: Switches) => Switches;
612
+ /**
613
+ * Adjust phase one's switches alone, after `config`: what only phase one
614
+ * reads, such as the Store transport settings a hand-built root passes.
615
+ */
616
+ readonly switches?: (switches: Switches) => Switches;
582
617
  readonly order?: ModuleOrder;
583
618
  readonly outage?: { readonly slot: string; readonly outage: Outage };
584
619
  /** Keep the shipped Redis refresh-token family store (the `multi` boot). */
@@ -593,11 +628,14 @@ export interface ComposeOptions {
593
628
  }
594
629
 
595
630
  /** The module list the template boots for `config`, as `app.mts` builds it. */
596
- export function composedModules(config: AppConfig, options: ComposeOptions = {}): Module[] {
631
+ export function composedModules(config: Switches, options: ComposeOptions = {}): Module[] {
597
632
  let modules = [
598
633
  ...buildModules(config, {
599
- environment: "production",
634
+ environment: options.environment ?? "production",
600
635
  repositoriesModule: testRepositoriesModule(options.extraClients, options.extraUsers),
636
+ ...(options.mailSenderModules === undefined
637
+ ? {}
638
+ : { mailSenderModules: options.mailSenderModules }),
601
639
  ...(options.shippedRefreshTokenFamilyStore
602
640
  ? {}
603
641
  : { refreshTokenFamilyModules: [memoryRefreshTokenFamilyStoreModule] }),
@@ -612,7 +650,10 @@ export function composedModules(config: AppConfig, options: ComposeOptions = {})
612
650
  export interface Composition {
613
651
  readonly app: express.Express;
614
652
  readonly handle: Awaited<ReturnType<typeof createApp>>;
653
+ /** The configuration boot parsed: what the handle's `config` slot holds. */
615
654
  readonly config: AppConfig;
655
+ /** What `createApp` was handed: the configuration as resolved (phase two), unparsed. */
656
+ readonly resolved: AppConfig;
616
657
  readonly modules: readonly Module[];
617
658
  readonly logger: RecordingLogger;
618
659
  readonly upstreams: Upstreams;
@@ -625,21 +666,43 @@ export interface Composition {
625
666
  * boot's `logger` component.
626
667
  */
627
668
  export async function compose(options: ComposeOptions = {}): Promise<Composition> {
628
- const base = resolveConfig(options.env ?? SINGLE_ENV, options.referenceConfs);
629
- const config = options.config ? options.config(base) : base;
669
+ const env = options.env ?? SINGLE_ENV;
670
+ const adjust = <C extends AppConfig>(config: C): C =>
671
+ options.config ? (options.config(config as unknown as Switches) as unknown as C) : config;
672
+ // The composition's own layers, read once for both phases, as `app.mts`
673
+ // reads them. Phase one: the switches the modules are chosen by.
674
+ const own = readOwnLayers(
675
+ options.operatorHocon === undefined
676
+ ? ownFiles()
677
+ : [hoconFile(options.operatorHocon), ...ownFiles()],
678
+ { env },
679
+ );
680
+ const switches = resolveConfig(env, options.reads, own);
681
+ const config = options.switches ? options.switches(adjust(switches)) : adjust(switches);
630
682
  const fakes = await sharedUpstreams();
631
683
  const modules = composedModules(config, options);
632
684
  const logger = createRecordingLogger();
685
+ // Phase two: the configuration as resolved over every loaded package's
686
+ // reference.conf, which createApp parses once.
687
+ const resolved = adjust(resolveForBoot(own, modules, config));
633
688
  const handle = await createApp({
634
689
  modules,
635
- bootstrapComponents: { config, pathResolver: (s) => s, logger },
690
+ bootstrapComponents: {
691
+ config: resolved,
692
+ pathResolver: (s) => s,
693
+ logger,
694
+ },
636
695
  overrideComponents: {
637
696
  ...(await federationOverrides(config, fakes)),
638
697
  ...options.extraOverrides?.(config),
639
698
  } as never,
640
699
  });
700
+ const parsed = handle.components.config;
701
+ if (parsed === undefined) throw new Error("createApp booted without the parsed configuration");
702
+ const httpSettings = handle.components.httpSettings;
703
+ if (httpSettings === undefined) throw new Error("createApp booted without httpSettings");
641
704
  const app = express();
642
- app.set("trust proxy", config.http.trustProxy);
705
+ app.set("trust proxy", httpSettings.trustProxy);
643
706
  app.use(
644
707
  helmet({
645
708
  contentSecurityPolicy: { directives: { defaultSrc: ["'none'"], frameAncestors: ["'none'"] } },
@@ -647,7 +710,7 @@ export async function compose(options: ComposeOptions = {}): Promise<Composition
647
710
  );
648
711
  app.use(handle.router);
649
712
  if (options.terminalErrorHandler !== false) app.use(terminalErrorHandler(logger));
650
- return { app, handle, config, modules, logger, upstreams: fakes };
713
+ return { app, handle, config: parsed, resolved, modules, logger, upstreams: fakes };
651
714
  }
652
715
 
653
716
  // ---------------------------------------------------------------------------
@@ -765,12 +828,17 @@ export const DISCOVERY_PATHS = [
765
828
  /**
766
829
  * What RFC 8414 §2 and OpenID Connect Discovery §3 require of the document
767
830
  * this composition serves, and what each advertised URL must be: https, on
768
- * the issuer's origin.
831
+ * the issuer's origin. `authorizationCode` says whether the composition
832
+ * registers that grant (it does unless a test switches it off): with it the
833
+ * document names the authorization endpoint and lists the code response type
834
+ * and PKCE; without it, none of them.
769
835
  */
770
- export function expectValidMetadata(doc: Record<string, unknown>): void {
836
+ export function expectValidMetadata(
837
+ doc: Record<string, unknown>,
838
+ { authorizationCode = true }: { readonly authorizationCode?: boolean } = {},
839
+ ): void {
771
840
  expect(doc.issuer).toBe(ISSUER);
772
841
  for (const field of [
773
- "authorization_endpoint",
774
842
  "token_endpoint",
775
843
  "jwks_uri",
776
844
  "response_types_supported",
@@ -786,13 +854,20 @@ export function expectValidMetadata(doc: Record<string, unknown>): void {
786
854
  expect(url.search + url.hash, field).toBe("");
787
855
  }
788
856
  if (field.endsWith("_supported") && Array.isArray(value)) {
789
- expect(value.length, field).toBeGreaterThan(0);
857
+ if (field !== "response_types_supported") expect(value.length, field).toBeGreaterThan(0);
790
858
  for (const entry of value) expect(typeof entry, field).toBe("string");
791
859
  expect(new Set(value).size, `${field} repeats a value`).toBe(value.length);
792
860
  }
793
861
  }
794
- expect(doc.response_types_supported).toEqual(["code"]);
795
- expect(doc.code_challenge_methods_supported).toEqual(["S256"]);
862
+ if (authorizationCode) {
863
+ expect(doc.authorization_endpoint).toBe(`${ISSUER}/oauth/authorize`);
864
+ expect(doc.response_types_supported).toEqual(["code"]);
865
+ expect(doc.code_challenge_methods_supported).toEqual(["S256"]);
866
+ } else {
867
+ expect(doc).not.toHaveProperty("authorization_endpoint");
868
+ expect(doc.response_types_supported).toEqual([]);
869
+ expect(doc).not.toHaveProperty("code_challenge_methods_supported");
870
+ }
796
871
  }
797
872
 
798
873
  // ---------------------------------------------------------------------------
@@ -937,7 +1012,7 @@ export const TOO_LARGE = { error: "invalid_request", error_description: "body_to
937
1012
  // ---------------------------------------------------------------------------
938
1013
 
939
1014
  /**
940
- * The parts of the #685 outage rule, each checked on its own so a case pins
1015
+ * The parts of the outage rule, each checked on its own so a case pins
941
1016
  * only what is broken and asserts the rest:
942
1017
  *
943
1018
  * - `answer` — the status and error code (a redirect's location and code);