@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/README.ja.md +66 -21
  2. package/README.md +76 -31
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +59 -29
  12. package/templates/standalone/Dockerfile +6 -2
  13. package/templates/standalone/README.ja.md +324 -169
  14. package/templates/standalone/README.md +618 -208
  15. package/templates/standalone/config/application.conf +211 -352
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +418 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +63 -32
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +1 -0
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/adapters.test.mts +419 -0
  28. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
  33. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  34. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  35. package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
  36. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  37. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
  38. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
  39. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
  40. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
  41. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
  42. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  43. package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
  44. package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
  45. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  46. package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
  47. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  48. package/templates/standalone/src/__tests__/listen.test.mts +6 -13
  49. package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
  50. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  51. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
  52. package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
  53. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  54. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  55. package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
  56. package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
  57. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
  58. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
  59. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
  60. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  61. package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
  62. package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
  63. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
  64. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  65. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  66. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
  67. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  68. package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
  69. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  70. package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
  71. package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
  72. package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
  73. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  74. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
  75. package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
  76. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
  77. package/templates/standalone/src/adapters.mts +162 -0
  78. package/templates/standalone/src/app.mts +65 -79
  79. package/templates/standalone/src/bootRefusal.mts +117 -0
  80. package/templates/standalone/src/buildModules.mts +220 -309
  81. package/templates/standalone/src/configPath.mts +449 -29
  82. package/templates/standalone/src/listen.mts +12 -18
  83. package/templates/standalone/src/logger.mts +31 -60
  84. package/templates/standalone/src/metrics.mts +24 -47
  85. package/templates/standalone/src/mfaSwitch.mts +285 -0
  86. package/templates/standalone/src/modules.mts +423 -629
  87. package/templates/standalone/src/rootRenames.mts +96 -0
  88. package/templates/standalone/src/routes.mts +8 -14
  89. package/templates/standalone/src/sections.mts +367 -0
  90. package/templates/standalone/src/shutdown.mts +45 -123
  91. package/templates/standalone/tests/index.test.js +6 -5
  92. package/templates/standalone/tests/package-lock.json +1615 -2223
  93. package/templates/standalone/tests/package.json +2 -2
  94. package/templates/standalone/tests/vitest.config.mjs +26 -0
  95. package/templates/standalone/vitest.config.mts +8 -7
  96. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  97. package/templates/versions.json +18 -15
  98. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
  99. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
  100. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
@@ -14,12 +14,9 @@
14
14
  * limitations under the License.
15
15
  */
16
16
  import {
17
- type AppConfig,
18
- consoleLogger,
19
17
  defaultRefreshTokenFamilyRevocationModule,
20
18
  defaultRefreshTokenFamilyRotationModule,
21
19
  jwksModule,
22
- type Logger,
23
20
  type Module,
24
21
  memoryAccessTokenDenylistModule,
25
22
  memoryConsentStoreModule,
@@ -27,18 +24,20 @@ import {
27
24
  memoryFederationGrantStoreModule,
28
25
  memoryRateLimiterModule,
29
26
  memoryReplaySeenSetModule,
27
+ sessionLifecycleModule,
30
28
  } from "@o3co/auth-provider-core";
31
- import { googleFederationModule } from "@o3co/auth-provider-federation-google";
29
+ import { googleFederationTypeModule } from "@o3co/auth-provider-federation-google";
32
30
  import { federationGrantsModules } from "@o3co/auth-provider-federation-grants";
33
- import { oidcFederationModule, oidcFederationNames } from "@o3co/auth-provider-federation-oidc";
31
+ import { oidcFederationTypeModule } from "@o3co/auth-provider-federation-oidc";
34
32
  import {
35
- oauthAuthorizationModule,
36
- oauthModule,
37
- oauthSessionModule,
33
+ oauthAuthorizationGrantsModule,
34
+ oauthEndpointsModule,
35
+ oauthSessionGrantModule,
38
36
  subjectRevocationServiceModule,
39
37
  } from "@o3co/auth-provider-oauth";
40
38
  import {
41
39
  redisAccessTokenDenylistModule,
40
+ redisAttemptCounterModule,
42
41
  redisCodeRepositoryModule,
43
42
  redisConsentStoreModule,
44
43
  redisFederationGrantIntentStoreModule,
@@ -49,399 +48,311 @@ import {
49
48
  redisReplaySeenSetModule,
50
49
  redisSessionStoresModule,
51
50
  } from "@o3co/auth-provider-redis";
51
+ import { sessionModule, sessionStoreModule } from "@o3co/auth-provider-session";
52
52
  import {
53
- extractFederationSection,
54
- sessionModule,
55
- sessionStoreModuleFor,
56
- } from "@o3co/auth-provider-session";
53
+ standardDevelopmentMailSenderModule,
54
+ standardSmtpMailSenderModule,
55
+ } from "@o3co/auth-provider-standard";
56
+ import type { Switches } from "./configPath.mjs";
57
+ import { mfaModulesFor } from "./mfaSwitch.mjs";
57
58
  import {
58
- auditSinkModule,
59
- googleFederationConfigModule,
59
+ auditSinkModuleFor,
60
+ httpModule,
60
61
  inMemoryCodeRepositoryModule,
61
62
  inMemoryFederationTokenStoreModule,
62
63
  inMemorySessionStoresModule,
63
64
  keyStoreModule,
64
- oidcFederationConfigModule,
65
- repositoriesModule,
65
+ loggingModule,
66
+ repositoriesModuleFor,
66
67
  standaloneRedisClientsModule,
67
68
  } from "./modules.mjs";
68
69
 
69
70
  /**
70
- * Overrides for the composition. All but `environment` and `logger` are
71
- * test-only: they let the smoke test substitute in-memory implementations of
72
- * the file-system-backed modules, and production callers should not pass them
73
- * — the defaults match the standalone scaffold.
71
+ * Overrides for the composition. All but `environment` and
72
+ * `mailSenderModules` are test-only: they let the smoke test substitute
73
+ * in-memory implementations of the file-system-backed modules, and production
74
+ * callers should not pass them — the defaults match the standalone scaffold.
74
75
  */
75
76
  export interface BuildModulesOverrides {
76
77
  /**
77
- * #473: the name this deployment selected its configuration by —
78
- * `CONFIG_ENV || NODE_ENV`, computed once in `app.mts` and passed here so
79
- * the Redis federation-token store's `allow-plaintext` guard reads the
80
- * environment the config actually came from, not `NODE_ENV` alone. Omitted,
81
- * the guard falls back to `NODE_ENV` (and `deployment.mode`, which it reads
82
- * off the config either way).
78
+ * The name this deployment selected its configuration by (`CONFIG_ENV ||
79
+ * NODE_ENV`, computed once in `app.mts`), so the Redis federation-token
80
+ * store's `allow-plaintext` guard reads the environment the config came
81
+ * from, not `NODE_ENV` alone. Omitted, the guard falls back to `NODE_ENV`
82
+ * (and the `deploymentMode` slot core fills from `core.deployment.mode`, which it
83
+ * reads either way). Unless `mailSenderModules` is given, it also chooses
84
+ * the mail sender: `development` installs the one that logs each code, any
85
+ * other name the SMTP one, and none is installed when it is omitted. The
86
+ * MFA module reads it too, for the development sample key's refusal.
83
87
  */
84
88
  readonly environment?: string;
85
- /**
86
- * Where the composition's own notices go — a deprecated config key, one
87
- * `config_key_deprecated` line (warn) each. `app.mts` passes the logger it
88
- * hands every module; omitted, `consoleLogger`.
89
- */
90
- readonly logger?: Logger;
91
89
  readonly keyStoreModule?: Module;
92
90
  readonly repositoriesModule?: Module;
93
- readonly storesModule?: Module;
94
91
  /**
95
- * D-2 v2 / Wave 5d: override the RT family store module + (when adapter
96
- * is `"redis"`) the bundled redis-clients + redis-store pair. Default
97
- * production manifest is `[standaloneRedisClientsModule, redisRefreshTokenFamilyStoreModule]`
98
- * (single shared ioredis socket per replica via the F4 PR2 unification).
99
- *
100
- * Smoke tests / unit tests that don't want to open an ioredis connection
101
- * pass `[memoryRefreshTokenFamilyStoreModule]` here. The override REPLACES
102
- * the entire group — when the override is provided, `standaloneRedisClientsModule`
103
- * is dropped from the manifest unless the override list includes it.
92
+ * Replaces the refresh-token family store modules (default
93
+ * `[redisRefreshTokenFamilyStoreModule]`, on the shared ioredis socket).
94
+ * Tests that must not open an ioredis connection pass
95
+ * `[memoryRefreshTokenFamilyStoreModule]`.
104
96
  */
105
97
  readonly refreshTokenFamilyModules?: readonly Module[];
106
98
  /**
107
- * #287: override the module filling the `auditSink` slot. Tests substitute
108
- * a sink they can assert on; the default writes the audit trail to stdout
109
- * and is what a deployment gets.
110
- *
111
- * The override REPLACES the default — it does not add a second provider,
112
- * which would be a boot-time slot collision. There is intentionally no way
113
- * to pass "no audit sink": #304's sink policy is that the trail is always
114
- * wired, and a composition that genuinely wants events discarded says so by
115
- * providing a sink that discards them.
99
+ * Replaces the module filling the `auditSink` slot (the default writes the
100
+ * trail to stdout); tests substitute a sink they can assert on. It replaces
101
+ * rather than adds, since a second provider would be a boot-time slot
102
+ * collision. There is no way to pass "no audit sink": pass one that discards.
116
103
  */
117
104
  readonly auditSinkModule?: Module;
105
+ /**
106
+ * The deployment's own modules behind core's `mailSender` slot. Given, they
107
+ * are installed under every environment name in place of the bundled
108
+ * sender, which is never installed beside them (a second provider would be
109
+ * a boot-time slot collision); an empty list installs no sender, and a
110
+ * module that needs one (the MFA email factor, or the MFA module with
111
+ * `requireEmailProof = "always"`) then refuses the boot.
112
+ */
113
+ readonly mailSenderModules?: readonly Module[];
118
114
  }
119
115
 
120
116
  /**
121
- * The access-token lifetime core's `reference.conf` ships on the deprecated
122
- * `oauth.accessToken.expiresIn`. Not a default — nothing mints with it — only
123
- * what an unmodified deployment carries there, so the deprecation line can tell
124
- * an operator's override from the shipped value. Pinned against the real file
125
- * by `access-token-lifetime-alias.test.mts`.
117
+ * Compose the standalone module list from phase one's `config`: the
118
+ * composition root's `adapters`, which choose the adapter behind each slot,
119
+ * its MFA switch, and whether federation grants are installed. Kept out of
120
+ * `app.mts` so a smoke test can check the manifest (that disabling federation
121
+ * grants removes their modules, say) without an HTTP server. The rules for
122
+ * editing the list (order, one module per store slot, the federation types it
123
+ * bundles) are in the template README, "Module Composition Order".
126
124
  */
127
- const SHIPPED_ACCESS_TOKEN_EXPIRES_IN = 3600;
125
+ export function buildModules(config: Switches, overrides: BuildModulesOverrides = {}): Module[] {
126
+ const adapters = config.adapters;
128
127
 
129
- /**
130
- * Compose the standalone v0.5.0 module list from `config`. Splitting this
131
- * out of `app.mts` keeps the composition root testable: a smoke test can
132
- * verify that disabling a federation removes its module pair from the
133
- * manifest without spinning up a full HTTP server.
134
- *
135
- * Federation gating: `googleFederationModule` requires `googleFederationConfig`,
136
- * which `googleFederationConfigModule` produces by reading
137
- * `config.federations.google`. When google is disabled (or the section is
138
- * absent), the config-bridge module's provider throws — so the entire pair
139
- * MUST be conditionally included at composition time, not gated inside the
140
- * provider.
141
- *
142
- * #524: the generic OIDC federation follows the same rule, once per enabled
143
- * `federations.<name>` of type "oidc": `oidcFederationModule(name)` per
144
- * instance plus the one `oidcFederationConfigModule` that reads every
145
- * instance's config into the slot they share. A section's `type` decides
146
- * which implementation owns the name, so the two gates never both select
147
- * the same section.
148
- */
149
- export function buildModules(config: AppConfig, overrides: BuildModulesOverrides = {}): Module[] {
150
- // A section's `type` names the implementation: `federations.google` is the
151
- // built-in Google federation only when its type is `google` (the default
152
- // for that name). With `type = "oidc"` it is a generic OIDC instance named
153
- // "google" (#524), and composing both would contribute the same
154
- // federation and redirect-policy keys twice.
155
- const googleEnabled =
156
- extractFederationSection(config.federations ?? {}, "google")?.type === "google";
157
- const oidcFederations = oidcFederationNames(config.federations ?? {});
158
- const logger = overrides.logger ?? consoleLogger;
128
+ // Federation grants: a user's standing consent that a client may obtain
129
+ // upstream tokens with no session behind the call. Off by default, and off
130
+ // installs nothing: no store, no socket, no boot requirement. On, the routes
131
+ // and the background registry the shutdown drains come as a pair; each store
132
+ // follows its own selection (grants in Redis with acquisition in memory is
133
+ // a supported single-replica shape); and the subject-revocation service is
134
+ // installed, whose `federationGrantStore` edge makes "revoke everything this
135
+ // subject holds" include the grants. Enabling it also states the deployment
136
+ // has a consent page, a callback per connection and a user repository
137
+ // covering each connection's registration; the routes module refuses at
138
+ // boot what is missing, naming it. Phase one decides it (`readSwitches`): a
139
+ // value boot would refuse installs the feature, so that boot refuses it
140
+ // rather than reading the feature as off.
141
+ const federationGrantsEnabled = config["federation-grants"]?.enabled === true;
159
142
 
160
- // Wave 5d (IH-14 + OR-M1 + OR-4) + OR-9: adapter-driven branching for
161
- // the OAuth-endpoint rate limiter, the user-session-store family, AND
162
- // the OAuth code repository. The RT family store always uses Redis in
163
- // the production manifest (D-2 v2 / OR-1) unless
164
- // `overrides.refreshTokenFamilyModules` swaps it out. When ANY consumer
165
- // adapter is `"redis"` (or the RT family override includes a
166
- // Redis-backed store module), the shared `standaloneRedisClientsModule`
167
- // is added once and provides every per-purpose ComponentMap slot from a
168
- // single ioredis socket per replica. Memory-only deployments skip it.
169
- const rateLimiterAdapter = config.rateLimiter?.adapter ?? "memory";
170
- const userSessionStoresAdapter = config.userSessionStores?.adapter ?? "memory";
171
- // #277: the RFC 7009 access-token denylist. Unlike the switches above there
172
- // is no "no denylist" branch — `oauthModule` reads the `accessTokenDenylist`
173
- // slot because it mounts `/oauth/revoke`, and core's boot validator refuses
174
- // a composition that reads the slot with nothing filling it, since the
175
- // endpoint would answer 200 while the token kept working. The switch here is
176
- // only over WHICH denylist.
177
- const accessTokenDenylistAdapter = config.accessTokenDenylist?.adapter ?? "memory";
178
- // #484: the jti single-use record behind private_key_jwt client auth.
179
- const replaySeenSetAdapter = config.replaySeenSet?.adapter ?? "memory";
180
- // #527: the consent store behind /authorize for clients that are not
181
- // first-party. `"none"` (the default) wires nothing — such clients are
182
- // refused, as before — so a first-party-only deployment pays nothing and
183
- // a multi-replica one is not handed a memory store it cannot run.
184
- // `"redis"` (#561) is the store such a deployment selects.
185
- const consentStoreAdapter = config.consentStore?.adapter ?? "none";
186
- // #456: adapter switch for the federation token store. `"memory"` by
187
- // default, the template's local-dev shape; `"redis"` mounts
188
- // `redisFederationTokenStoreModule` off the shared socket — what the README
189
- // promised and what the previous factory-based module never delivered: it
190
- // built the Redis store without a client and failed at boot.
191
- const federationTokenStoreAdapter = config.federationTokenStore?.type ?? "memory";
192
- // #593 slice 7: federation grants — a user's standing consent that a client
193
- // may obtain upstream tokens with no session behind the call. Off by
194
- // default, and off installs nothing: no store, no socket, no boot
195
- // requirement a deployment that never asked for grants would have to meet.
196
- // On, the routes and the background registry the shutdown drains come as a
197
- // pair, each store follows its own switch (grants in Redis with acquisition
198
- // in memory is a supported single-replica shape), and the subject-revocation
199
- // service is installed so a credential change can reach the grants — its
200
- // `federationGrantStore` edge is what makes "revoke everything this subject
201
- // holds" include them. Enabling the feature is also a statement that the
202
- // deployment has a consent page, a callback per connection and a user
203
- // repository that covers each connection's registration; the routes module
204
- // refuses at boot what is missing, naming it.
205
- const federationGrantsEnabled = config.federationGrants?.enabled === true;
206
- const federationGrantStoreAdapter = config.federationGrantStore?.adapter ?? "memory";
207
- const federationGrantIntentStoreAdapter = config.federationGrantIntentStore?.adapter ?? "memory";
208
-
209
- // OR-9: effective code-repo adapter. `oauth.code.adapter` is the
210
- // authoritative switch; the legacy `repositories.code.type = "redis"`
211
- // path is honored with a deprecation warn so existing operators
212
- // relying on `CLIENT_CODE_TYPE=redis` env-var overrides keep working
213
- // until they migrate. See CHANGELOG for the removal version.
214
- const oauthCodeAdapter = config.oauth?.code?.adapter;
215
- const legacyCodeType = (config.repositories?.code as { type?: string } | undefined)?.type;
216
- let codeRepositoryAdapter: "memory" | "redis";
217
- if (oauthCodeAdapter !== undefined) {
218
- codeRepositoryAdapter = oauthCodeAdapter;
219
- } else if (legacyCodeType === "redis") {
220
- logger.warn(
221
- {
222
- key: "repositories.code.type",
223
- env: "CLIENT_CODE_TYPE",
224
- replacement: "oauth.code.adapter",
225
- replacementEnv: "OAUTH_CODE_ADAPTER",
226
- },
227
- "config_key_deprecated",
228
- );
229
- codeRepositoryAdapter = "redis";
230
- } else {
231
- codeRepositoryAdapter = "memory";
232
- }
233
-
234
- // The access-token lifetime's deprecated alias: `oauth.accessToken.expiresIn`
235
- // (OAUTH_ACCESS_TOKEN_EXPIRES_IN) is still read as the default while
236
- // `defaultExpiresIn` is unset — `resolveAccessTokenLifetime` does that, and
237
- // this only says so. Core's `reference.conf` keeps the shipped lifetime on
238
- // the deprecated key, so "the alias supplied the default" is every
239
- // deployment that set nothing; an override of it is the one case with
240
- // something to move, the reading `legacyCodeType === "redis"` gives above.
241
- // See CHANGELOG for the removal version.
242
- const accessToken = config.oauth.accessToken;
243
- if (
244
- accessToken.defaultExpiresIn === undefined &&
245
- accessToken.expiresIn !== SHIPPED_ACCESS_TOKEN_EXPIRES_IN
246
- ) {
247
- logger.warn(
248
- {
249
- key: "oauth.accessToken.expiresIn",
250
- env: "OAUTH_ACCESS_TOKEN_EXPIRES_IN",
251
- replacement: "oauth.accessToken.defaultExpiresIn",
252
- replacementEnv: "OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN",
253
- },
254
- "config_key_deprecated",
255
- );
256
- }
143
+ // MFA: the template's own switch, `mfaMode` (MFA_MODE). `off` installs
144
+ // nothing of MFA; `optional` and `required` install what `mfaModulesFor`
145
+ // lists, the configuration then expects `mfa`
146
+ // (`expectedSessionRequirements`) and `mfa.mode` is written from the
147
+ // switch (`resolveForBoot`).
148
+ const mfaInstalled = config.mfaMode !== "off";
257
149
 
258
150
  const refreshTokenFamilyModules: readonly Module[] = overrides.refreshTokenFamilyModules ?? [
259
151
  redisRefreshTokenFamilyStoreModule,
260
152
  ];
261
- // Detect whether the RT-family override (if any) keeps the Redis-backed
262
- // store. Default (no override) uses Redis. An override that includes the
263
- // Redis store module without also wiring `standaloneRedisClientsModule`
264
- // would otherwise boot-fail on the missing `refreshTokenFamilyClient`
265
- // component (Copilot review on PR #121).
153
+ // Whether the family modules keep the Redis-backed store (the default
154
+ // does). Without the shared clients module added for it, that store fails
155
+ // boot on the missing `refreshTokenFamilyClient` component.
266
156
  const refreshTokenFamilyUsesRedis = refreshTokenFamilyModules.some(
267
157
  (m) => m.name === "redis-refresh-token-family-store",
268
158
  );
159
+ // When ANY selection is `"redis"`, or the refresh-token family modules keep
160
+ // the Redis store (the default), the shared `standaloneRedisClientsModule`
161
+ // is added once, one ioredis socket per replica; memory-only deployments
162
+ // open none.
269
163
  const usingRedisAnywhere =
270
164
  refreshTokenFamilyUsesRedis ||
271
- rateLimiterAdapter === "redis" ||
272
- userSessionStoresAdapter === "redis" ||
273
- codeRepositoryAdapter === "redis" ||
274
- accessTokenDenylistAdapter === "redis" ||
275
- replaySeenSetAdapter === "redis" ||
276
- consentStoreAdapter === "redis" ||
277
- federationTokenStoreAdapter === "redis" ||
278
- // Only while the feature is on: a switch left at "redis" for a feature
279
- // that is off must not open a socket.
165
+ adapters.rateLimiter === "redis" ||
166
+ adapters.attemptCounter === "redis" ||
167
+ adapters.userSessionStores === "redis" ||
168
+ adapters.codeRepository === "redis" ||
169
+ adapters.accessTokenDenylist === "redis" ||
170
+ adapters.replaySeenSet === "redis" ||
171
+ adapters.consentStore === "redis" ||
172
+ adapters.federationTokenStore === "redis" ||
173
+ // Only while the feature is on: a selection left at "redis" for a
174
+ // feature that is off must not open a socket.
280
175
  (federationGrantsEnabled &&
281
- (federationGrantStoreAdapter === "redis" || federationGrantIntentStoreAdapter === "redis"));
176
+ (adapters.federationGrantStore === "redis" ||
177
+ adapters.federationGrantIntentStore === "redis")) ||
178
+ (mfaInstalled &&
179
+ (adapters.mfaFactorStore === "redis" || adapters.mfaTransactionStore === "redis"));
282
180
 
283
- // The four user-session stores switch on `userSessionStores.adapter`; the
284
- // federation-token store is always wired and switches on its own key
285
- // below (#456). Pre-Wave-5d the redis branch dropped the
286
- // federation-token-store provider entirely (Copilot review on PR #121);
287
- // splitting `storesModule` fixed that boot failure.
181
+ // The four user-session stores and the subject-level revocation pair
182
+ // follow `adapters.userSessionStores`; the federation-token store is always
183
+ // wired and follows its own selection below.
288
184
  const sessionStoresModules: Module[] =
289
- userSessionStoresAdapter === "redis"
185
+ adapters.userSessionStores === "redis"
290
186
  ? [redisSessionStoresModule]
291
- : overrides.storesModule
292
- ? [overrides.storesModule]
293
- : [inMemorySessionStoresModule];
187
+ : [inMemorySessionStoresModule];
294
188
 
295
189
  const rateLimiterModules: Module[] =
296
- rateLimiterAdapter === "redis" ? [redisRateLimiterModule] : [memoryRateLimiterModule];
190
+ adapters.rateLimiter === "redis" ? [redisRateLimiterModule] : [memoryRateLimiterModule];
297
191
 
298
- // OR-9: code-repository module — mutually exclusive memory/redis pair.
299
- // Same pattern as sessionStoresModules + rateLimiterModules. The two
300
- // modules provide the same `codeRepository` slot; including both would
301
- // be a boot-time slot collision.
192
+ // `memory` installs no counter: the login's attempt guard counts per
193
+ // process, which `core.deployment.mode = "multi"` refuses.
194
+ const attemptCounterModules: Module[] =
195
+ adapters.attemptCounter === "redis" ? [redisAttemptCounterModule] : [];
196
+
197
+ // Mutually exclusive: both modules provide the `codeRepository` slot, and
198
+ // including both would be a boot-time slot collision.
302
199
  const codeRepositoryModules: Module[] =
303
- codeRepositoryAdapter === "redis"
200
+ adapters.codeRepository === "redis"
304
201
  ? [redisCodeRepositoryModule]
305
202
  : [inMemoryCodeRepositoryModule];
306
203
 
307
- // #277: mutually-exclusive denylist pair, same shape as the three switches
308
- // above. The memory branch is a dev convenience and nothing more: it forks
309
- // per replica, so `deployment.mode = "multi"` refuses it by name (see core's
310
- // replica-safety guard). The template's own application.conf ships `"redis"`.
204
+ // The RFC 7009 access-token denylist has no "none": `oauthEndpointsModule` mounts
205
+ // `/oauth/revoke` and reads the `accessTokenDenylist` slot, and core's boot
206
+ // validator refuses a composition that reads the slot with nothing filling
207
+ // it, since the endpoint would answer 200 while the token kept working. The
208
+ // memory denylist is a dev convenience: it forks per replica, so
209
+ // `core.deployment.mode = "multi"` refuses it by name. The template ships
210
+ // `"redis"`.
311
211
  const accessTokenDenylistModules: Module[] =
312
- accessTokenDenylistAdapter === "redis"
212
+ adapters.accessTokenDenylist === "redis"
313
213
  ? [redisAccessTokenDenylistModule]
314
214
  : [memoryAccessTokenDenylistModule];
315
215
 
316
- // #527: opt-in. Each module provides both slots the consent step needs —
317
- // the consent records and the requests parked while the page asks — so the
318
- // two cannot be wired apart. The memory one declares itself replica-unsafe
319
- // and `deployment.mode = "multi"` refuses it by name; the Redis one (#561)
320
- // shares both over the ioredis socket.
216
+ // Opt-in: `"none"` (the default) wires nothing, and a client that is not
217
+ // first-party is refused. Each module provides both slots the consent step
218
+ // needs (the consent records and the requests parked while the page asks),
219
+ // so the two cannot be wired apart. The memory one declares itself
220
+ // replica-unsafe and `core.deployment.mode = "multi"` refuses it by name;
221
+ // the Redis one shares both over the ioredis socket.
321
222
  const consentStoreModules: Module[] =
322
- consentStoreAdapter === "redis"
223
+ adapters.consentStore === "redis"
323
224
  ? [redisConsentStoreModule]
324
- : consentStoreAdapter === "memory"
225
+ : adapters.consentStore === "memory"
325
226
  ? [memoryConsentStoreModule]
326
227
  : [];
327
228
 
328
- // #455 / #456: mutually-exclusive federation-token-store pair, the same
329
- // shape as the session stores. One module per adapter, so the memory one
330
- // can declare `replicaSafety` on its manifest and the Redis one can
331
- // `require` its client slot. The previous single module chose the adapter
332
- // at runtime under one name — the replica guard could not tell the two
333
- // apart, and the Redis branch had no client to hand the builder.
334
- //
335
- // #473: the Redis module is built for this composition root so its
336
- // plaintext guard knows which environment selected the config; it reads
337
- // `deployment.mode` off the config itself.
229
+ // The jti single-use record behind private_key_jwt client authentication.
338
230
  const replaySeenSetModules: Module[] =
339
- replaySeenSetAdapter === "redis" ? [redisReplaySeenSetModule] : [memoryReplaySeenSetModule];
231
+ adapters.replaySeenSet === "redis" ? [redisReplaySeenSetModule] : [memoryReplaySeenSetModule];
340
232
 
233
+ // One federation-token-store module per adapter, so the memory one can
234
+ // declare `replicaSafety` on its manifest and the Redis one can `require`
235
+ // its client slot. The Redis module is built for this composition root so
236
+ // its plaintext guard knows which environment selected the config; it
237
+ // reads the replica count from core's `deploymentMode` slot.
341
238
  const federationTokenStoreModules: Module[] =
342
- federationTokenStoreAdapter === "redis"
239
+ adapters.federationTokenStore === "redis"
343
240
  ? [redisFederationTokenStoreModuleFor({ environment: overrides.environment })]
344
241
  : [inMemoryFederationTokenStoreModule];
345
242
 
346
- // #593 slice 7: one module per store, nothing while the feature is off.
347
- // The Redis grant store is built for this composition root so its
348
- // plaintext guard knows which environment selected the config (#473), as
349
- // the federation-token store's is. Both memory modules declare
350
- // `replicaSafety`, so `deployment.mode = "multi"` refuses them by name; a
351
- // Redis grant store beside memory user-session stores is refused by the
352
- // routes module itself, because the grants would outlive the boundary that
353
- // ends them (D13).
243
+ // One module per store, nothing while the feature is off or the store is
244
+ // `"none"` (the default), which the routes module refuses at boot naming
245
+ // the store. The Redis grant store is built for this composition root so
246
+ // its plaintext guard knows which environment selected the config, as the
247
+ // federation-token store's is. Both memory modules declare
248
+ // `replicaSafety`, so `core.deployment.mode = "multi"` refuses them by name;
249
+ // the routes module itself refuses a Redis grant store beside memory
250
+ // user-session stores, because the grants would outlive the boundary that
251
+ // ends them.
354
252
  const federationGrantStoreModules: Module[] = !federationGrantsEnabled
355
253
  ? []
356
- : federationGrantStoreAdapter === "redis"
254
+ : adapters.federationGrantStore === "redis"
357
255
  ? [
358
256
  overrides.environment === undefined
359
257
  ? redisFederationGrantStoreModuleFor()
360
258
  : redisFederationGrantStoreModuleFor({ environment: overrides.environment }),
361
259
  ]
362
- : [memoryFederationGrantStoreModule];
260
+ : adapters.federationGrantStore === "memory"
261
+ ? [memoryFederationGrantStoreModule]
262
+ : [];
363
263
  const federationGrantIntentStoreModules: Module[] = !federationGrantsEnabled
364
264
  ? []
365
- : federationGrantIntentStoreAdapter === "redis"
265
+ : adapters.federationGrantIntentStore === "redis"
366
266
  ? [redisFederationGrantIntentStoreModule]
367
- : [memoryFederationGrantIntentStoreModule];
267
+ : adapters.federationGrantIntentStore === "memory"
268
+ ? [memoryFederationGrantIntentStoreModule]
269
+ : [];
270
+
271
+ // What the MFA switch installs (`mfaSwitch.mts`): nothing while it is off.
272
+ const mfaInstalledModules = mfaModulesFor({
273
+ mode: config.mfaMode,
274
+ adapters,
275
+ storeTransport: config.storeTransport,
276
+ environment: overrides.environment,
277
+ });
278
+
279
+ // The mail sender behind core's `mailSender` slot: the deployment's own when
280
+ // given, otherwise the bundled one for the environment. The development one
281
+ // logs each code and refuses the boot where the configuration or NODE_ENV
282
+ // is production or staging, or the deployment multi-replica.
283
+ const mailSenderModules: readonly Module[] =
284
+ overrides.mailSenderModules ??
285
+ (overrides.environment === undefined
286
+ ? []
287
+ : overrides.environment === "development"
288
+ ? [standardDevelopmentMailSenderModule({ environment: overrides.environment })]
289
+ : [standardSmtpMailSenderModule]);
368
290
 
369
291
  return [
370
- // D-5: sessionStoreModule wires the express-session middleware into the
371
- // boot-planner-managed lifecycle. **Mount order is enforced by this
372
- // list position (declarationIndex tie-breaking)** — the module
373
- // intentionally has no `before`/`after` clause, so it MUST be listed
374
- // ahead of every session-consuming module here. Do not reorder.
375
- //
376
- // #474: built from `config` rather than the static manifest, so that
377
- // `session.storage.type = "memory"` declares itself replica-unsafe and
378
- // `deployment.mode = "multi"` refuses it by name like the other stores.
379
- sessionStoreModuleFor(config),
380
- // #593 slice 7: the grants routes mount under `/oauth` beside
381
- // `oauthModule`'s router, which parses the bodies of its own routes
382
- // only, so each parses its own requests and their place relative to
383
- // `oauthModule` does not matter. The browser half sits after the
384
- // session middleware by its own `after`, wherever it is listed.
292
+ // MUST stay first: it declares no `before`/`after`, so this position is
293
+ // what mounts express-session ahead of every session-consuming module.
294
+ // It declares `session-store.storage.type = "memory"` replica-unsafe from
295
+ // its own section, so `core.deployment.mode = "multi"` refuses it.
296
+ sessionStoreModule,
297
+ // Under `/oauth` beside `oauthEndpointsModule`, each parsing its own requests, so
298
+ // their relative order does not matter; the browser half orders itself
299
+ // after the session middleware by its own `after`.
385
300
  ...(federationGrantsEnabled ? federationGrantsModules : []),
386
- oauthModule({ config }),
387
- oauthSessionModule({ config }),
388
- oauthAuthorizationModule({ config }),
389
- // JWKS publishing — always wired (depends only on config + keyStore).
390
- // Contributed by core's `jwksModule`, NOT oauthModule: a provider that
391
- // signs tokens must publish its verification keys regardless of OIDC
392
- // issuer config. Co-installed with oauthModule so the discovery
393
- // `jwks_uri` (advertised by oauthModule when an issuer is set) always
394
- // resolves to this mounted route.
301
+ oauthEndpointsModule,
302
+ oauthSessionGrantModule,
303
+ oauthAuthorizationGrantsModule,
304
+ // Always wired: a provider that signs tokens must publish its
305
+ // verification keys regardless of OIDC issuer config, and `oauthEndpointsModule`'s
306
+ // discovery `jwks_uri` (advertised when an issuer is set) must resolve.
395
307
  jwksModule,
396
308
  sessionModule,
397
- ...(googleEnabled ? [googleFederationModule, googleFederationConfigModule] : []),
398
- ...(oidcFederations.length > 0
399
- ? [oidcFederationConfigModule, ...oidcFederations.map((name) => oidcFederationModule(name))]
400
- : []),
309
+ // The federation types the template bundles, always: each contributes
310
+ // its type, and boot hands it every enabled `core.federations` entry
311
+ // that names it, under the entry's name. Nothing here reads an entry.
312
+ googleFederationTypeModule(),
313
+ oidcFederationTypeModule(),
314
+ // The template's own settings: `logging {}` and `http {}`.
315
+ loggingModule,
316
+ httpModule,
401
317
  overrides.keyStoreModule ?? keyStoreModule,
402
- overrides.repositoriesModule ?? repositoriesModule,
403
- // #287: the audit sink, always wired. Unlike every switch above there is
404
- // no "off" branch — the routes' security events (`token.issued.failure`,
405
- // `authorize.rejected`, `rate_limit.unavailable`, …) went nowhere in the
406
- // shipped artifact because the slot was empty and `emitAuditEvent`
407
- // no-ops when it is. Which sink is a config question
408
- // (`audit.sink.type`); whether there is one is not.
409
- overrides.auditSinkModule ?? auditSinkModule,
410
- // Shared ioredis clients — only when at least one consumer adapter
411
- // actually needs Redis. Memory-only deployments skip this so they
412
- // don't open an unused socket.
318
+ overrides.repositoriesModule ??
319
+ repositoriesModuleFor({ client: adapters.clientRepository, user: adapters.userRepository }),
320
+ // The audit sink, always wired: `emitAuditEvent` no-ops on an empty slot,
321
+ // so the routes' security events (`token.issued.failure`,
322
+ // `authorize.rejected`, `rate_limit.unavailable`, …) would go nowhere.
323
+ // Which sink is a selection (`adapters.auditSink`); whether there is one
324
+ // is not.
325
+ overrides.auditSinkModule ?? auditSinkModuleFor(adapters.auditSink),
326
+ ...mailSenderModules,
413
327
  ...(usingRedisAnywhere ? [standaloneRedisClientsModule] : []),
414
- // Federation-token store: redis (multi-replica) or memory (dev). Always
415
- // wired, independent of the `userSessionStores.adapter` switch — it was
416
- // bundled into `storesModule` pre-Wave-5d, and the redis session-stores
417
- // branch dropped the provider.
418
328
  ...federationTokenStoreModules,
419
- // #593 slice 7: the grant store and the intent store, when the feature is on.
420
329
  ...federationGrantStoreModules,
421
330
  ...federationGrantIntentStoreModules,
422
- // User-session-store family: redis (multi-replica) or memory (dev).
423
331
  ...sessionStoresModules,
424
- // OAuth-endpoint rate limiter: redis (shared counters) or memory.
332
+ // Core's session lifecycle, over the session stores' lifecycle store:
333
+ // a login opens its session's record, a code exchange joins its relying
334
+ // party and family to it, and both logout endpoints close it, which
335
+ // revokes the families and tells the relying parties. Its sweep resumes
336
+ // a close left pending.
337
+ sessionLifecycleModule,
425
338
  ...rateLimiterModules,
426
- // OAuth code repository: redis (multi-replica) or memory (single-instance).
339
+ ...attemptCounterModules,
427
340
  ...codeRepositoryModules,
428
- // RFC 7009 access-token denylist: redis (shared revocations) or memory
429
- // (single-instance dev). Always wired — see the switch above.
430
341
  ...accessTokenDenylistModules,
431
342
  ...replaySeenSetModules,
432
- // Consent records for clients that are not first-party: redis
433
- // (multi-replica, #561), memory (single-instance), or nothing (#527).
434
343
  ...consentStoreModules,
435
- // RT family store: redis by default (closes OR-1); override path
436
- // swaps to `[memoryRefreshTokenFamilyStoreModule]` for unit tests.
437
344
  ...refreshTokenFamilyModules,
438
345
  defaultRefreshTokenFamilyRotationModule,
439
346
  defaultRefreshTokenFamilyRevocationModule,
440
- // #593 slice 7: the composed "end everything this subject holds" a
441
- // credential change calls, reached as `handle.components.subjectRevocationService`.
442
- // Installed with the feature, which is what makes it reach the grants;
443
- // the template never installed it before, so a deployment without
444
- // grants is as it was — a Store there calls core's revokeAllForSubject.
445
- ...(federationGrantsEnabled ? [subjectRevocationServiceModule] : []),
347
+ // MFA's modules and stores; the MFA routes order themselves after the
348
+ // session middleware.
349
+ ...mfaInstalledModules,
350
+ // The composed "end everything this subject holds" a credential change
351
+ // calls, reached as `handle.components.subjectRevocationService`.
352
+ // Installed with federation grants, which is what makes it reach the
353
+ // grants, and with MFA, whose operator reset ends every session and
354
+ // token of the subject's through it; otherwise a Store calls core's
355
+ // revokeAllForSubject.
356
+ ...(federationGrantsEnabled || mfaInstalled ? [subjectRevocationServiceModule] : []),
446
357
  ];
447
358
  }