@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
@@ -1,6 +1,6 @@
1
1
  # @o3co/auth-provider-standalone
2
2
 
3
- Last updated: 2026-10-03
3
+ Last updated: 2026-10-06
4
4
 
5
5
  Deployable server template for auth.provider. This is the composition root — it reads config, loads modules, and starts the Express server. Generated by `@o3co/create-auth-provider`.
6
6
 
@@ -26,7 +26,7 @@ the Google and generic OpenID Connect federation adapters, `-federation-grants`,
26
26
  slot), the signing key store, the client and user repositories, the audit
27
27
  sink, the one shared
28
28
  Redis connection, the in-memory user-session stores, code repository and
29
- federation token store, and the federation config bridges —
29
+ federation token store —
30
30
  [`src/modules.mts`](src/modules.mts), with the defaults of the sections they
31
31
  own in [`config/reference.conf`](config/reference.conf). They are the
32
32
  scaffold's rather than a package's because each builds its component from
@@ -104,7 +104,7 @@ Nothing secret lives in `clients.yaml` for such a client.
104
104
 
105
105
  **What this does not do.** It does not make `/authorize` safe against forced navigation for a client that *is* first-party — that remains the accepted model here. What it prevents is a client that should never have been trusted with a silent code being registered into that position by accident.
106
106
 
107
- **Unmarked registrations.** A registration without the field is not first-party: on the defaults it is refused at `/authorize` until you mark it, with no admit-with-warning window. Mark every client you operate before pointing it at `/authorize`. `oauth.authorize.allowUnmarkedClients` is not a setting: a config or environment that sets it (`OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS`, any value) fails at boot with migration instructions rather than being silently ignored.
107
+ **Unmarked registrations.** A registration without the field is not first-party: on the defaults it is refused at `/authorize` until you mark it, with no admit-with-warning window. Mark every client you operate before pointing it at `/authorize`. `oauth.authorize.allowUnmarkedClients` is not a setting: a config or environment that sets it (`OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS`, any value) fails at boot rather than being silently ignored: the oauth module refuses the key as removed (`config-path-relocated`), and the variable as one bound to a removed key (`environment-variable-renamed`). Mark your clients, then delete the key and unset the variable.
108
108
 
109
109
  ## Multi-replica deployments
110
110
 
@@ -112,6 +112,8 @@ The [operator runbook](../../docs/operator-runbook.md) covers the operational si
112
112
 
113
113
  When running multiple instances of the standalone server behind a load balancer, point `REDIS_CLIENTS_URL` at a **shared** Redis 7.2+ instance. Without a shared Redis URL, every replica holds refresh-token families in its own connection (and any local-only Redis), so a refresh request that lands on a replica that did not issue the token returns `invalid_grant` on every other request in a round-robin LB.
114
114
 
115
+ **That Redis must run `maxmemory-policy noeviction`** (Redis's default). The Redis stores whose keys must stay until they expire — the attempt counter, the user-session stores' lifecycle store, the federation token store and the MFA stores — read the policy at boot (`INFO memory`, then `CONFIG GET maxmemory-policy`), and refuse to boot on any other policy or on one the server will not report. If the connection's user may not run `INFO` or `CONFIG` (an ACL-restricted user, or a managed service that renames them) and you know the server runs `noeviction`, set `REDIS_CLIENTS_ASSUME_NO_EVICTION=true` (`redis-clients.assumeNoEviction`, default `false`). It is read only while the policy cannot be read: a policy the server reports always takes precedence, so it never lets an `allkeys-*` or `volatile-*` server through.
116
+
115
117
  **Set `HTTP_TRUST_PROXY` to your proxy's address behind a load balancer.** It defaults to `false`, which makes `req.ip` the *load balancer's* address rather than the client's. Every rate limit keyed by IP — the OAuth endpoint limiters and the `POST /session/login` brute-force guard — then shares **one bucket across all users**: the first 20 login attempts from anyone exhaust the window and every subsequent user gets `429`. The failure reads like an attack rather than a misconfiguration, which is what makes it expensive to diagnose at 3am. Set it whenever anything terminates TLS or proxies in front of this service, and make sure that hop is the one setting `X-Forwarded-For`. An audit event with no `ip` means the request's address was not an address — `req.ip` came from an `X-Forwarded-For` that held something else — so check that `HTTP_TRUST_PROXY` trusts only the hop that sets the header.
116
118
 
117
119
  It takes four shapes, all of them Express's own:
@@ -127,7 +129,11 @@ It takes four shapes, all of them Express's own:
127
129
 
128
130
  An allowlist is a network control, not a cryptographic one. The edge must also **strip** inbound `X-Forwarded-*` headers rather than appending to them, and the hop between it and this process must not be reachable by anyone able to spoof a source address.
129
131
 
130
- **Point `adapters.rateLimiter` at Redis.** It defaults to `"memory"`, which is per-process: with N replicas every configured limit is effectively N times larger and resets on every deploy. The memory adapter is also **evadable under bucket exhaustion**: it caps itself at 10,000 buckets and, at the cap, admitting a new key evicts the bucket closest to reset — so an attacker who can present many source IPs (and `req.ip` is client-influenced whenever `HTTP_TRUST_PROXY` is broader than your actual hops) can churn the table until a target's counter is evicted and starts over. That is acceptable for one dev process; it is not a production rate limit. The login guard runs on this same shared component, so one setting covers both the OAuth endpoints and `/session/login`. The login window and limit stay configured at `session.rateLimit.login`; the session module contributes them as the `login` budget both adapters read, so there is nothing to restate.
132
+ **Point `adapters.rateLimiter` at Redis.** It defaults to `"memory"`, which is per-process: with N replicas every configured limit is effectively N times larger and resets on every deploy. The memory adapter is also **evadable under bucket exhaustion**: it caps itself at 10,000 buckets and, at the cap, admitting a new key evicts the bucket closest to reset — so an attacker who can present many source IPs (and `req.ip` is client-influenced whenever `HTTP_TRUST_PROXY` is broader than your actual hops) can churn the table until a target's counter is evicted and starts over. That is acceptable for one dev process; it is not a production rate limit. `/session/login` does not run on this component, nor does `POST /oauth/device/verification` once you add the device grant: each one's own attempt limit (`session.rateLimit.login`, `device-grant.rateLimit`) is counted on the attempt counter, and no limiter setting changes it.
133
+
134
+ **The MFA routes' budget is the limiter's.** The MFA package ships no budget for its routes: every `/session/mfa` POST is limited per client address under the prefix `mfa` by the wired limiter alone. `config/reference.conf` gives both limiters `limits.mfa { limit = 60, windowSeconds = 300 }` (`core-rate-limiter-memory.limits.mfa`, `redis-rate-limiter.limits.mfa`), so switching `adapters.rateLimiter` keeps it. It is a default there, so the limiter the composition does not wire carries it without boot naming its section; change it for the wired limiter in `config/application.conf` or a layer of your own. Without an entry the limiter's `defaultLimit`, 60 per 60 s, applies. The MFA lock — attempts per transaction, the backoff, the weekly failures — is the MFA package's own and no limiter setting changes it.
135
+
136
+ **Point `adapters.attemptCounter` at Redis.** It defaults to `"memory"`, which wires no counter: the login — and device verification, when you add the device grant — counts its attempts per process, so with N replicas `session.rateLimit.login` (and `device-grant.rateLimit`) allows N times its limit and resets on every deploy, and `CORE_DEPLOYMENT_MODE=multi` refuses the boot. `"redis"` (`ADAPTERS_ATTEMPT_COUNTER=redis`) installs `redisAttemptCounterModule` on the shared Redis socket. Its Redis must run `maxmemory-policy noeviction` (Redis's default): an evicted window would start the count over. The module refuses to boot on any other policy. It also refuses on a policy the server will not report, unless `REDIS_CLIENTS_ASSUME_NO_EVICTION=true` (above). Both fail closed: while the counter cannot answer, `/session/login` and `POST /oauth/device/verification` answer `503`, whatever `redis-rate-limiter.failMode` says.
131
137
 
132
138
  **Behind a BFF, raise `limits.token` before you need to.** The OAuth-endpoint
133
139
  rate limits key on `req.ip` (`packages/core/src/ratelimit/guard.mts` builds the
@@ -173,9 +179,9 @@ raising it globally to accommodate one topology would weaken it for the other.
173
179
  Put the throttling that protects the BFF's *own* users in front of the BFF,
174
180
  where per-user identity still exists.
175
181
 
176
- **Set `CORE_DEPLOYMENT_MODE=multi` once you run more than one replica.** Boot then *fails* if any in-memory store that has to be shared is still wired, naming every offender and what it costs — user sessions forking (back-channel logout reaches one replica, a logged-out session stays valid on the others), rate-limit counters multiplying, access-token revocation not propagating, a single-use client assertion or WebAuthn challenge replayable once per replica. The check reads the declaration each installed module carries on its own manifest rather than a list of library module names, so this template's own in-memory modules — the user-session stores (`ADAPTERS_USER_SESSION_STORES=memory`), the authorization-code repository (`ADAPTERS_CODE_REPOSITORY=memory`) and the federation token store (`ADAPTERS_FEDERATION_TOKEN_STORE=memory`, the default) — are refused by name too. So are express-session's own store under `SESSION_STORE_STORAGE_TYPE=memory` (#474) and the default memory rate limiter (`core-rate-limiter-memory`, `ADAPTERS_RATE_LIMITER=memory`). With the mode unset nothing is refused: every one of them is listed in a single `replica_unsafe_adapters` warning at boot. (The login and WebAuthn-options routes carry per-process fallback limiters that warn on their own, but only in a composition that wires no `rateLimiter` at all, which this template never does; see the operator runbook.) With `CORE_DEPLOYMENT_MODE=single` the check is silent, because you have said there is one replica. This template does not install DPoP; a composition that adds it records every accepted proof in the same replay seen-set as `private_key_jwt` (`ADAPTERS_REPLAY_SEEN_SET`), so it gets the same answer — `memory` is refused under `CORE_DEPLOYMENT_MODE=multi` and listed in the unset-mode warning, and the shipped `redis` shares DPoP's records across replicas — see the dpop package's [operator requirements](../../packages/dpop/README.md#operator-requirements).
182
+ **Set `CORE_DEPLOYMENT_MODE=multi` once you run more than one replica.** Boot then *fails* if any in-memory store that has to be shared is still wired, naming every offender and what it costs — user sessions forking (back-channel logout reaches one replica, a logged-out session stays valid on the others), rate-limit counters multiplying, access-token revocation not propagating, a single-use client assertion or WebAuthn challenge replayable once per replica. The check reads the declaration each installed module carries on its own manifest rather than a list of library module names, so this template's own in-memory modules — the user-session stores (`ADAPTERS_USER_SESSION_STORES=memory`), the authorization-code repository (`ADAPTERS_CODE_REPOSITORY=memory`) and the federation token store (`ADAPTERS_FEDERATION_TOKEN_STORE=memory`, the default) — are refused by name too. So are express-session's own store under `SESSION_STORE_STORAGE_TYPE=memory` (#474) and the default memory rate limiter (`core-rate-limiter-memory`, `ADAPTERS_RATE_LIMITER=memory`). With the mode unset nothing is refused: every one of them is listed in a single `replica_unsafe_adapters` warning at boot. (The WebAuthn-options route carries a per-process fallback limiter that warns on its own, but only in a composition that wires no `rateLimiter` at all, which this template never does. The login counts per process under `ADAPTERS_ATTEMPT_COUNTER=memory`: `multi` refuses the boot, naming the login, and an unset mode warns `attempt_counter_not_shared`; see the operator runbook.) With `CORE_DEPLOYMENT_MODE=single` the check is silent, because you have said there is one replica. This template does not install DPoP; a composition that adds it records every accepted proof in the same replay seen-set as `private_key_jwt` (`ADAPTERS_REPLAY_SEEN_SET`), so it gets the same answer — `memory` is refused under `CORE_DEPLOYMENT_MODE=multi` and listed in the unset-mode warning, and the shipped `redis` shares DPoP's records across replicas — see the dpop package's [operator requirements](../../packages/dpop/README.md#operator-requirements).
177
183
 
178
- The variable sets `core.deployment.mode`. `DEPLOYMENT_MODE`, its old name, refuses boot set alone or beside `CORE_DEPLOYMENT_MODE` at a different value; beside it at the same value, it boots.
184
+ The variable sets `core.deployment.mode`. `DEPLOYMENT_MODE`, its old name, refuses boot while it is set, alone or beside `CORE_DEPLOYMENT_MODE` at any value: unset it.
179
185
 
180
186
  Be aware of what this check *cannot* do: if you scale to N replicas without ever setting `CORE_DEPLOYMENT_MODE`, nothing fails. A process holding all its state in its own memory has no shared medium through which to notice peers — the condition is undetectable from inside exactly when it is true. Set the variable as part of scaling, not after something breaks.
181
187
 
@@ -296,7 +302,10 @@ Configuration is loaded from `config/application.conf` (HOCON format), over the
296
302
  `http.cors`), `key-store`, the shared Redis connection's `redis-clients`,
297
303
  `repositories`, the in-process code repository's
298
304
  `standalone-in-memory-code-repository` and `audit-sink` — and of the
299
- composition root's own `adapters` and `mfaMode` (below). Set a deployment's own value in
305
+ composition root's own `adapters` and `mfaMode` (below), and the MFA
306
+ routes' budget in both rate limiters' sections
307
+ (`core-rate-limiter-memory.limits.mfa`, `redis-rate-limiter.limits.mfa`;
308
+ see [Multi-replica deployments](#multi-replica-deployments)). Set a deployment's own value in
300
309
  the two files above, not there. `config/reference.conf` binds each of these
301
310
  keys' variables beside its default, so a value either file above sets wins
302
311
  over the variable, except for `HTTP_PORT`, `HTTP_TRUST_PROXY`,
@@ -311,35 +320,61 @@ It reads the two files above once, under one snapshot of the environment
311
320
  [`src/configPath.mts`](src/configPath.mts)) — so a file replaced, or a
312
321
  variable changed, while the process starts cannot make boot parse something
313
322
  other than what the modules were chosen by. First, before it knows its
314
- modules, it reads the switches `buildModules` chooses them by — the
315
- federations, the features — and the `sessionRequirements` it derives the
316
- session requirements it expects from, from the two files above over core's
317
- `reference.conf` alone (`readSwitches`, with core's transitional reader),
318
- parsing those paths (`SWITCHES`) and nothing else. It reads `adapters` — which
319
- adapter fills each slot, the composition root's own section — from the two
320
- files above over the template's `config/reference.conf`, with the template's
321
- own schema (`readAdapters`): a selection still written at the path it moved
322
- from, or a variable renamed with it, is refused there, before any module is
323
- chosen, naming the new path and variable. It reads the MFA switch `mfaMode`
323
+ modules, it reads what `buildModules` chooses them by — never a federation
324
+ entry — from the two files above over the template's `config/reference.conf`
325
+ alone (`readSwitches`). It reads `adapters` — which adapter fills each slot,
326
+ the composition root's own section — with the template's own schema
327
+ (`readAdapters`): a selection still written at the path it moved from, or a
328
+ variable renamed with it, is refused there, before any module is chosen,
329
+ naming the new path and variable. It reads the MFA switch `mfaMode`
324
330
  (`MFA_MODE`), the composition root's own key, the same way, with the
325
331
  template's own schema (`readMfaSwitch`), and beside them the user
326
332
  repository's HTTP settings, unparsed, which the Store-backed MFA factor store
327
- is built over. Boot is not handed `adapters` or `mfaMode`. It sees nothing
328
- a package's `reference.conf` alone sets — none is layered yet — and a module
329
- you add to `buildModules` that reads its configuration when it is built adds
330
- the paths it reads to `SWITCHES`. The log level is read before boot too, as
333
+ is built over. It reads whether federation grants are installed from
334
+ `federation-grants.enabled` (`FEDERATION_GRANTS_ENABLED`, which
335
+ `application.conf` binds for this), with core's `coerceBooleanFromEnv`: a
336
+ value that does not read as `false` installs the federation-grants modules,
337
+ so that boot refuses a bad value at `federation-grants.enabled` rather than
338
+ the feature reading as off, and a setting at the section's old path,
339
+ `federationGrants`, installs them too, for boot to refuse naming the new
340
+ path. Boot is not handed `adapters` or `mfaMode`. Phase one reads no other
341
+ key, and sees nothing a package's `reference.conf` alone sets — none is
342
+ layered yet. A module you add to `buildModules` reads its own section at
343
+ boot (`deps.section`), never when it is built. The log level is read before boot too, as
331
344
  the `logging` module's section, with that module's schema, over the template's
332
345
  `reference.conf` (`readLogging`): the logger exists before boot, since the
333
- template logs while it reads its configuration and chooses its modules. Then it hands `createApp` the configuration as resolved over every
334
- loaded module's `reference.conf` (`resolveForBoot`), unparsed, with the session
335
- requirements phase one derived written in, and the `mfa` section as the MFA
336
- switch decides it (see [Multi-factor authentication](#multi-factor-authentication)):
346
+ template logs while it reads its configuration and chooses its modules.
347
+ What the template refuses of the configuration before boot — `adapters`,
348
+ `mfaMode` and `logging` in phase one, and what it builds for boot below
349
+ (`resolveForBoot`) — is a `BootError` under the reason boot raises for the
350
+ same case (`config-path-relocated`, `environment-variable-renamed`,
351
+ `config-validation-failed`, `module-section-path-invalid`;
352
+ [`src/bootRefusal.mts`](src/bootRefusal.mts)), so an alert on a boot error's
353
+ reason sees it too. Not every failure to start carries a reason: a
354
+ `CONFIG_ENV` naming a file outside `config/` is a plain `Error`, a missing
355
+ `{ENV}.conf` or a file HOCON cannot parse is the HOCON library's error, and
356
+ a failure after boot, such as a port the listener cannot bind, is its own. Then it hands `createApp` the configuration as resolved over every
357
+ loaded module's `reference.conf` (`resolveForBoot`), unparsed, with
358
+ `core.sessionRequirements` as the MFA switch expects it written in — the
359
+ configuration's list with `mfa` added when the switch installs MFA; a value
360
+ that is not a list of names is handed on as written, for boot to refuse — and
361
+ the `mfa` section as the MFA switch decides it (see [Multi-factor authentication](#multi-factor-authentication)):
337
362
  boot parses it once, with every loaded module's schema. What
338
363
  the template reads after boot — the trusted hops, the port, the readiness
339
364
  deadline — it reads from the `http` module: core's `httpSettings` slot and the
340
- template's `httpHostSettings`. A top-level section no
341
- loaded module owns is kept and logged once at boot as
342
- `config_sections_ignored`: that is where a misspelt section name shows.
365
+ template's `httpHostSettings`. Beside it, the template hands boot the
366
+ configuration's defaults (`configDefaultsFor`): the same `reference.conf`
367
+ files with neither of the two files above and no environment. Boot names a
368
+ top-level section no loaded module owns by them, once, at warn, and keeps it:
369
+ a section a loaded package's `reference.conf` sets for a module the
370
+ composition does not load (`redis-clients` with no store on Redis, say) is
371
+ silent while it is as that file sets it, and `config_sections_not_loaded`
372
+ once your files or a variable change it — the setting reaches nothing; a
373
+ section no loaded package sets is `config_sections_ignored`, which is where a
374
+ misspelt section name shows. A renamed variable a loaded package's
375
+ `reference.conf` captures and no loaded module declares is named
376
+ `environment_variables_not_applied`. The [operator
377
+ runbook](../../docs/operator-runbook.md) has a row for each.
343
378
 
344
379
  Values in the overlay take precedence over `application.conf`. The scaffold
345
380
  ships with `development.conf`, `production.conf` and `mailpit.conf` (the
@@ -359,6 +394,7 @@ fails before boot, naming it.
359
394
  | Variable | Default | Description |
360
395
  |---|---|---|
361
396
  | `ADAPTERS_RATE_LIMITER` | `memory` | `adapters.rateLimiter`: `memory` or `redis`. See [Multi-replica deployments](#multi-replica-deployments) |
397
+ | `ADAPTERS_ATTEMPT_COUNTER` | `memory` | `adapters.attemptCounter`: the counter behind the login's attempt limit (and device verification's, when the device grant is added), `memory` (none: per process) or `redis`. See [Multi-replica deployments](#multi-replica-deployments) |
362
398
  | `ADAPTERS_USER_SESSION_STORES` | `memory` | `adapters.userSessionStores`: the user-session stores, `memory` or `redis`. Keep it with `SESSION_STORE_STORAGE_TYPE` (see [Docker](#docker)) |
363
399
  | `ADAPTERS_ACCESS_TOKEN_DENYLIST` | `redis` | `adapters.accessTokenDenylist`: `memory` or `redis` |
364
400
  | `ADAPTERS_REPLAY_SEEN_SET` | `redis` | `adapters.replaySeenSet`: the replay seen-set behind `private_key_jwt`, `memory` or `redis` |
@@ -376,9 +412,8 @@ fails before boot, naming it.
376
412
  A selection still written where it was — `rateLimiter.adapter`,
377
413
  `oauth.code.adapter`, `audit.sink.type`, `repositories.user.type` and the
378
414
  like — or a variable renamed with it (`RATE_LIMITER_ADAPTER`, `CLIENT_TYPE`,
379
- …) set alone or to a different value, fails before boot, naming the new path
380
- and variable; the old variable beside the new one at the same value is
381
- accepted.
415
+ …) set, alone or beside the new one at any value, fails before boot, naming
416
+ the new path and variable: unset the old variable.
382
417
 
383
418
  ### HTTP
384
419
 
@@ -447,7 +482,6 @@ every relying party must be handed the shared secret — which also lets it
447
482
  |---|---|---|
448
483
  | `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` | `3600` | Access token lifetime in seconds that every grant mints when the request asks for none. Whole positive number, at most one year (`31536000`). |
449
484
  | `OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN` | the default | The most a token-exchange request's `expires_in` parameter can obtain; a larger request is clamped to it. Unset means the default, so no token is extended unless you set this. A default above it is a boot failure naming both keys. It also bounds how long an exchanged token outlives a revocation at a resource server that validates it offline. |
450
- | `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | — | **Deprecated** alias of `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` (config key `oauth.accessToken.expiresIn`, of `oauth.accessToken.defaultExpiresIn`), read only while the new one is unset; while it still decides the default, boot logs `config_key_deprecated` (warn). Move the value to the new variable. |
451
485
  | `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | Refresh token lifetime in seconds. Whole positive number, at most one year (`31536000`). |
452
486
 
453
487
  Each is read only as decimal digits. Exporting one as an empty string is a
@@ -457,6 +491,11 @@ boot failure, not a fallback (HOCON resolves `FOO=` to `""`), and so is a hex
457
491
  Only token exchange (RFC 8693) reads an `expires_in` request parameter; every
458
492
  other grant ignores it and mints the default.
459
493
 
494
+ The default's old name, `OAUTH_ACCESS_TOKEN_EXPIRES_IN` (config key
495
+ `oauth.accessToken.expiresIn`), refuses boot while it is set, alone or beside
496
+ `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` at any value. The old key refuses boot at any value. Move the value to
497
+ the new name rather than deleting it: without it, the default is `3600`.
498
+
460
499
  ### Grant Types
461
500
 
462
501
  | Variable | Default | Description |
@@ -468,11 +507,11 @@ other grant ignores it and mints the default.
468
507
  | `OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED` | `false` | Enable the jwt-bearer grant type (RFC 7523) |
469
508
 
470
509
  Each switch is its module's key — `oauth-session.enabled`, and
471
- `oauth-authorization.grants.<grant>.enabled` for the others — which the
472
- template reads before boot, from its own files and the environment, to choose
473
- the modules. `OAUTH_GRANTS_<GRANT>_ENABLED`, the old names, refuse boot set
474
- alone or beside the new name at a different value; beside it at the same value,
475
- they boot.
510
+ `oauth-authorization.grants.<grant>.enabled` for the others. Both modules are
511
+ always loaded and read their switches at boot, from the configuration boot
512
+ parses; the template reads none of them before boot. `OAUTH_GRANTS_<GRANT>_ENABLED`,
513
+ the old names, refuse boot while they are set, alone or beside the new name at
514
+ any value.
476
515
 
477
516
  ### Session
478
517
 
@@ -492,8 +531,8 @@ they boot.
492
531
  The cookie and its store are the session store's section, `session-store`; the
493
532
  CSRF token's lifetime, the login page and the login's budget are the session
494
533
  module's, `session`. `SESSION_<KEY>`, the old names of the `SESSION_STORE_*`
495
- variables, refuse boot set alone or beside the new name at a different value;
496
- beside it at the same value, they boot.
534
+ variables, refuse boot while they are set, alone or beside the new name at any
535
+ value.
497
536
 
498
537
  If you set `SESSION_STORE_SECURE=false` for local HTTP development or set `SESSION_STORE_DOMAIN`
499
538
  for shared-domain cookies, also set `SESSION_STORE_NAME` to a non-`__Host-` value such as
@@ -598,8 +637,46 @@ each is reached at (`/session/oauth/federation/<name>`). A key of one is bound
598
637
  to the variable named after its path, `CORE_FEDERATIONS_<NAME>_<KEY>`. The map
599
638
  written at the top level (`federations { ... }`) refuses boot, naming each
600
639
  key's path under `core.federations`; a `FEDERATIONS_GOOGLE_*` or
601
- `FEDERATIONS_OIDC_*` variable set alone, or beside its new name at a different
602
- value, is refused before any module is chosen.
640
+ `FEDERATIONS_OIDC_*` variable set, alone or beside its new name at any value,
641
+ is refused before any module is chosen.
642
+
643
+ Every entry names its `type`, and boot hands each enabled entry to the module
644
+ that handles that type, under the entry's name. The template always loads the
645
+ two types it bundles — `google` (`@o3co/auth-provider-federation-google`) and
646
+ `oidc` (`@o3co/auth-provider-federation-oidc`) — whatever the map says, and
647
+ reads no entry itself: a key an entry carries is read, and a malformed one
648
+ refused at its path (`core.federations.<name>.<key>`), by core and by the
649
+ type's schema. An entry with no `type`, enabled or not, is refused at
650
+ `core.federations.<name>.type` (`config-validation-failed`); an enabled entry
651
+ of a type no loaded module handles refuses the boot
652
+ (`federation-type-unhandled`), naming the entry.
653
+ GitHub and Apple are not bundled: a deployment that wants one adds its
654
+ package and its module to `buildModules` — GitHub's type module,
655
+ `githubFederationTypeModule()`, with entries of `type = "github"`; Apple's
656
+ per its [README](../../packages/federation-apple/README.md).
657
+
658
+ `core.federations.google` ships with `type = "google"`. A second Google
659
+ client is a second entry of that type, under its own name and with its own
660
+ callback:
661
+
662
+ ```hocon
663
+ core.federations {
664
+ google-work {
665
+ enabled = true
666
+ type = "google"
667
+ clientId = ${GOOGLE_WORK_CLIENT_ID}
668
+ clientSecret = ${GOOGLE_WORK_CLIENT_SECRET}
669
+ callbackURL = "https://auth.example.com/session/oauth/federation/google-work/callback"
670
+ }
671
+ }
672
+ ```
673
+
674
+ The keys a `google` entry takes beside the variables below — `redirectAllowlist`,
675
+ `sessionDomain`, `authCallbackUrl`, `clientUrl`,
676
+ `requireAuthorizationResponseIss`, `endSessionEndpoint` — are listed in full
677
+ in [the package's README](../../packages/federation-google/README.md);
678
+ `config/application.conf` shows every one of them but `endSessionEndpoint`.
679
+ A key the type does not name refuses the entry.
603
680
 
604
681
  | Variable | Default | Description |
605
682
  |---|---|---|
@@ -641,9 +718,8 @@ core.federations {
641
718
  }
642
719
  ```
643
720
 
644
- The entry's `type` names the implementation. `core.federations.google` without a
645
- `type` is the built-in Google federation; with `type = "oidc"` it is a generic
646
- OIDC instance named `google`, and the built-in module is not composed.
721
+ The entry's `type` names the implementation, never its name: an entry named
722
+ `google` with `type = "oidc"` is a generic OIDC federation named `google`.
647
723
 
648
724
  Every field the package accepts — `scopes`, `discovery` / `endpoints`,
649
725
  `privateKey`, `userInfo`, `idTokenSignedResponseAlg`, `clockToleranceSeconds` —
@@ -730,8 +806,8 @@ its own.
730
806
 
731
807
  `CLIENT_CODE_DEFAULT_EXPIRES_IN` and `CLIENT_CODE_KEY_PREFIX` were renamed
732
808
  with them, and `CLIENT_CODE_ENDPOINT_URI` and `CLIENT_CODE_PASSWORD` removed. A
733
- renamed one set alone, or beside its new name at a different value, and a
734
- removed one set at all, fails boot naming what to set instead.
809
+ renamed one set, alone or beside its new name at any value, and a removed one
810
+ set at all, fails boot naming what to set instead.
735
811
 
736
812
  ### Federation Token Store
737
813
 
@@ -798,7 +874,7 @@ in development too, or every password login stops there.
798
874
  | Variable | Default | Description |
799
875
  |---|---|---|
800
876
  | `MFA_MODE` | `required` | `mfaMode`: `required`, `optional` or `off` |
801
- | `MFA_ENCRYPTION_KEY` | — | The MFA key ring's first key, which seals every factor's data: canonical base64 of 32 bytes (`openssl rand -base64 32`). Required with MFA on. Under `CONFIG_ENV=development`, `config/development.conf` puts the MFA package's published sample key in its place: write your own key there instead, since this variable set beside that ring refuses the boot. The sample key is refused under `CONFIG_ENV` or `NODE_ENV` `production` or `staging` and under `CORE_DEPLOYMENT_MODE=multi` |
877
+ | `MFA_ENCRYPTION_KEY` | — | The MFA key ring's first key, which seals every factor's data: canonical base64 of 32 bytes (`openssl rand -base64 32`). Required with MFA on. Under `CONFIG_ENV=development`, `config/development.conf` puts the MFA package's published sample key in its place: write your own key there instead, since this variable set beside that ring refuses the boot. The sample key is accepted only where `CONFIG_ENV` and `NODE_ENV`, each where set, say `development` or `test`, and is refused under `CORE_DEPLOYMENT_MODE=multi` |
802
878
  | `MFA_PAGE_URL` | `/mfa` | The deployment's MFA page, where a login's second factor and a step-up start. The template ships no page: the page contract is the [MFA package's](../../packages/mfa/README.md#the-routes) |
803
879
  | `MFA_STORE_TIMEOUT_MS` | `5000` | `mfa.storeTimeoutMs`, one Store call's time: at least every store's own per-call timeout. Below `REPOSITORIES_USER_HTTP_TIMEOUT` where the Store is called (`ADAPTERS_USER_REPOSITORY=http`, or the factors in the Store) the boot is refused, so raise the two together; above 37500 ms it is refused too |
804
880
  | `STANDARD_SMTP_MAIL_SENDER_HOST` | — | The SMTP relay outside development, where MFA mails the account-email proof and the email factor's codes. With MFA on, the boot needs it and `STANDARD_SMTP_MAIL_SENDER_FROM` |
@@ -997,7 +1073,7 @@ auth.provider instances cannot collide in the same database:
997
1073
 
998
1074
  | Variable | Default | Description |
999
1075
  |---|---|---|
1000
- | `REDIS_SESSION_STORES_KEY_PREFIX` | `ss:` | Outer prefix for user sessions, RP registry, session-family index, and session-federation index. |
1076
+ | `REDIS_SESSION_STORES_KEY_PREFIX` | `ss:` | Outer prefix for user sessions, the subject session index, the subject-revocation boundaries and the session lifecycle records. |
1001
1077
  | `REDIS_REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX` | `rtfam:` | Prefix for refresh-token family records. |
1002
1078
  | `REDIS_CODE_REPOSITORY_KEY_PREFIX` | `oauth:code:` | Prefix for OAuth authorization codes. |
1003
1079
  | `REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX` | `ft:` | Prefix for federation token records, their per-session index and their lock keys. |
@@ -1021,15 +1097,15 @@ are configured per federation (`CORE_FEDERATIONS_GOOGLE_CALLBACK_URL`,
1021
1097
 
1022
1098
  [`src/buildModules.mts`](src/buildModules.mts) is the single source of truth for which modules are composed and in what order — read it rather than a copy of it. The boot planner resolves `requires` / `provides` topologically, but it mounts routes and middleware in list order unless a module declares `before` / `after` — so position matters for anything that mounts. These are the rules to keep when you edit the list:
1023
1099
 
1024
- 1. **`sessionStoreModuleFor(config)` stays first.** It mounts the `express-session` middleware and declares no `before` / `after`, so its position in the list is what puts it ahead of every route that reads `req.session`. It is built from `config` so that `session-store.storage.type = "memory"` declares itself replica-unsafe.
1025
- 2. **Under `/oauth`, the order does not matter.** `federationGrantsModules` (while federation grants are enabled), `oauthModule` and any module of your own may all mount routes under `/oauth`. `oauthModule`'s router parses the bodies of its own routes only, so each module's requests reach its own parsers whatever the list order — provided every module under `/oauth` parses its own body and scopes its parsers to exactly its own paths, as the shipped ones do — a route (`router.all(path, parser)` or the route's own handler list), not `router.use(path, parser)`, which matches every path beneath `path` as well. The federation grants browser half orders itself after the session middleware with its own `after`.
1100
+ 1. **`sessionStoreModule` stays first.** It mounts the `express-session` middleware and declares no `before` / `after`, so its position in the list is what puts it ahead of every route that reads `req.session`. It declares its replica safety from its own section, so `session-store.storage.type = "memory"` is replica-unsafe and refused under `core.deployment.mode = "multi"`.
1101
+ 2. **Under `/oauth`, the order does not matter.** `federationGrantsModules` (while federation grants are enabled), `oauthEndpointsModule` and any module of your own may all mount routes under `/oauth`. `oauthEndpointsModule`'s router parses the bodies of its own routes only, so each module's requests reach its own parsers whatever the list order — provided every module under `/oauth` parses its own body and scopes its parsers to exactly its own paths, as the shipped ones do — a route (`router.all(path, parser)` or the route's own handler list), not `router.use(path, parser)`, which matches every path beneath `path` as well. The federation grants browser half orders itself after the session middleware with its own `after`.
1026
1102
  3. **One module per store slot.** Each adapter switch — `adapters.federationTokenStore`, `adapters.userSessionStores`, `adapters.rateLimiter`, `adapters.codeRepository`, `adapters.accessTokenDenylist`, `adapters.replaySeenSet`, `adapters.consentStore`, the two federation-grant store switches and the two MFA store switches — picks one of a memory / Redis pair (or, for the MFA factors, the Store). Both provide the same slot, so wiring both is a boot-time slot collision. `adapters.consentStore = "none"` wires neither, the federation-grant stores are wired only while the feature is enabled, and the MFA stores only while `MFA_MODE` installs MFA.
1027
1103
  4. **The shared Redis connection comes with the first Redis-backed module.** `standaloneRedisClientsModule` opens the one ioredis connection every Redis adapter here uses, from its own section (`redis-clients`), and is added whenever a composed module needs one. The refresh-token family store is on Redis in the shipped composition, so a deployment always has it; the in-memory family store is a test override (`overrides.refreshTokenFamilyModules`).
1028
1104
  5. **The template's own settings modules are always composed.** `loggingModule` and `httpModule` own `logging {}` and `http {}`, the CORS list among `http`'s keys (`http.cors.allowedOrigins`). `httpModule` provides core's `httpSettings`, which is authoritative, so no `overrideComponents` entry replaces it while the module is loaded; it also provides the template's `httpHostSettings`, which `app.mts` reads after boot for the port and the readiness deadline. The logger is built before boot from the `logging` section (`readLogging`), not by a module, and handed to boot as the `logger` component.
1029
- 6. **A federation adapter comes with its config bridge.** `googleFederationModule` with `googleFederationConfigModule` — only for an enabled `core.federations.google` entry whose `type` is `google`, so a `type = "oidc"` section named `google` is not composed twice — and one `oidcFederationModule(name)` per enabled `type = "oidc"` section, with the one `oidcFederationConfigModule` they share. A bridge's provider throws when its section is absent, so the pair is included at composition time rather than gated inside it.
1030
- 7. **The mail sender follows the environment.** `@o3co/auth-provider-standard`'s development sender, which logs each code, where the configuration was selected as `development`; its module installs only where that name, and `CONFIG_ENV` and `NODE_ENV` wherever they are set, each read `development` or `test`, and refuses the boot otherwise, or where `core.deployment.mode` is `multi`. Under any other name, the SMTP sender's module, whose section is `standard-smtp-mail-sender` ([the package's README](../../packages/standard/README.md)); its sender is built only where something reads the `mailSender` slot, and there the boot needs `STANDARD_SMTP_MAIL_SENDER_HOST` and `STANDARD_SMTP_MAIL_SENDER_FROM`. While `MFA_MODE` is `off` nothing reads the slot, so the template boots without them; with MFA on, the MFA module and the operator reset read it. A sender of your own replaces this choice: pass its module where [`src/app.mts`](src/app.mts) calls `buildModules`, as `buildModules(switches, { environment: env, logger, mailSenderModules: [mySenderModule] })`. Given, `mailSenderModules` is installed under every environment name and the bundled sender is not, so the two never both provide the slot; an empty list installs no sender, and a module that needs one (the MFA email factor, or the MFA module with `requireEmailProof = "always"`) then refuses the boot.
1105
+ 6. **The federation types are always composed.** `googleFederationTypeModule()` and `oidcFederationTypeModule()` are listed whatever `core.federations` says: each contributes its type, and boot builds one federation per enabled entry of it, under the entry's name, so a federation is added or removed in configuration alone. Nothing in the list reads an entry. A module that contributes a federation under an entry's name directly beside an entry dispatched by its type is a boot-time `duplicate-contribute`: add a federation of another kind as a type module, and give its entries that type.
1106
+ 7. **The mail sender follows the environment.** `@o3co/auth-provider-standard`'s development sender, which logs each code, where the configuration was selected as `development`; its module installs only where that name, and `CONFIG_ENV` and `NODE_ENV` wherever they are set, each read `development` or `test`, and refuses the boot otherwise, or where `core.deployment.mode` is `multi`. Under any other name, the SMTP sender's module, whose section is `standard-smtp-mail-sender` ([the package's README](../../packages/standard/README.md)); its sender is built only where something reads the `mailSender` slot, and there the boot needs `STANDARD_SMTP_MAIL_SENDER_HOST` and `STANDARD_SMTP_MAIL_SENDER_FROM`. While `MFA_MODE` is `off` nothing reads the slot, so the template boots without them; with MFA on, the MFA module and the operator reset read it. A sender of your own replaces this choice: pass its module where [`src/app.mts`](src/app.mts) calls `buildModules`, as `buildModules(switches, { environment: env, mailSenderModules: [mySenderModule] })`. Given, `mailSenderModules` is installed under every environment name and the bundled sender is not, so the two never both provide the slot; an empty list installs no sender, and a module that needs one (the MFA email factor, or the MFA module with `requireEmailProof = "always"`) then refuses the boot.
1031
1107
 
1032
- `jwksModule` (from core) is always composed: a provider that signs tokens publishes its verification keys whether or not an issuer is configured. What each route module mounts is in its package's README. One behaviour to know when composing: `sessionModule`'s `POST /session/logout` deletes the `UserSession` record (so `/oauth/introspect` and `/oauth/userinfo` stop honouring tokens minted from that session), the subject index and the federation entries — but it does **not** revoke refresh-token families; `POST /oauth/logout` is the endpoint that runs the full cascade. See [Which logout endpoint invalidates what](../../docs/operator-runbook.md#which-logout-endpoint-invalidates-what).
1108
+ `jwksModule` (from core) is always composed: a provider that signs tokens publishes its verification keys whether or not an issuer is configured. What each route module mounts is in its package's README. The template composes core's `sessionLifecycleModule`: a login opens its session's record, a code exchange joins it, and both `POST /session/logout` and `POST /oauth/logout` close the session through the lifecycle, which revokes the session's refresh-token families and tells its relying parties. See [Which logout endpoint invalidates what](../../docs/operator-runbook.md#which-logout-endpoint-invalidates-what).
1033
1109
 
1034
1110
  ## Built-in Routes
1035
1111
 
@@ -1138,6 +1214,10 @@ development sender, which only logs each code:
1138
1214
  docker compose -f docker-compose.yml -f docker-compose.mailpit.yml up --build
1139
1215
  ```
1140
1216
 
1217
+ Before the first run, write a key of your own into `.env` as
1218
+ `MFA_ENCRYPTION_KEY` (`openssl rand -base64 32`): this run does not use the
1219
+ development sample key, and without a key the boot is refused.
1220
+
1141
1221
  Read the mail at <http://localhost:8025>. Mailpit keeps it and delivers
1142
1222
  nothing onward. A plain `docker compose up` (`make dev`) starts no Mailpit, and
1143
1223
  the production file names none. It is for development only: a deployment
@@ -1148,9 +1228,11 @@ What the overlay sets on the app service:
1148
1228
 
1149
1229
  - `CONFIG_ENV=mailpit`. Under `development` the template installs the sender
1150
1230
  that logs each code ([Module Composition Order](#module-composition-order),
1151
- rule 7). [`config/mailpit.conf`](config/mailpit.conf) includes
1152
- `development.conf`, so the run is otherwise the development one, the MFA
1153
- sample key included.
1231
+ rule 7). [`config/mailpit.conf`](config/mailpit.conf) does not include
1232
+ `development.conf`, whose one setting is the MFA sample key: the MFA
1233
+ package accepts that key only where every environment name set says
1234
+ `development` or `test`. The key ring's first key is `MFA_ENCRYPTION_KEY`,
1235
+ as the MFA package's `reference.conf` binds it.
1154
1236
  - Nothing of `MFA_MODE`: MFA is on as the template ships it, or as your
1155
1237
  `.env` sets it. With MFA off nothing sends mail.
1156
1238
  - `ADAPTERS_MFA_FACTOR_STORE=redis` and `ADAPTERS_MFA_TRANSACTION_STORE=redis`,
@@ -1243,14 +1325,14 @@ unready one.
1243
1325
 
1244
1326
  ## Adding Custom Modules
1245
1327
 
1246
- To add a custom module, import it in `src/buildModules.mts` and add it to the array `buildModules` returns. The boot planner resolves dependencies by what modules `require` and `provide`, but it mounts routes and middleware in list order wherever no `before` / `after` says otherwise. So position matters for middleware that other routes depend on — the session store module, rule 1 of [Module Composition Order](#module-composition-order). A route under a prefix another module also uses, such as `/oauth`, needs no particular position (rule 2), provided it parses its own body and scopes its parsers to exactly its own paths: `oauthModule`'s router parses only its own routes', and a parser that ran for every request under the prefix would read other modules' bodies for them. A module that mounts nothing of the first kind can go at the end:
1328
+ To add a custom module, import it in `src/buildModules.mts` and add it to the array `buildModules` returns. The boot planner resolves dependencies by what modules `require` and `provide`, but it mounts routes and middleware in list order wherever no `before` / `after` says otherwise. So position matters for middleware that other routes depend on — the session store module, rule 1 of [Module Composition Order](#module-composition-order). A route under a prefix another module also uses, such as `/oauth`, needs no particular position (rule 2), provided it parses its own body and scopes its parsers to exactly its own paths: `oauthEndpointsModule`'s router parses only its own routes', and a parser that ran for every request under the prefix would read other modules' bodies for them. A module that mounts nothing of the first kind can go at the end:
1247
1329
 
1248
1330
  ```diff
1249
1331
  // src/buildModules.mts
1250
1332
  +import { myCustomModule } from "./my-custom-module.mjs";
1251
1333
  …
1252
1334
  return [
1253
- sessionStoreModuleFor(config),
1335
+ sessionStoreModule,
1254
1336
  …
1255
1337
  ...(federationGrantsEnabled || mfaInstalled ? [subjectRevocationServiceModule] : []),
1256
1338
  + myCustomModule,
@@ -28,12 +28,11 @@
28
28
  # `oauth-authorization.grants.clientCredentials.enabled = true` in their own
29
29
  # layer.
30
30
  #
31
- # The template reads these switches before it chooses its modules, from its
32
- # own files, so this file writes every switch and binds its variable: one set
33
- # only in the package's reference.conf would read as unset there, and a
34
- # variable bound only there would install no grant. The env-override line is
35
- # repeated after each value because a layer's own value wins over a variable
36
- # bound below it.
31
+ # The modules read these switches at boot, from the configuration boot parses;
32
+ # the template reads none of them before. This file writes the template's
33
+ # choices and binds each variable again: the env-override line is repeated
34
+ # after each value because a layer's own value wins over a variable bound
35
+ # below it.
37
36
  #
38
37
  # PKCE is mandatory for every authorization-code client and `S256` is the only
39
38
  # method the provider accepts -- fixed policy (OAuth 2.1 §4.1.1 / RFC 9700
@@ -94,8 +93,9 @@ oauth {
94
93
  # /authorize refuses any client not marked `firstParty: true` in
95
94
  # clients.yaml — the template's example clients are marked. There is no
96
95
  # opt-out: the one-time `allowUnmarkedClients` migration flag is removed,
97
- # and a config or environment still setting it fails at boot with
98
- # migration instructions (via the tombstone in core's reference.conf).
96
+ # and a config or environment still setting it fails at boot: the oauth
97
+ # module refuses the key as removed, and an exported
98
+ # OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS as the variable of a removed key.
99
99
  # POST /oauth/revoke revokes access tokens by writing their `jti` to the
100
100
  # denylist wired below, and boot FAILS if that denylist is missing — an
101
101
  # RFC 7009 endpoint must answer 200, so an unwired denylist would tell every
@@ -154,30 +154,20 @@ core.sessionRequirements.expected = []
154
154
  # the recorded decision not to make that check) — each refused at boot when
155
155
  # missing. Connections and the Redis grant store's key ring have no
156
156
  # environment form; write them in a deployment-owned HOCON layer (README:
157
- # Federation Grants). The template reads `federation-grants.enabled` before it
158
- # chooses its modules, from its own files, which is why this file binds
159
- # FEDERATION_GRANTS_ENABLED too.
160
- federation-grants {
161
- enabled = false
162
- enabled = ${?FEDERATION_GRANTS_ENABLED}
163
- identityLookup = "required"
164
- identityLookup = ${?FEDERATION_GRANTS_IDENTITY_LOOKUP}
165
- consent {
166
- url = ${?FEDERATION_GRANTS_CONSENT_URL}
167
- }
168
- }
169
-
170
- # The Redis grant store seals every credential; "required" needs its key ring,
171
- # `redis-federation-grant-store.encryptionKeys`.
172
- redis-federation-grant-store {
173
- encryptionMode = "required"
174
- encryptionMode = ${?REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE}
175
- }
157
+ # Federation Grants). The section's defaults and its other variables
158
+ # (FEDERATION_GRANTS_IDENTITY_LOOKUP, FEDERATION_GRANTS_CONSENT_URL) are the
159
+ # federation-grants package's reference.conf, and the Redis grant store's
160
+ # (REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE, "required" by default) the
161
+ # Redis package's: each is layered only where a module of its package is
162
+ # installed. The
163
+ # template reads `federation-grants.enabled` before it chooses its modules,
164
+ # from its own files, which is why this file binds FEDERATION_GRANTS_ENABLED;
165
+ # unset, it writes nothing, and the feature is off.
166
+ federation-grants.enabled = ${?FEDERATION_GRANTS_ENABLED}
176
167
 
177
168
  # The session store's section: the session cookie and the store
178
- # express-session keeps sessions in. The template reads `storage` before it
179
- # chooses its modules (a memory store declares itself replica-unsafe), from
180
- # its own files, which is why this file binds SESSION_STORE_STORAGE_TYPE too.
169
+ # express-session keeps sessions in. A memory store declares itself
170
+ # replica-unsafe from this section.
181
171
  session-store {
182
172
  secret = ${?SESSION_STORE_SECRET}
183
173
  # A `__Host-` name requires Secure, Path=/ and no Domain attribute. If
@@ -218,10 +208,11 @@ session {
218
208
  # ignored. The federation flow has a separate list per provider (see
219
209
  # `core.federations` below).
220
210
  redirectAllowlist = []
221
- # Rate limiting for POST /session/login (brute-force protection), in
222
- # windowMs (milliseconds); the session module contributes it as the `login`
223
- # budget, kept in this layer so operators can tweak it. It does NOT govern
224
- # OAuth endpoint rate limiting: the `rateLimiter` component
211
+ # POST /session/login's own attempt limit (brute-force protection), in
212
+ # windowMs (milliseconds), counted on the attempt counter
213
+ # (`adapters.attemptCounter`); kept in this layer so operators can tweak
214
+ # it. No rate limiter changes it. It does NOT govern OAuth endpoint rate
215
+ # limiting: the `rateLimiter` component
225
216
  # (memoryRateLimiterModule or redisRateLimiterModule) reads its own section,
226
217
  # `core-rate-limiter-memory` / `redis-rate-limiter` (with `windowSeconds`
227
218
  # instead of `windowMs`). Switch via `adapters.rateLimiter = "memory" |
@@ -233,15 +224,28 @@ session {
233
224
  }
234
225
  }
235
226
 
227
+ # The MFA routes' budget, 60 per 300 s, is config/reference.conf's, given to
228
+ # both limiters so that switching `adapters.rateLimiter` keeps it. Change it
229
+ # for the limiter this deployment wires, in its own section:
230
+ #
231
+ # redis-rate-limiter.limits.mfa { limit = 30, windowSeconds = 300 }
232
+
236
233
  # The federations, in core's own section, keyed by the name each is reached
237
234
  # at (/session/oauth/federation/<name>). A key of one is bound to the
238
- # variable named after its path, CORE_FEDERATIONS_<NAME>_<KEY>.
235
+ # variable named after its path, CORE_FEDERATIONS_<NAME>_<KEY>. Each entry
236
+ # names its `type`, and boot hands an enabled entry to the module that handles
237
+ # that type: the template loads the `google` and `oidc` types, so an entry of
238
+ # either type under any name is a federation of its own. An entry without a
239
+ # `type`, enabled or not, refuses the boot, and so does an enabled entry of a
240
+ # type no loaded module handles.
239
241
  core.federations {
240
242
  # Template ships a Google federation declaration disabled-by-default.
241
- # Operators enable + populate credentials via env vars.
243
+ # Operators enable + populate credentials via env vars. A second Google
244
+ # client is another entry with `type = "google"` and its own callbackURL.
242
245
  google {
243
246
  enabled = false
244
247
  enabled = ${?CORE_FEDERATIONS_GOOGLE_ENABLED}
248
+ type = "google"
245
249
  clientId = ${?CORE_FEDERATIONS_GOOGLE_CLIENT_ID}
246
250
  clientSecret = ${?CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET}
247
251
  callbackURL = "http://localhost:3000/session/oauth/federation/google/callback"
@@ -343,4 +347,5 @@ http {
343
347
  redis-clients {
344
348
  url = ${?REDIS_CLIENTS_URL}
345
349
  password = ${?REDIS_CLIENTS_PASSWORD}
350
+ assumeNoEviction = ${?REDIS_CLIENTS_ASSUME_NO_EVICTION}
346
351
  }
@@ -50,6 +50,14 @@
50
50
  # verifier itself as the challenge and so proves nothing against anyone who
51
51
  # can read the authorization request. Set it only for a legacy client that
52
52
  # genuinely cannot compute SHA-256, and treat it as a migration deadline.
53
+ #
54
+ # `allowExchangeOfTokensIssuedToOthers`: read only where the token-exchange
55
+ # module (@o3co/auth-provider-oauth-token-exchange) is installed, which this
56
+ # template does not do by default. Token exchange requires the caller to be
57
+ # an audience of the subject token by default: the token's `azp` is the
58
+ # client, or its `aud` contains it. `true` lets this one client exchange
59
+ # tokens issued to other clients (a gateway, an on-behalf-of service). Only
60
+ # a strict `true` counts.
53
61
 
54
62
  # A machine client that authenticates with a key pair (#484). Nothing here is
55
63
  # secret: the private half stays with the client, which signs an assertion
@@ -10,8 +10,8 @@
10
10
  # The MFA key ring's development key: the MFA package's published sample key
11
11
  # (MFA_DEVELOPMENT_SAMPLE_KEY), which everyone holds. It stands in for
12
12
  # MFA_ENCRYPTION_KEY here: to use a key of your own in development, write it
13
- # in place of this one. It is refused where the configuration is selected as
14
- # production or staging, where NODE_ENV is either, and under
13
+ # in place of this one. It is accepted only where the configuration's name and
14
+ # NODE_ENV, each where set, say development or test, and is refused under
15
15
  # `core.deployment.mode = "multi"`; boot warns while it is in use
16
16
  # (mfa_development_sample_key_in_use). Read only while MFA_MODE installs MFA.
17
17
  mfa.encryptionKeys = [{ key = "bzNjbzptZmE6ZGV2ZWxvcG1lbnQtc2FtcGxlLWtleSE=" }]
@@ -1,8 +1,13 @@
1
1
  # Environment overlay for a development run whose mail goes to Mailpit
2
2
  # (docker-compose.mailpit.yml). Loaded when CONFIG_ENV is "mailpit".
3
3
  #
4
- # The development overlay under another name: the template installs the
5
- # sender that logs each code only where the configuration is selected as
6
- # "development", and the SMTP sender under any other name. For development
7
- # only; a deployment names its own relay.
8
- include "development.conf"
4
+ # The template installs the sender that logs each code only where the
5
+ # configuration is selected as "development", and the SMTP sender under any
6
+ # other name. For development only; a deployment names its own relay.
7
+ #
8
+ # It does not include development.conf, whose one setting is the MFA
9
+ # development sample key: the MFA package accepts that key only where every
10
+ # environment name set says development or test, and this run is selected as
11
+ # "mailpit". The key ring's first key is MFA_ENCRYPTION_KEY, as the MFA
12
+ # package's reference.conf binds it: write a key of your own
13
+ # (openssl rand -base64 32) into .env; without one the boot is refused.