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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (92) hide show
  1. package/README.ja.md +139 -48
  2. package/README.md +138 -47
  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 +69 -24
  12. package/templates/standalone/Dockerfile +7 -3
  13. package/templates/standalone/README.ja.md +677 -184
  14. package/templates/standalone/README.md +699 -238
  15. package/templates/standalone/config/application.conf +203 -325
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +4 -2
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +121 -0
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +71 -115
  73. package/templates/standalone/src/buildModules.mts +229 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +60 -0
  76. package/templates/standalone/src/logger.mts +31 -57
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +480 -464
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +89 -0
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +54 -126
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
  91. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  92. package/templates/standalone/src/terminalError.mts +0 -60
@@ -1,21 +1,92 @@
1
1
  # @o3co/auth-provider-standalone
2
2
 
3
+ Last updated: 2026-10-03
4
+
3
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`.
4
6
 
7
+ ## Responsibility
8
+
9
+ **Role.** The composition root and the starting point for a deployment. It is
10
+ not a library: nothing imports it. `@o3co/create-auth-provider` copies it into
11
+ a new project, and from then on the operator owns and edits the copy. It sits
12
+ on top of the packages: it composes `@o3co/auth-provider-oauth`, `-session`,
13
+ the Google and generic OpenID Connect federation adapters, `-federation-grants`,
14
+ `-mfa`, `-redis`, `-foundation` and `-standard`'s mail senders over
15
+ `@o3co/auth-provider-core`.
16
+
17
+ **Owns** the choices that are specific to one deployment:
18
+
19
+ - which modules are composed, in what order, and which adapter fills each
20
+ store slot — [`src/buildModules.mts`](src/buildModules.mts) (see
21
+ [Module Composition Order](#module-composition-order)), the choice read from
22
+ its own keys, the `adapters` section ([`src/adapters.mts`](src/adapters.mts))
23
+ and the MFA switch `mfaMode` ([`src/mfaSwitch.mts`](src/mfaSwitch.mts));
24
+ - the modules only this scaffold has — `logging` and `http` (the host
25
+ process's own settings, with its CORS list, and core's `httpSettings`
26
+ slot), the signing key store, the client and user repositories, the audit
27
+ sink, the one shared
28
+ Redis connection, the in-memory user-session stores, code repository and
29
+ federation token store, and the federation config bridges —
30
+ [`src/modules.mts`](src/modules.mts), with the defaults of the sections they
31
+ own in [`config/reference.conf`](config/reference.conf). They are the
32
+ scaffold's rather than a package's because each builds its component from
33
+ this template's configuration sections; a deployment that wants another
34
+ source wires its own module of the same shape;
35
+ - where configuration comes from and how its layers stack —
36
+ [`src/configPath.mts`](src/configPath.mts) and [`config/`](config/);
37
+ - the host process: the Express app, its security headers and startup —
38
+ [`src/app.mts`](src/app.mts); the health, readiness and metrics routes, the
39
+ composed router and core's terminal error handler after them, in the order
40
+ they are mounted — [`src/routes.mts`](src/routes.mts); the listener, which logs `server_listening`
41
+ (info, `port`) once its socket is bound, fails boot with the bind error
42
+ when it cannot be, and logs any later server error as `server_error`
43
+ (error) — [`src/listen.mts`](src/listen.mts). Every error this code logs — an unhandled
44
+ request error, the shared Redis connection's `error` event, a failed
45
+ shutdown — is logged as core's
46
+ [`loggableError`](../../packages/core/README.md#logger) projection, never the
47
+ error, which can carry what an upstream or Redis said (the connection's
48
+ error carries the `AUTH` handshake, password included, when the server
49
+ refuses it);
50
+ - the concrete logger and audit stream (pino) —
51
+ [`src/logger.mts`](src/logger.mts) — and the metrics
52
+ ([`src/metrics.mts`](src/metrics.mts));
53
+ - process lifecycle: signal handling with a drain deadline —
54
+ [`src/shutdown.mts`](src/shutdown.mts);
55
+ - packaging: the `Dockerfile`, the compose files, the `Makefile`;
56
+ - its tests: `src/__tests__/`, which `pnpm run test` and `make test` run against
57
+ this composition, and `tests/`, a separate black-box API suite with its own
58
+ `package.json`, run against a server that is already up (`API_BASE_URL`).
59
+ Among what `src/__tests__/` holds: every module this template can turn on
60
+ boots together and keeps the contracts that exist only when they meet — one
61
+ discovery document, each module's body rules in either mount order, a store
62
+ or repository outage answered 503 and logged once — pinned by
63
+ `all-modules-composition.test.mts`.
64
+
65
+ **Does not own** the HTTP API, the grants, the token format, how each store
66
+ behaves, or the config schema — the packages do. Change those upstream, not
67
+ here.
68
+
69
+ **Why separate.** Everything above is a per-deployment decision, so it is kept
70
+ out of the published libraries and put in a tree the operator is expected to
71
+ modify. It is `"private": true` and never published itself; it reaches users
72
+ only through the scaffolder. A scaffolded copy carries this README too; its
73
+ relative links into `../../docs` and `../../packages` resolve only inside this
74
+ repository.
75
+
5
76
  ## Requirements
6
77
 
7
78
  - **Node.js** `>=22.0.0`
8
79
  - **`bcrypt` native binary**: this template depends transitively on `bcrypt@6.x`, which ships **prebuilt N-API binaries** for `darwin-arm64`, `darwin-x64`, `linux-x64` (glibc and musl), `linux-arm64` (glibc and musl), `linux-arm`, `win32-x64`, and `win32-arm64`. On these platforms `pnpm install` succeeds without compiling from source, and no extra toolchain is required (the `darwin-*`, `node:*-alpine`, and `node:*-bookworm` images all match a shipped prebuild). On platforms or libc/arch combinations that have no matching prebuild, `pnpm install` falls back to compiling, which then requires a C++ compiler and Python (the standard Node.js native-addon toolchain): Debian/Ubuntu `apt-get install build-essential python3`, Alpine `apk add make g++ python3`, macOS `xcode-select --install`. The pnpm 10 `onlyBuiltDependencies` allowlist for `bcrypt` lives in `pnpm-workspace.yaml` — pnpm ≥10.29 reads it only from there, in single-package projects too. `create-auth-provider` writes that file into the project it scaffolds (inside this monorepo, the allowlist sits in the workspace root's `pnpm-workspace.yaml`); without it a fresh `pnpm install` silently skips the install hook on those platforms.
9
80
  - **Redis 7.2 LTS or later** for any Redis-backed adapter (refresh token family store, code repository, federation token store, session store). The `pExpireGT` flag pair on which several adapters depend was introduced in Redis 7.0+; 7.2 LTS is the tested floor. Tested against AWS ElastiCache for Redis 7.2, Upstash Redis, Redis Cloud 7.2, and self-managed `redis:7.2-alpine`.
10
- - **ioredis** `^6.0.0` (direct runtime dependency). Every `@o3co/auth-provider-redis` adapter this template wires — refresh-token families, authorization codes, rate-limit counters, the access-token denylist, the user-session stores, the federation-token store — runs off the one ioredis connection `standaloneRedisClientsModule` opens per replica (`makeIoredisClients`). The `redis` npm package (`^6.2.1`) stays a dependency for one thing: `connect-redis` takes a node-redis client, so the `express-session` store behind `SESSION_STORAGE_TYPE=redis` is a second, separate connection (the `session-store` probe in `/readyz`).
81
+ - **ioredis** `^6.0.0` (direct runtime dependency). Every `@o3co/auth-provider-redis` adapter this template wires — refresh-token families, authorization codes, rate-limit counters, the access-token denylist, the replay seen-set, the user-session stores, the federation-token store, the consent stores and the federation-grant stores — runs off the one ioredis connection `standaloneRedisClientsModule` opens per replica. The same module provides the client of every other store the Redis package ships — the device-code store and the WebAuthn challenge store — so a deployment that adds the device grant or WebAuthn to `buildModules.mts` with a Redis store finds its client there. The `redis` npm package (`^6.2.1`) stays a dependency for one thing: `connect-redis` takes a node-redis client, so the `express-session` store behind `SESSION_STORE_STORAGE_TYPE=redis` is a second, separate connection (the `session-store` probe in `/readyz`).
11
82
 
12
83
  ## First-party clients
13
84
 
14
- `GET /authorize` mints an authorization code as soon as the user's session is authenticated. There is **no consent step**: the user is never asked whether this client may have their identity.
85
+ For a client marked `firstParty: true`, `GET /authorize` mints an authorization code as soon as the user's session is authenticated. There is **no consent step**: the user is never asked whether this client may have their identity.
15
86
 
16
87
  That is a coherent model for a **first-party** OP — one where every registered client is operated by you — and it is coherent nowhere else. A page you do not control can force a top-level navigation to `/authorize?client_id=X&redirect_uri=...&code_challenge=<theirs>`, a logged-in user's browser mints a code bound to *their* session, it is delivered to X's registered `redirect_uri`, and whoever chose the `code_challenge` redeems it for tokens carrying that user's identity.
17
88
 
18
- So the assumption is enforced rather than assumed. A client must carry `firstParty: true` in its registration to use `/authorize`; anything else is refused with `unauthorized_client`. Mark a client first-party only if you would be comfortable with it receiving a user's identity **without the user being asked**.
89
+ So the assumption is enforced rather than assumed. Every other client — one with no `firstParty` field and one carrying `false` alike — goes through the consent step: the user is asked on your consent page before a code is minted. That step needs a consent store, and the template ships none (`ADAPTERS_CONSENT_STORE=none`), so on the defaults such a client is refused at `/authorize` with `unauthorized_client`; see [Consent Store](#consent-store) to serve it. Mark a client first-party only if you would be comfortable with it receiving a user's identity **without the user being asked**.
19
90
 
20
91
  ```yaml
21
92
  # config/clients.yaml
@@ -31,19 +102,19 @@ A machine client can authenticate with a key instead of a shared secret (#484):
31
102
  [oauth package README](../../packages/oauth/README.md#client-authentication-private_key_jwt-rfc-7523-22).
32
103
  Nothing secret lives in `clients.yaml` for such a client.
33
104
 
34
- **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. The step that changes the former is a consent screen, tracked separately.
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.
35
106
 
36
- **Migrating.** Registrations that predate the field are refused at `/authorize` until you mark them — a client with no `firstParty` field and one carrying an explicit `false` are treated alike. Mark every client you operate before pointing it at `/authorize`; there is no admit-with-warning window. The one-time `oauth.authorize.allowUnmarkedClients` migration flag was removed before it ever shipped in a release, and a config or environment still setting 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 with migration instructions rather than being silently ignored.
37
108
 
38
109
  ## Multi-replica deployments
39
110
 
40
111
  The [operator runbook](../../docs/operator-runbook.md) covers the operational side end to end — boot refusals, probes, dependency failures, alerts, Redis sizing, key rotation. This section is the multi-replica checklist.
41
112
 
42
- When running multiple instances of the standalone server behind a load balancer, point `REFRESH_TOKEN_FAMILY_STORE_REDIS_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.
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.
43
114
 
44
- **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`.
115
+ **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.
45
116
 
46
- Since #292 it takes four shapes, all of them Express's own:
117
+ It takes four shapes, all of them Express's own:
47
118
 
48
119
  | Value | Meaning |
49
120
  |---|---|
@@ -56,7 +127,7 @@ Since #292 it takes four shapes, all of them Express's own:
56
127
 
57
128
  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.
58
129
 
59
- **Point `rateLimiter.adapter` 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. Since #270 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 `rateLimit.login`; both adapters seed their own `limits.login` from it, so there is nothing to restate.
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.
60
131
 
61
132
  **Behind a BFF, raise `limits.token` before you need to.** The OAuth-endpoint
62
133
  rate limits key on `req.ip` (`packages/core/src/ratelimit/guard.mts` builds the
@@ -79,10 +150,11 @@ its address is the honest answer to "who called". There is no per-user identity
79
150
  in these requests to key on.
80
151
 
81
152
  The knob is the per-endpoint budget. Both limiter adapters take
82
- `limits { <prefix> { limit, windowSeconds } }`, keyed by the endpoint prefix:
153
+ `limits { <prefix> { limit, windowSeconds } }` in their own sections, keyed by
154
+ the endpoint prefix:
83
155
 
84
156
  ```hocon
85
- redisRateLimiter {
157
+ redis-rate-limiter {
86
158
  limits {
87
159
  token { limit = 600, windowSeconds = 60 }
88
160
  introspect { limit = 600, windowSeconds = 60 }
@@ -90,9 +162,9 @@ redisRateLimiter {
90
162
  }
91
163
  ```
92
164
 
93
- (`memoryRateLimiter.limits` for the memory adapter, same shape.) Size it from
165
+ (`core-rate-limiter-memory.limits` for the memory adapter, same shape.) Size it from
94
166
  your BFF's peak sign-ins and refreshes per minute with headroom, and remember
95
- that under `rateLimiter.adapter = "redis"` the budget is shared across replicas
167
+ that under `adapters.rateLimiter = "redis"` the budget is shared across replicas
96
168
  — which is what you want here, and what makes the number mean something.
97
169
 
98
170
  The defaults are deliberately left alone: 60/60 s per source IP is a sensible
@@ -101,93 +173,254 @@ raising it globally to accommodate one topology would weaken it for the other.
101
173
  Put the throttling that protects the BFF's *own* users in front of the BFF,
102
174
  where per-user identity still exists.
103
175
 
104
- **Set `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, DPoP proof-replay detection forking. 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 (`USER_SESSION_STORES_ADAPTER=memory`), the authorization-code repository (`OAUTH_CODE_ADAPTER=memory`) and the federation token store (`FEDERATION_TOKEN_STORE_TYPE=memory`, the default) — are refused by name too; before #455 they booted under `multi`. Since #474 that includes express-session's own store under `SESSION_STORAGE_TYPE=memory` and the login / WebAuthn-options rate limiters when no shared `rateLimiter` is wired; with the mode unset those three warn instead (see the operator runbook). With the mode unset you get one `replica_unsafe_adapters` warning at boot instead; with `DEPLOYMENT_MODE=single` the check is silent, because you have said there is one replica.
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).
177
+
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.
105
179
 
106
- Be aware of what this check *cannot* do: if you scale to N replicas without ever setting `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.
180
+ 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.
107
181
 
108
- **Access-token revocation needs a denylist, and the template ships one.** `POST /oauth/revoke` revokes an access token by writing its `jti` to the `accessTokenDenylist`, which token verification and introspection consult. Boot *fails* if the endpoint is mounted with no denylist behind it: RFC 7009 obliges the endpoint to answer `200`, so an unwired denylist would tell you a token is revoked while it keeps working until it expires. `accessTokenDenylist.adapter` defaults to `"redis"` here (`ACCESS_TOKEN_DENYLIST_ADAPTER=memory` for single-instance local work) and shares the ioredis socket configured by `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL`, so it costs no extra connection. A deployment that genuinely does not revoke access tokens sets `OAUTH_REVOCATION_ACCESS_TOKEN=unsupported` instead; the endpoint then answers `unsupported_token_type` for `token_type_hint=access_token`. **Refresh-token revocation works in either case and never needed a denylist.**
182
+ **Access-token revocation needs a denylist, and the template ships one.** `POST /oauth/revoke` revokes an access token by writing its `jti` to the `accessTokenDenylist`, which token verification and introspection consult. Boot *fails* if the endpoint is mounted with no denylist behind it: RFC 7009 obliges the endpoint to answer `200`, so an unwired denylist would tell you a token is revoked while it keeps working until it expires. `adapters.accessTokenDenylist` defaults to `"redis"` here (`ADAPTERS_ACCESS_TOKEN_DENYLIST=memory` for single-instance local work) and shares the ioredis socket configured by `REDIS_CLIENTS_URL`, so it costs no extra connection. A deployment that genuinely does not revoke access tokens sets `OAUTH_REVOCATION_ACCESS_TOKEN=unsupported` instead; the endpoint then answers `unsupported_token_type` for `token_type_hint=access_token`. **Refresh-token revocation works in either case and never needed a denylist.**
109
183
 
110
184
  Other multi-replica considerations covered by the default modules:
111
185
 
112
- - The `express-session` store (`sessionStoreModule`) is its own connection: `SESSION_STORAGE_TYPE=redis` with `SESSION_STORAGE_REDIS_URL` (`session.storage.redis.url`) pointing at the shared instance.
113
- - The user-session stores switch on `userSessionStores.adapter = "redis"` (`USER_SESSION_STORES_ADAPTER`), which wires `redisSessionStoresModule` off the shared ioredis connection — the one `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL` configures.
114
- - The authorization-code repository switches on `oauth.code.adapter` (`OAUTH_CODE_ADAPTER`); the template ships `"redis"`, on that same connection. The older `repositories.code.type = "redis"` (`CLIENT_CODE_TYPE`) switch is still honoured with a deprecation warning at boot. `CLIENT_CODE_ENDPOINT_URI` (`repositories.code.redis.endpointUri`) is still bound by `config/application.conf` but nothing reads it: the Redis code repository has run on the shared connection since the `{ endpointUri }` builder shape was retired, so there is one Redis URL for every adapter and that variable is legacy.
115
- - The replay seen-set — the `jti` single-use record behind `private_key_jwt` client authentication (#484) — switches on `replaySeenSet.adapter` (`REPLAY_SEEN_SET_ADAPTER`); the template ships `"redis"` on the shared connection, and `memory` is refused under `DEPLOYMENT_MODE=multi` because a captured client assertion would replay once per replica.
116
- - The consent step for clients that are not first-party (#527) switches on `consentStore.adapter` (`CONSENT_STORE_ADAPTER`). It is off (`none`) by default; `memory` is refused under `DEPLOYMENT_MODE=multi`, because a consent granted on one replica would be asked for again on every other and a consent page's parked request would be unknown to the replica that receives the answer. `redis` (#561) keeps both on the shared connection. See [Consent Store](#consent-store).
117
- - The federation token store defaults to memory. Set `FEDERATION_TOKEN_STORE_TYPE=redis` (`federationTokenStore.type = "redis"`) and supply `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY` — 32 bytes, base64-encoded (`openssl rand -base64 32`); the store encrypts the upstream refresh tokens it holds. It shares the ioredis socket configured by `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL`. See [Federation Token Store](#federation-token-store).
118
- - Federation grants (#593), when enabled, keep two more stores: the grants themselves (`FEDERATION_GRANT_STORE_ADAPTER`) and acquisition's records (`FEDERATION_GRANT_INTENT_STORE_ADAPTER`). Both ship `redis` on the shared socket; `memory` for either is refused under `DEPLOYMENT_MODE=multi`, and Redis grants beside memory user-session stores are refused on any replica count, because the grants would outlive the boundary that ends them. See [Federation Grants](#federation-grants).
186
+ - The `express-session` store (`sessionStoreModule`) is its own connection: `SESSION_STORE_STORAGE_TYPE=redis` with `SESSION_STORE_STORAGE_REDIS_URL` (`session-store.storage.redis.url`) pointing at the shared instance.
187
+ - The user-session stores switch on `adapters.userSessionStores = "redis"` (`ADAPTERS_USER_SESSION_STORES`), which wires `redisSessionStoresModule` off the shared ioredis connection — the one `REDIS_CLIENTS_URL` configures.
188
+ - The authorization-code repository switches on `adapters.codeRepository` (`ADAPTERS_CODE_REPOSITORY`); the template ships `"redis"`, on that same connection, so there is one Redis URL for every adapter. The Redis repository's own settings are its section, `redis-code-repository`; the in-process one's, `standalone-in-memory-code-repository` (see [Code Repository](#code-repository)).
189
+ - The replay seen-set — the `jti` single-use record behind `private_key_jwt` client authentication (#484) — switches on `adapters.replaySeenSet` (`ADAPTERS_REPLAY_SEEN_SET`); the template ships `"redis"` on the shared connection, and `memory` is refused under `CORE_DEPLOYMENT_MODE=multi` because a captured client assertion would replay once per replica.
190
+ - The consent step for clients that are not first-party (#527) switches on `adapters.consentStore` (`ADAPTERS_CONSENT_STORE`). It is off (`none`) by default; `memory` is refused under `CORE_DEPLOYMENT_MODE=multi`, because a consent granted on one replica would be asked for again on every other and a consent page's parked request would be unknown to the replica that receives the answer. `redis` (#561) keeps both on the shared connection. See [Consent Store](#consent-store).
191
+ - The federation token store defaults to memory. Set `ADAPTERS_FEDERATION_TOKEN_STORE=redis` (`adapters.federationTokenStore = "redis"`) and supply `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY` — 32 bytes, base64-encoded (`openssl rand -base64 32`); the store encrypts the upstream refresh tokens it holds. It shares the ioredis socket configured by `REDIS_CLIENTS_URL`. See [Federation Token Store](#federation-token-store).
192
+ - Federation grants (#593), when enabled, keep two more stores: the grants themselves (`ADAPTERS_FEDERATION_GRANT_STORE`) and acquisition's records (`ADAPTERS_FEDERATION_GRANT_INTENT_STORE`). Both ship `none`, which an enabled feature refuses at boot naming the store: select `redis` (the shared socket) or `memory` for each; `memory` for either is refused under `CORE_DEPLOYMENT_MODE=multi`, and Redis grants beside memory user-session stores are refused on any replica count, because the grants would outlive the boundary that ends them. See [Federation Grants](#federation-grants).
119
193
 
120
194
  ## Usage
121
195
 
196
+ `pnpm run debug` runs `src/app.mts` under `tsx watch` on this machine. It reads
197
+ no `.env` file — its configuration is `config/` plus the environment of the
198
+ shell that starts it — and it does not boot until that environment supplies:
199
+
200
+ - `OAUTH_JWT_ISSUER` — this server's origin (`http` is accepted for a loopback
201
+ host);
202
+ - a signing key pair, `KEY_STORE_LOCAL_PRIVATE_KEY_PATH` / `KEY_STORE_LOCAL_PUBLIC_KEY_PATH`
203
+ (see [OAuth JWT](#oauth-jwt));
204
+ - `SESSION_STORE_SECRET`, at least 32 bytes;
205
+ - `REPOSITORIES_USER_HTTP_AUTHENTICATE_URL` and `REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL` —
206
+ your user service ("the Store"), `https`, or `http` on a loopback host. Boot
207
+ checks their form, not that anything answers; logins fail until something
208
+ does (see [User Repository](#user-repository));
209
+ - a Redis it can reach at `redis://localhost:6379`. The shipped configuration
210
+ keeps the browser session store there (`SESSION_STORE_STORAGE_REDIS_URL`), and
211
+ opens one shared connection (`REDIS_CLIENTS_URL`) for the
212
+ refresh-token families, the authorization codes, the access-token denylist
213
+ and the replay seen-set — and for the user-session stores, with the setting
214
+ below.
215
+
216
+ Plain HTTP also needs `SESSION_STORE_SECURE=false` and a session cookie name without
217
+ the `__Host-` prefix (see [Session](#session)). And set
218
+ `ADAPTERS_USER_SESSION_STORES=redis`: the browser session store is in Redis, the
219
+ user-session stores default to memory, and `tsx watch` restarts the process on
220
+ every save — split, a restart leaves the browser's session pointing at a
221
+ `UserSession` that is gone, and `/authorize` loops (see [Docker](#docker)).
222
+ MFA is on by default, and its two stores default to memory too: set
223
+ `ADAPTERS_MFA_FACTOR_STORE=redis` and `ADAPTERS_MFA_TRANSACTION_STORE=redis`
224
+ for the same reason, or every save empties them while the sessions survive —
225
+ or `MFA_MODE=off` to run without MFA (see
226
+ [Multi-factor authentication](#multi-factor-authentication)).
227
+
228
+ It also reads the client registry, `config/clients.yaml` (`REPOSITORIES_CLIENT_YAML_PATH`). That
229
+ file is per-deployment: the scaffold creates an empty one, and `.gitignore`
230
+ keeps it out of the project's repository, so a fresh clone has none and boot
231
+ fails naming the path until you create it — from `config/clients.yaml.example`,
232
+ or empty for a registry with no clients.
233
+
234
+ The key pair below is generated with Node, which every machine running this
235
+ already has. `openssl genpkey -algorithm ed25519` works too, with OpenSSL 1.1.1
236
+ or later — not with the LibreSSL that macOS ships as `/usr/bin/openssl`.
237
+
122
238
  ```bash
123
- # Install dependencies
124
239
  pnpm install
125
240
 
126
- # Copy environment file and fill in required values
127
- cp .env.example .env
241
+ # A signing key pair (Ed25519) and a local Redis
242
+ node -e '
243
+ const { generateKeyPairSync } = require("node:crypto");
244
+ const fs = require("node:fs");
245
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
246
+ fs.writeFileSync("jwt-private.pem", privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 });
247
+ fs.writeFileSync("jwt-public.pem", publicKey.export({ type: "spki", format: "pem" }));
248
+ '
249
+ docker run -d --name auth-redis -p 127.0.0.1:6379:6379 redis:7.2-alpine
250
+
251
+ # What boot requires (replace the user-service URLs with your Store's)
252
+ export OAUTH_JWT_ISSUER=http://localhost:3000 \
253
+ KEY_STORE_LOCAL_PRIVATE_KEY_PATH=./jwt-private.pem \
254
+ KEY_STORE_LOCAL_PUBLIC_KEY_PATH=./jwt-public.pem \
255
+ SESSION_STORE_SECRET="$(openssl rand -hex 32)" \
256
+ SESSION_STORE_SECURE=false SESSION_STORE_NAME=auth.sid \
257
+ ADAPTERS_USER_SESSION_STORES=redis \
258
+ ADAPTERS_MFA_FACTOR_STORE=redis ADAPTERS_MFA_TRANSACTION_STORE=redis \
259
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_URL=http://localhost:8080/authenticate \
260
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL=http://localhost:8080/authenticate-by-token
128
261
 
129
262
  # Start in development mode (hot reload)
130
263
  pnpm run debug
131
264
 
132
- # Build and start in production mode
265
+ # Build, then run the compiled server (CONFIG_ENV or NODE_ENV selects config/<env>.conf)
133
266
  pnpm run build
134
- pnpm start
267
+ pnpm run start
135
268
  ```
136
269
 
270
+ `.env` is read only by the compose files (`env_file`). `make dev`
271
+ (`docker compose up --build`) runs the same debug server in a container beside
272
+ a Redis service, with its environment from `.env` — copied from
273
+ `.env.example`, whose Redis URLs name that service. Values there are resolved
274
+ inside the container, which holds no key files: only `src/`, `config/`, the
275
+ package manifests and what installing and building them produced. A key path or
276
+ a `localhost` URL that works on the host does not work there. The deployable
277
+ image and its compose file are under [Docker](#docker).
278
+
137
279
  ## Configuration
138
280
 
139
- Configuration is loaded from `config/application.conf` (HOCON format). Each value can be overridden with the corresponding environment variable.
281
+ Configuration is loaded from `config/application.conf` (HOCON format), over the defaults in the packages' `reference.conf` files and the template's own `config/reference.conf`. Each value can be overridden with the corresponding environment variable.
140
282
 
141
283
  ### Environment-specific config overlay
142
284
 
143
- `src/app.mts` loads configuration in two layers:
285
+ `src/app.mts` loads configuration in layers, highest precedence first:
144
286
 
145
- 1. **`config/application.conf`** — base defaults for the app.
146
- 2. **`config/{ENV}.conf`** — overlay for the current environment, where
287
+ 1. **`config/{ENV}.conf`** — overlay for the current environment, where
147
288
  `ENV = CONFIG_ENV || NODE_ENV || "development"`.
289
+ 2. **`config/application.conf`** — this deployment's settings.
290
+ 3. **the `reference.conf` of each package the loaded modules come from**, then
291
+ **`@o3co/auth-provider-core`'s** — the defaults, resolved from the
292
+ installed packages: each module declares its package's file, and core's
293
+ `moduleReferences(modules)` lists them, core's last. The template's own
294
+ modules declare `config/reference.conf`, which holds the defaults of the
295
+ sections they own — `logging`, `http` (with its CORS list,
296
+ `http.cors`), `key-store`, the shared Redis connection's `redis-clients`,
297
+ `repositories`, the in-process code repository's
298
+ `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
300
+ the two files above, not there. `config/reference.conf` binds each of these
301
+ keys' variables beside its default, so a value either file above sets wins
302
+ over the variable, except for `HTTP_PORT`, `HTTP_TRUST_PROXY`,
303
+ `HTTP_CORS_ALLOWED_ORIGINS` and `REDIS_CLIENTS_URL` /
304
+ `REDIS_CLIENTS_PASSWORD`, which `application.conf` binds again in its last
305
+ lines, so each of those wins over a value that file sets above them. A key
306
+ neither file above sets takes its value from here.
307
+
308
+ It reads the two files above once, under one snapshot of the environment
309
+ (`readOwnLayers`), and builds two phases from that one read
310
+ ([#728](https://github.com/o3co/auth.provider/issues/728);
311
+ [`src/configPath.mts`](src/configPath.mts)) — so a file replaced, or a
312
+ variable changed, while the process starts cannot make boot parse something
313
+ 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`
324
+ (`MFA_MODE`), the composition root's own key, the same way, with the
325
+ template's own schema (`readMfaSwitch`), and beside them the user
326
+ 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
331
+ the `logging` module's section, with that module's schema, over the template's
332
+ `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)):
337
+ boot parses it once, with every loaded module's schema. What
338
+ the template reads after boot — the trusted hops, the port, the readiness
339
+ 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.
148
343
 
149
344
  Values in the overlay take precedence over `application.conf`. The scaffold
150
- ships with `development.conf` and `production.conf`. To add another
345
+ ships with `development.conf`, `production.conf` and `mailpit.conf` (the
346
+ development overlay under the name the
347
+ [Mailpit overlay](#mailpit-for-the-smtp-sender-development-only) selects). To add another
151
348
  environment (e.g. `staging`), create `config/staging.conf` and set
152
349
  `CONFIG_ENV=staging`. A missing `{ENV}.conf` is a boot-time error — typos
153
350
  fail fast rather than silently falling back to defaults.
154
351
 
352
+ ### Adapters
353
+
354
+ Which adapter fills each slot is the composition root's own choice, its own
355
+ section `adapters`, read before any module is chosen (above). Each key has its
356
+ variable; a value the template does not know, or a key it does not declare,
357
+ fails before boot, naming it.
358
+
359
+ | Variable | Default | Description |
360
+ |---|---|---|
361
+ | `ADAPTERS_RATE_LIMITER` | `memory` | `adapters.rateLimiter`: `memory` or `redis`. See [Multi-replica deployments](#multi-replica-deployments) |
362
+ | `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
+ | `ADAPTERS_ACCESS_TOKEN_DENYLIST` | `redis` | `adapters.accessTokenDenylist`: `memory` or `redis` |
364
+ | `ADAPTERS_REPLAY_SEEN_SET` | `redis` | `adapters.replaySeenSet`: the replay seen-set behind `private_key_jwt`, `memory` or `redis` |
365
+ | `ADAPTERS_CONSENT_STORE` | `none` | `adapters.consentStore`: `none`, `memory` or `redis`. See [Consent Store](#consent-store) |
366
+ | `ADAPTERS_FEDERATION_TOKEN_STORE` | `memory` | `adapters.federationTokenStore`: `memory` or `redis`. See [Federation Token Store](#federation-token-store) |
367
+ | `ADAPTERS_FEDERATION_GRANT_STORE` | `none` | `adapters.federationGrantStore`: `none`, `memory` or `redis`. See [Federation Grants](#federation-grants) |
368
+ | `ADAPTERS_FEDERATION_GRANT_INTENT_STORE` | `none` | `adapters.federationGrantIntentStore`: `none`, `memory` or `redis`. See [Federation Grants](#federation-grants) |
369
+ | `ADAPTERS_MFA_FACTOR_STORE` | `memory` | `adapters.mfaFactorStore`: `memory` (development and test only), `redis` or `store` (the Store's endpoints), read while `MFA_MODE` installs MFA. See [Multi-factor authentication](#multi-factor-authentication) |
370
+ | `ADAPTERS_MFA_TRANSACTION_STORE` | `memory` | `adapters.mfaTransactionStore`: `memory` (development and test only) or `redis`, read while `MFA_MODE` installs MFA |
371
+ | `ADAPTERS_CODE_REPOSITORY` | `redis` | `adapters.codeRepository`: the authorization-code repository, `memory` or `redis`. See [Code Repository](#code-repository) |
372
+ | `ADAPTERS_CLIENT_REPOSITORY` | `yaml` | `adapters.clientRepository`: `yaml`, or `static` (core's alias of `yaml`). See [Client Repository](#client-repository) |
373
+ | `ADAPTERS_USER_REPOSITORY` | `http` | `adapters.userRepository`: `http`, `yaml` or `static` (core's alias of `yaml`). See [User Repository](#user-repository) |
374
+ | `ADAPTERS_AUDIT_SINK` | `logger` | `adapters.auditSink`: where security events go, `logger` (pino-enveloped NDJSON on stdout) or `console` (bare event JSON). There is no `none`. See [Audit trail](#audit-trail) |
375
+
376
+ A selection still written where it was — `rateLimiter.adapter`,
377
+ `oauth.code.adapter`, `audit.sink.type`, `repositories.user.type` and the
378
+ 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.
382
+
155
383
  ### HTTP
156
384
 
157
385
  | Variable | Default | Description |
158
386
  |---|---|---|
159
- | `HTTP_PORT` | `3000` | Port the server listens on |
387
+ | `HTTP_PORT` | `3000` | Port the server listens on, `0` to `65535` in decimal digits; `0` lets the OS choose a free one. Set but empty, it fails boot |
160
388
  | `HTTP_TRUST_PROXY` | `false` | Express `trust proxy`: `false`, an address/CIDR list (`10.0.0.0/8,loopback`), a hop count (`1`), or `true`. **Required behind a load balancer** — otherwise every IP-keyed rate limit shares one bucket across all users. Prefer naming the proxy over `true`. See [Multi-replica deployments](#multi-replica-deployments) |
161
389
  | `HTTP_READINESS_TIMEOUT_MS` | `1000` | Per-probe deadline for `/readyz` (see [Health endpoints](#health-endpoints)) |
162
- | `LOG_LEVEL` | `info` | Minimum level emitted: `trace`\|`debug`\|`info`\|`warn`\|`error`\|`fatal`\|`silent`. Does **not** gate the audit trail — see [Audit trail](#audit-trail) |
163
- | `AUDIT_SINK_TYPE` | `logger` | Where security events go: `logger` (pino-enveloped NDJSON on stdout) or `console` (bare event JSON). There is no `none`. See [Audit trail](#audit-trail) |
390
+ | `LOGGING_LEVEL` | `info` | Minimum level emitted: `trace`\|`debug`\|`info`\|`warn`\|`error`\|`fatal`\|`silent`. Does **not** gate the audit trail — see [Audit trail](#audit-trail) |
164
391
 
165
392
  ### OAuth JWT
166
393
 
394
+ The signing key is the `key-store` module's section: `key-store.provider`
395
+ (`KEY_STORE_PROVIDER`, `local`) and the local key store's `key-store.local`
396
+ (`KEY_STORE_LOCAL_*`). The issuer and the token format stay under `oauth.jwt`.
397
+
167
398
  | Variable | Default | Description |
168
399
  |---|---|---|
169
- | `OAUTH_JWT_ALGORITHM` | `EdDSA` | JWT signing algorithm: `EdDSA`, `ES256`, `RS256` or `HS256`. The default is asymmetric so `/.well-known/jwks.json` publishes a real verification key. |
170
- | `OAUTH_JWT_SECRET` | — | Signing secret, **HMAC (`HS256`) only**. At least 32 bytes (256 bits) of random material — `openssl rand -hex 32`. Hex/base64 values are measured **decoded**, so a 32-character hex string counts as 16 bytes and is refused. |
400
+ | `KEY_STORE_LOCAL_ALGORITHM` | `EdDSA` | JWT signing algorithm: `EdDSA`, `ES256`, `RS256` or `HS256`. The default is asymmetric so `/.well-known/jwks.json` publishes a real verification key. |
401
+ | `KEY_STORE_LOCAL_SECRET` | — | Signing secret, **HMAC (`HS256`) only**. At least 32 bytes (256 bits) of random material — `openssl rand -hex 32`. Hex/base64 values are measured **decoded**, so a 32-character hex string counts as 16 bytes and is refused. |
171
402
  | `OAUTH_JWT_ISSUER` | **(required)** | Canonical issuer URL stamped as `iss` on every token. Must be absolute `https` (`http` only for a loopback host), with no query or fragment. Boot fails when unset — it is never derived from the `Host` header. |
172
403
  | `OAUTH_REQUIRE_EMAIL_VERIFIED` | `false` | Refuse to issue tokens for a user until the Store publishes `emailVerified: true`. Enforced at `/authorize` and on the `session` grant. Verification itself is the Store's job — this only reads the result. |
173
- | `OAUTH_CIMD_ENABLED` | `false` | Accept Client ID Metadata Documents (#529): a client whose `client_id` is the `https` URL of its own registration, the model MCP hosts use. Such a client is public, never first-party — it goes through the consent step, so wire `CONSENT_STORE_ADAPTER`. |
174
- | `OAUTH_CIMD_ALLOWED_SCOPES` | — | Comma-separated: what any such client may obtain (its document's `scope` is intersected with this). Empty admits none. |
175
- | `OAUTH_CIMD_ALLOWED_AUDIENCES` | — | Comma-separated: the resource servers it may mint for (RFC 8707 `resource`). Empty admits only its own `client_id`. |
176
- | `OAUTH_CIMD_ALLOWED_HOSTS` | — | Comma-separated hosts a document may live on: exact, or `.suffix` for a domain and its subdomains. Empty admits any public host. |
177
- | `OAUTH_CIMD_DENIED_HOSTS` | — | Comma-separated hosts refused even when allowed above. |
178
- | `OAUTH_CIMD_MAX_BYTES` | `5120` | Byte cap on a document (the draft recommends 5 KB). |
179
- | `OAUTH_CIMD_TIMEOUT_MS` | `5000` | Fetch timeout. |
180
- | `OAUTH_CIMD_CACHE_MAX_AGE_MS` | `600000` | Upper bound on how long a valid document is served from cache; `Cache-Control: max-age` may shorten it. |
181
- | `OAUTH_CIMD_MAX_CACHE_ENTRIES` | `256` | How many documents are remembered at once. The keys are chosen by an unauthenticated caller, so the map is bounded. |
182
- | `OAUTH_CIMD_STALE_IF_ERROR_MS` | `300000` | How long a registration already validated is still served after a revalidation that failed for a reason that is not the document's (a DNS blip, a 5xx, a timeout) — an outage should not break a working client. A document that was *rejected* is dropped immediately. `0` disables it. |
183
- | `OAUTH_CIMD_NEGATIVE_CACHE_MS` | `60000` | How long a refusal is remembered, so the same `client_id` is not resolved and fetched again on every request. Short, so a client that fixes its document is not locked out. |
184
- | `OAUTH_CIMD_MAX_CONCURRENT_FETCHES` | `8` | How many documents may be in flight at once, across every `client_id`. Bounds what an unauthenticated caller can make this server dial. |
185
- | `OAUTH_JWT_KID` | `v0` | Key ID included in the JWT header |
186
- | `OAUTH_JWT_PRIVATE_KEY` | — | PEM-encoded private key (asymmetric algorithms) |
187
- | `OAUTH_JWT_PRIVATE_KEY_PATH` | — | Path to PEM private key file |
188
- | `OAUTH_JWT_PUBLIC_KEY` | — | PEM-encoded public key |
189
- | `OAUTH_JWT_PUBLIC_KEY_PATH` | — | Path to PEM public key file |
404
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED` | `false` | Accept Client ID Metadata Documents (#529): a client whose `client_id` is the `https` URL of its own registration, the model MCP hosts use. Such a client is public, never first-party — it goes through the consent step, so wire `ADAPTERS_CONSENT_STORE`. |
405
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_SCOPES` | — | Comma-separated: what any such client may obtain (its document's `scope` is intersected with this). Empty admits none. |
406
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_AUDIENCES` | — | Comma-separated: the resource servers it may mint for (RFC 8707 `resource`). Empty admits only its own `client_id`. |
407
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_HOSTS` | — | Comma-separated hosts a document may live on: exact, or `.suffix` for a domain and its subdomains. Empty admits any public host. |
408
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_DENIED_HOSTS` | — | Comma-separated hosts refused even when allowed above. |
409
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_BYTES` | `5120` | Byte cap on a document (the draft recommends 5 KB). |
410
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_TIMEOUT_MS` | `5000` | Fetch timeout. |
411
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_CACHE_MAX_AGE_MS` | `600000` | Upper bound on how long a valid document is served from cache; `Cache-Control: max-age` may shorten it. |
412
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CACHE_ENTRIES` | `256` | How many documents are remembered at once. The keys are chosen by an unauthenticated caller, so the map is bounded. |
413
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_STALE_IF_ERROR_MS` | `300000` | How long a registration already validated is still served after a revalidation that failed for a reason that is not the document's (a DNS blip, a 5xx, a timeout) — an outage should not break a working client. A document that was *rejected* is dropped immediately. `0` disables it. |
414
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_NEGATIVE_CACHE_MS` | `60000` | How long a refusal is remembered, so the same `client_id` is not resolved and fetched again on every request. Short, so a client that fixes its document is not locked out. |
415
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CONCURRENT_FETCHES` | `8` | How many documents may be in flight at once, across every `client_id`. Bounds what an unauthenticated caller can make this server dial. |
416
+ | `KEY_STORE_LOCAL_KID` | `v0` | Key ID included in the JWT header. 1 to 256 characters with no control character; anything else fails boot, including a variable exported but empty (which used to sign under the kid `""`; tokens issued that way are refused once it is corrected, so their users sign in again) |
417
+ | `KEY_STORE_LOCAL_PRIVATE_KEY` | — | PEM-encoded private key (asymmetric algorithms) |
418
+ | `KEY_STORE_LOCAL_PRIVATE_KEY_PATH` | — | Path to PEM private key file |
419
+ | `KEY_STORE_LOCAL_PUBLIC_KEY` | — | PEM-encoded public key |
420
+ | `KEY_STORE_LOCAL_PUBLIC_KEY_PATH` | — | Path to PEM public key file |
190
421
  | `OAUTH_JWT_LEGACY_TYP_ACCEPT` | `false` | Accept tokens whose `typ` header is absent. `false` rejects them, treating a typ-less token as the misconfiguration or downgrade attempt it usually is. Set `true` only for a bounded migration window while v0.4.x tokens are still in circulation. |
422
+ | `JWKS_PATH` | `/.well-known/jwks.json` | Where the verification keys are published under the issuer, and what discovery advertises as `jwks_uri` (`jwks.path`). An absolute path with no `//`, dot-segment, query, fragment, backslash, percent-encoding or control character. |
423
+ | `JWKS_CACHE_MAX_AGE` | `300` | The JWKS response's `Cache-Control: public, max-age`, in seconds (`jwks.cacheMaxAge`). Keep it well below the key-overlap window, so a rotated key reaches caching verifiers in time. |
191
424
 
192
425
  **Signing keys are required.** The default algorithm is `EdDSA` and there is no
193
426
  key-material default: a deployment that sets none fails at boot naming the keys
@@ -199,11 +432,11 @@ openssl genpkey -algorithm ed25519 -out jwt-private.pem
199
432
  openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
200
433
  ```
201
434
 
202
- then point `OAUTH_JWT_PRIVATE_KEY_PATH` / `OAUTH_JWT_PUBLIC_KEY_PATH` at them
203
- (or inline the PEMs via `OAUTH_JWT_PRIVATE_KEY` / `OAUTH_JWT_PUBLIC_KEY`).
435
+ then point `KEY_STORE_LOCAL_PRIVATE_KEY_PATH` / `KEY_STORE_LOCAL_PUBLIC_KEY_PATH` at them
436
+ (or inline the PEMs via `KEY_STORE_LOCAL_PRIVATE_KEY` / `KEY_STORE_LOCAL_PUBLIC_KEY`).
204
437
 
205
- `HS256` is still selectable (`OAUTH_JWT_ALGORITHM=HS256` plus a ≥32-byte
206
- `OAUTH_JWT_SECRET`), but understand what it costs: a symmetric key has no
438
+ `HS256` is still selectable (`KEY_STORE_LOCAL_ALGORITHM=HS256` plus a ≥32-byte
439
+ `KEY_STORE_LOCAL_SECRET`), but understand what it costs: a symmetric key has no
207
440
  public half, so `/.well-known/jwks.json` answers `404 jwks_not_published` and
208
441
  every relying party must be handed the shared secret — which also lets it
209
442
  **mint** tokens, not merely verify them.
@@ -214,12 +447,12 @@ every relying party must be handed the shared secret — which also lets it
214
447
  |---|---|---|
215
448
  | `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`). |
216
449
  | `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. |
217
- | `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. Move the value to the new variable. |
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. |
218
451
  | `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | Refresh token lifetime in seconds. Whole positive number, at most one year (`31536000`). |
219
452
 
220
- Exporting one of these as an empty string is a boot failure, not a fallback:
221
- HOCON resolves `FOO=` to `""`, which coerces to `0`, and a zero lifetime mints
222
- tokens that have already expired.
453
+ Each is read only as decimal digits. Exporting one as an empty string is a
454
+ boot failure, not a fallback (HOCON resolves `FOO=` to `""`), and so is a hex
455
+ (`0x10`), exponent (`1e3`), sign (`+5`) or fraction (`5.0`).
223
456
 
224
457
  Only token exchange (RFC 8693) reads an `expires_in` request parameter; every
225
458
  other grant ignores it and mints the default.
@@ -228,29 +461,48 @@ other grant ignores it and mints the default.
228
461
 
229
462
  | Variable | Default | Description |
230
463
  |---|---|---|
231
- | `OAUTH_GRANTS_SESSION_ENABLED` | `true` | Enable the session grant type |
232
- | `OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED` | `true` | Enable the authorization code grant type |
233
- | `OAUTH_GRANTS_REFRESH_TOKEN_ENABLED` | `true` | Enable the refresh token grant type |
464
+ | `OAUTH_SESSION_ENABLED` | `true` | Enable the session grant type |
465
+ | `OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED` | `true` | Enable the authorization code grant type |
466
+ | `OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED` | `true` | Enable the refresh token grant type |
467
+ | `OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED` | `false` | Enable the client credentials grant type |
468
+ | `OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED` | `false` | Enable the jwt-bearer grant type (RFC 7523) |
469
+
470
+ 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.
234
476
 
235
477
  ### Session
236
478
 
237
479
  | Variable | Default | Description |
238
480
  |---|---|---|
239
- | `SESSION_SECRET` | — | **Required.** Signs the cookie that *is* the authenticated session, so guessing it forges logins. At least 32 bytes (256 bits) — `openssl rand -hex 32`. Measured **decoded**, same rule as `OAUTH_JWT_SECRET`. |
240
- | `SESSION_NAME` | `__Host-auth.session` | Session cookie name. The default uses the `__Host-` prefix and therefore requires `SESSION_SECURE=true` and no `SESSION_DOMAIN`. |
241
- | `SESSION_MAX_AGE` | `3600000` | Session cookie max age in milliseconds. Whole positive number, at most one year (`31536000000`). |
242
- | `SESSION_SECURE` | `true` | Set `Secure` flag on session cookie |
243
- | `SESSION_SAME_SITE` | `lax` | `SameSite` attribute (`lax`, `strict`, `none`). `none` **requires** `SESSION_SECURE=true` — browsers drop a `SameSite=None` cookie that is not `Secure`, so boot refuses the combination rather than letting every login fail silently in the client. |
244
- | `SESSION_DOMAIN` | — | Cookie domain (unset by default) |
245
- | `SESSION_CSRF_TTL_SECONDS` | `7200` | Lifetime of an issued CSRF token, in seconds. Integer, 1–86400; boot fails otherwise (an *empty* value coerces to `0` and would silently disable the token arm). |
246
- | `SESSION_STORAGE_TYPE` | `redis` | Session store backend: `redis` or `memory`. `memory` is per process and is refused under `DEPLOYMENT_MODE=multi` like the other in-memory stores (#474) |
247
- | `SESSION_STORAGE_REDIS_URL` | `redis://localhost:6379` | Redis connection URL for session storage |
248
- | `SESSION_STORAGE_REDIS_PASSWORD` | — | Redis password for session storage |
249
-
250
- If you set `SESSION_SECURE=false` for local HTTP development or set `SESSION_DOMAIN`
251
- for shared-domain cookies, also set `SESSION_NAME` to a non-`__Host-` value such as
252
- `auth.sid`. The server fails fast when a `__Host-` cookie name is combined with
253
- attributes that browsers reject for that prefix.
481
+ | `SESSION_STORE_SECRET` | — | **Required.** Signs the cookie that *is* the authenticated session, so guessing it forges logins. At least 32 bytes (256 bits) — `openssl rand -hex 32`. Measured **decoded**, same rule as `KEY_STORE_LOCAL_SECRET`. |
482
+ | `SESSION_STORE_NAME` | `__Host-auth.session` | Session cookie name. The default uses the `__Host-` prefix and therefore requires `SESSION_STORE_SECURE=true` and no `SESSION_STORE_DOMAIN`. |
483
+ | `SESSION_STORE_MAX_AGE` | `3600000` | Session cookie max age in milliseconds. Whole positive number, at most one year (`31536000000`). |
484
+ | `SESSION_STORE_SECURE` | `true` | Set `Secure` flag on session cookie |
485
+ | `SESSION_STORE_SAME_SITE` | `lax` | `SameSite` attribute (`lax`, `strict`, `none`). `none` **requires** `SESSION_STORE_SECURE=true` — browsers drop a `SameSite=None` cookie that is not `Secure`, so boot refuses the combination rather than letting every login fail silently in the client. |
486
+ | `SESSION_STORE_DOMAIN` | — | Cookie domain (unset by default) |
487
+ | `SESSION_CSRF_TTL_SECONDS` | `7200` | Lifetime of an issued CSRF token, in seconds. Whole number in decimal digits, 1–86400; boot fails otherwise, an *empty* or blank value included. |
488
+ | `SESSION_STORE_STORAGE_TYPE` | `redis` | Session store backend: `redis` or `memory`. `memory` is per process and is refused under `CORE_DEPLOYMENT_MODE=multi` like the other in-memory stores (#474) |
489
+ | `SESSION_STORE_STORAGE_REDIS_URL` | `redis://localhost:6379` | Redis connection URL for session storage |
490
+ | `SESSION_STORE_STORAGE_REDIS_PASSWORD` | — | Redis password for session storage |
491
+
492
+ The cookie and its store are the session store's section, `session-store`; the
493
+ CSRF token's lifetime, the login page and the login's budget are the session
494
+ 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.
497
+
498
+ If you set `SESSION_STORE_SECURE=false` for local HTTP development or set `SESSION_STORE_DOMAIN`
499
+ for shared-domain cookies, also set `SESSION_STORE_NAME` to a non-`__Host-` value such as
500
+ `auth.sid` — with `SESSION_STORE_SECURE=false`, one with no prefix: a `__Secure-` name
501
+ needs `SESSION_STORE_SECURE=true` too. The server fails fast when a `__Host-` or
502
+ `__Secure-` cookie name is combined with attributes that browsers reject for that
503
+ prefix (in any case), on a `SESSION_STORE_NAME` that is not a cookie name (an RFC 6265
504
+ token: no space, `;` or other separator), and on a `SESSION_STORE_DOMAIN` that is not a
505
+ host name (a scheme, a port or a path).
254
506
 
255
507
  #### CSRF on `/session/login` and `/session/logout`
256
508
 
@@ -258,7 +510,7 @@ Both routes accept a request carrying **either** a same-origin (or trusted)
258
510
  `Origin` / `Referer`, **or** a valid double-submit CSRF token, and reject one
259
511
  carrying neither (#272). Browsers satisfy this automatically; a scripted client
260
512
  calls `GET /session/csrf` first and sends the returned `csrf_token` back as
261
- both the `<SESSION_NAME>.csrf` cookie and an `x-csrf-token` header (or a
513
+ both the `<SESSION_STORE_NAME>.csrf` cookie and an `x-csrf-token` header (or a
262
514
  `csrf_token` form field).
263
515
 
264
516
  Two configuration notes:
@@ -269,19 +521,21 @@ Two configuration notes:
269
521
  request.
270
522
  - If your login UI is served from a **different origin** than the provider,
271
523
  list that origin under `session.csrf.trustedOrigins` in your HOCON config.
272
- `cors.allowedOrigins` does not confer CSRF trust — see
524
+ A listed origin can also answer federation-grant consents and device
525
+ verification, so a client's origin is never listed (the federation-grants
526
+ ADR's D7). `http.cors.allowedOrigins` does not confer CSRF trust — see
273
527
  [CORS](#cors) for what it does confer.
274
528
 
275
529
  ### CORS
276
530
 
277
531
  | Variable | Default | Description |
278
532
  |---|---|---|
279
- | `CORS_ALLOWED_ORIGINS` | *(empty — CORS off)* | Comma-separated list of browser origins allowed to read the token, userinfo, revocation and discovery/JWKS responses |
533
+ | `HTTP_CORS_ALLOWED_ORIGINS` | *(empty — CORS off)* | Comma-separated list of browser origins allowed to read the token, userinfo, revocation and discovery/JWKS responses |
280
534
 
281
535
  A browser app served from a different origin than this provider — an SPA on
282
536
  `https://app.example.com` against a provider on `https://auth.example.com` —
283
537
  cannot call the token endpoint at all unless that origin is on
284
- `cors.allowedOrigins`. Set it and the provider answers preflights and stamps
538
+ `http.cors.allowedOrigins`. Set it and the provider answers preflights and stamps
285
539
  `Access-Control-Allow-Origin` on the five endpoints a browser has a legitimate
286
540
  reason to call cross-origin:
287
541
 
@@ -291,7 +545,7 @@ reason to call cross-origin:
291
545
  | `/oauth/userinfo` | `GET`, `POST` | OIDC Core §5.3 defines both |
292
546
  | `/oauth/revoke` | `POST` | RFC 7009 §2.1 — a public client revoking its own tokens on sign-out |
293
547
  | `/.well-known/openid-configuration` | `GET` | Discovery, fetched by browser client libraries |
294
- | `/.well-known/jwks.json` | `GET` | Same; follows `oauth.jwt.jwksPath` when you override it |
548
+ | `/.well-known/jwks.json` | `GET` | Same; follows `jwks.path` when you override it |
295
549
 
296
550
  `/oauth/introspect` and `/oauth/authorize` are deliberately **not** included.
297
551
  Introspection is server-to-server and already refuses public clients, so no
@@ -299,12 +553,21 @@ browser could use it; `/authorize` is a top-level navigation rather than a
299
553
  `fetch`, and CORS has no say over where a browser may navigate.
300
554
 
301
555
  ```hocon
302
- cors {
556
+ http.cors {
303
557
  allowedOrigins = ["https://app.example.com", "http://localhost:5173"]
304
558
  }
305
559
  ```
306
560
 
307
- or `CORS_ALLOWED_ORIGINS=https://app.example.com,http://localhost:5173`.
561
+ or `HTTP_CORS_ALLOWED_ORIGINS=https://app.example.com,http://localhost:5173`.
562
+ Those are the two spellings; a value that is neither — a number, an
563
+ object, a boolean, which only a configuration file can write — fails boot
564
+ naming `http.cors.allowedOrigins` rather than reading as no origins.
565
+
566
+ The list is part of the `http` module's section, with its default in
567
+ `config/reference.conf`. The `http` module hands it to core's CORS middleware
568
+ in the `httpSettings` slot, beside the trusted hops, and core reads the slot,
569
+ not the configuration: a composition with no `httpSettings` provider mounts
570
+ no CORS.
308
571
 
309
572
  **Matching is exact string equality against the `Origin` header**, and every
310
573
  shape that could not match fails at boot naming its index rather than sitting
@@ -330,16 +593,25 @@ all — no headers, no `Vary`, nothing changed.
330
593
 
331
594
  ### Google Federation
332
595
 
596
+ The federations are core's: `core.federations`, one map keyed by the name
597
+ each is reached at (`/session/oauth/federation/<name>`). A key of one is bound
598
+ to the variable named after its path, `CORE_FEDERATIONS_<NAME>_<KEY>`. The map
599
+ written at the top level (`federations { ... }`) refuses boot, naming each
600
+ 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.
603
+
333
604
  | Variable | Default | Description |
334
605
  |---|---|---|
335
- | `FEDERATIONS_GOOGLE_ENABLED` | `false` | Enable Google OAuth federation |
336
- | `FEDERATIONS_GOOGLE_CLIENT_ID` | — | Google OAuth client ID |
337
- | `FEDERATIONS_GOOGLE_CLIENT_SECRET` | — | Google OAuth client secret |
338
- | `FEDERATIONS_GOOGLE_CALLBACK_URL` | `http://localhost:3000/session/oauth/federation/google/callback` | Google OAuth callback URL |
606
+ | `CORE_FEDERATIONS_GOOGLE_ENABLED` | `false` | Enable Google OAuth federation |
607
+ | `CORE_FEDERATIONS_GOOGLE_CLIENT_ID` | — | Google OAuth client ID |
608
+ | `CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET` | — | Google OAuth client secret |
609
+ | `CORE_FEDERATIONS_GOOGLE_CALLBACK_URL` | `http://localhost:3000/session/oauth/federation/google/callback` | Google OAuth callback URL |
610
+ | `CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE` | unset (`offline`) | `offline`: every sign-in shows Google's consent screen and every session gets a refresh token. `online`: consent on the first sign-in only, and no refresh token at all. See [federation-google](../../packages/federation-google/README.md#refresh-tokens-and-the-consent-screen) |
339
611
 
340
612
  ### OIDC Federation (any OpenID Connect provider)
341
613
 
342
- One instance ships in `config/application.conf` under `federations.oidc`
614
+ One instance ships in `config/application.conf` under `core.federations.oidc`
343
615
  (`@o3co/auth-provider-federation-oidc`, #524): Okta, Entra ID, Auth0, Keycloak
344
616
  or your own tenant, selected by issuer. Discovery runs at boot and a failure
345
617
  refuses boot. The identity handed to the Store is `oidc:<sub>`; an identity
@@ -347,16 +619,16 @@ the Store does not know is refused with 401 — nothing is provisioned.
347
619
 
348
620
  | Variable | Default | Description |
349
621
  |---|---|---|
350
- | `FEDERATIONS_OIDC_ENABLED` | `false` | Enable the OIDC federation |
351
- | `FEDERATIONS_OIDC_ISSUER` | — | Issuer identifier (https), exactly as the IdP writes `iss` |
352
- | `FEDERATIONS_OIDC_CLIENT_ID` | — | Client ID registered at the IdP |
353
- | `FEDERATIONS_OIDC_CLIENT_SECRET` | — | Client secret (`client_secret_basic`); set `privateKey` in the config file for `private_key_jwt` instead |
354
- | `FEDERATIONS_OIDC_CALLBACK_URL` | `http://localhost:3000/session/oauth/federation/oidc/callback` | Where the IdP sends the browser back |
622
+ | `CORE_FEDERATIONS_OIDC_ENABLED` | `false` | Enable the OIDC federation |
623
+ | `CORE_FEDERATIONS_OIDC_ISSUER` | — | Issuer identifier (https), exactly as the IdP writes `iss` |
624
+ | `CORE_FEDERATIONS_OIDC_CLIENT_ID` | — | Client ID registered at the IdP |
625
+ | `CORE_FEDERATIONS_OIDC_CLIENT_SECRET` | — | Client secret (`client_secret_basic`); set `privateKey` in the config file for `private_key_jwt` instead |
626
+ | `CORE_FEDERATIONS_OIDC_CALLBACK_URL` | `http://localhost:3000/session/oauth/federation/oidc/callback` | Where the IdP sends the browser back |
355
627
 
356
628
  A second IdP is another section with `type = "oidc"` and its own callback:
357
629
 
358
630
  ```hocon
359
- federations {
631
+ core.federations {
360
632
  okta {
361
633
  enabled = true
362
634
  type = "oidc"
@@ -369,7 +641,7 @@ federations {
369
641
  }
370
642
  ```
371
643
 
372
- The section's `type` names the implementation. `federations.google` without a
644
+ The entry's `type` names the implementation. `core.federations.google` without a
373
645
  `type` is the built-in Google federation; with `type = "oidc"` it is a generic
374
646
  OIDC instance named `google`, and the built-in module is not composed.
375
647
 
@@ -381,20 +653,27 @@ is documented in [its README](../../packages/federation-oidc/README.md).
381
653
 
382
654
  | Variable | Default | Description |
383
655
  |---|---|---|
384
- | `CLIENT_TYPE` | `yaml` | Client store backend: `yaml` |
385
- | `CLIENT_PATH` | `./config/clients.yaml` | Path to the YAML client registry |
656
+ | `ADAPTERS_CLIENT_REPOSITORY` | `yaml` | Client store backend: `yaml`, or `static`, core's alias of `yaml`, which reads `repositories.client.static.path` (no default) |
657
+ | `REPOSITORIES_CLIENT_YAML_PATH` | `./config/clients.yaml` | Path to the YAML client registry |
658
+
659
+ The registry is per-deployment configuration: `.gitignore` keeps
660
+ `config/clients.yaml` out of git and `.dockerignore` keeps it out of the image,
661
+ and the production compose file mounts it (see [Docker](#docker)).
386
662
 
387
663
  ### User Repository
388
664
 
389
665
  | Variable | Default | Description |
390
666
  |---|---|---|
391
- | `CLIENT_USER_TYPE` | `http` | User repository backend: `http` |
392
- | `CLIENT_USER_AUTHENTICATE_URL` | — | URL for password-based user authentication. **https required** (see below) |
393
- | `CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL` | — | URL for token-based user authentication. **https required** (see below) |
394
- | `CLIENT_USER_LINK_FEDERATED_IDENTITY_URL` | — | Optional. URL the Store links a federated identity at (#482); enables `?link=1` on the federation start route. **https required** |
395
- | `CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` | — | Optional. URL the Store answers who holds an upstream identity at (#613, federation grants' check 5). With it, declare what the Store covers in `repositories.user.http.federatedIdentityLookupCoverage` (HOCON). **https required** |
396
- | `CLIENT_USER_TIMEOUT` | `5000` | HTTP request timeout in milliseconds. Positive integer ≤ `2147483647` |
397
- | `CLIENT_USER_MAX_RESPONSE_BYTES` | `1048576` | Largest upstream response body accepted, in bytes |
667
+ | `ADAPTERS_USER_REPOSITORY` | `http` | User repository backend: `http`, or `yaml` for local work (`static`, core's alias of `yaml`, reads `repositories.user.static.path`, with no default) |
668
+ | `REPOSITORIES_USER_YAML_PATH` | `./config/users.yaml` | The YAML user file the `yaml` backend reads |
669
+ | `REPOSITORIES_USER_HTTP_AUTHENTICATE_URL` | — | URL for password-based user authentication. **https required** (see below) |
670
+ | `REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL` | — | URL for token-based user authentication. **https required** (see below) |
671
+ | `REPOSITORIES_USER_HTTP_LINK_FEDERATED_IDENTITY_URL` | — | Optional. URL the Store links a federated identity at (#482); enables `?link=1` on the federation start route. **https required** |
672
+ | `REPOSITORIES_USER_HTTP_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` | — | Optional. URL the Store answers who holds an upstream identity at (#613, federation grants' check 5). With it, declare what the Store covers in `repositories.user.http.federatedIdentityLookupCoverage` (HOCON). **https required** |
673
+ | `REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL` | — | Optional. URL the Store keeps the MFA enrollment witness at (the MFA ADR's D12): with it, a lost factor store cannot open a first binding. The Store must then answer `mfaEnrolled` on both authenticate URLs. Unset, a boot with MFA on warns (`mfa_enrollment_witness_unwritable`). **https required** |
674
+ | `REPOSITORIES_USER_HTTP_BEARER_TOKEN` | — | Optional. The credential this server presents to the Store: every Store request carries `Authorization: Bearer <token>`. A bare token (no `Bearer ` prefix) of at least 32 bytes — `openssl rand -hex 32`. Unset, no `Authorization` header is sent (see below) |
675
+ | `REPOSITORIES_USER_HTTP_TIMEOUT` | `5000` | HTTP request timeout in milliseconds. Positive integer ≤ `2147483647` |
676
+ | `REPOSITORIES_USER_HTTP_MAX_RESPONSE_BYTES` | `1048576` | Largest upstream response body accepted, in bytes |
398
677
 
399
678
  Both user-authentication URLs carry **plaintext user credentials** to the
400
679
  upstream store, so both must be absolute `https://` URLs. `http://` is accepted
@@ -405,31 +684,71 @@ only for a loopback host — `localhost`, an address in `127.0.0.0/8`, or `[::1]
405
684
  deployment does not control end to end. Boot fails if either URL, the timeout,
406
685
  or the response cap is unusable, rather than at the first login attempt.
407
686
 
687
+ Each URL must be the endpoint that answers, not one that redirects: no request
688
+ follows a `3xx`, so a URL that redirects fails every call — see
689
+ [What the Store must enforce itself](../../packages/foundation/README.md#what-the-store-must-enforce-itself).
690
+
691
+ **Who may call the Store.** What the token-based login and account linking
692
+ send is an identifier, not a secret, so a Store that answers any caller
693
+ resolves a known identity to its user — or links one to any account — for
694
+ anyone who can reach it. Set `REPOSITORIES_USER_HTTP_BEARER_TOKEN` and have the Store
695
+ answer every request whose `Authorization` is not exactly `Bearer <that token>`
696
+ with `401` and `WWW-Authenticate: Bearer error="invalid_token"` (or `403` and
697
+ `error="insufficient_scope"`). The one token goes to every Store URL above, so
698
+ those endpoints must be one trust domain. With `ADAPTERS_USER_REPOSITORY=http` — this
699
+ template's default — boot fails if the token is weaker than 32 bytes
700
+ (measured like `SESSION_STORE_SECRET`), malformed, or exported but empty; it appears
701
+ in no error this server throws. With that challenge, a token the Store does not
702
+ accept is an outage on every Store call: logins answer
703
+ `503 temporarily_unavailable` and log an error naming the refused credential,
704
+ not the token, and so do federation-grants connects
705
+ (`federation_grant_callback_unavailable`). A `401` or `403` without the
706
+ challenge keeps its old meaning — at login "no such user", at a `?link=1`
707
+ link a refusal, and at the federation-grants identity lookup an outage like
708
+ any other non-`2xx` — so a mismatch then shows only as every login failing
709
+ and every link refused; and a Store must not put a `Bearer` challenge on a
710
+ user's wrong password or a link it refuses. A Store that cannot be reached,
711
+ closes the connection before a complete response, or answers with a malformed
712
+ HTTP response is logged as such, with at most a transport code
713
+ (`ECONNREFUSED`, `UND_ERR_SOCKET`, `ERR_SSL_WRONG_VERSION_NUMBER`, …), and
714
+ never with what the transport quoted. Without the token, admit only this server to the Store
715
+ by network policy or platform mutual TLS. Rotation, what each caller logs, and
716
+ what the Store checks are in the same foundation README section.
717
+
408
718
  ### Code Repository
409
719
 
720
+ `adapters.codeRepository` (`ADAPTERS_CODE_REPOSITORY`) chooses the
721
+ repository; each reads its own section. The Redis one runs on the shared
722
+ connection `REDIS_CLIENTS_URL` configures, so it takes no URL or password of
723
+ its own.
724
+
410
725
  | Variable | Default | Description |
411
726
  |---|---|---|
412
- | `CLIENT_CODE_TYPE` | `redis` | Deprecated alias of `OAUTH_CODE_ADAPTER` (`repositories.code.type`); honoured with a boot warning |
413
- | `CLIENT_CODE_ENDPOINT_URI` | — | Legacy; bound to `repositories.code.redis.endpointUri` but not read — codes use the shared `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL` connection |
414
- | `CLIENT_CODE_PASSWORD` | — | Redis password for code storage |
415
- | `CLIENT_CODE_DEFAULT_EXPIRES_IN` | `600` | Default authorization code lifetime in seconds |
727
+ | `REDIS_CODE_REPOSITORY_DEFAULT_EXPIRES_IN` | `600` | `redis-code-repository.defaultExpiresIn`: the Redis repository's default authorization-code lifetime, in seconds |
728
+ | `REDIS_CODE_REPOSITORY_KEY_PREFIX` | `oauth:code:` | `redis-code-repository.keyPrefix`: the Redis repository's key namespace (see [Redis Namespacing](#redis-namespacing)) |
729
+ | `STANDALONE_IN_MEMORY_CODE_REPOSITORY_DEFAULT_EXPIRES_IN` | `600` | `standalone-in-memory-code-repository.defaultExpiresIn`: the in-process repository's, a positive whole number of seconds |
730
+
731
+ `CLIENT_CODE_DEFAULT_EXPIRES_IN` and `CLIENT_CODE_KEY_PREFIX` were renamed
732
+ 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.
416
735
 
417
736
  ### Federation Token Store
418
737
 
419
738
  The upstream IdP tokens (a Google refresh token, say) held on behalf of a
420
739
  session. Memory by default, which forks per replica and is refused under
421
- `DEPLOYMENT_MODE=multi`; the Redis store shares the socket configured by
422
- `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL` and encrypts records at rest, so it
740
+ `CORE_DEPLOYMENT_MODE=multi`; the Redis store shares the socket configured by
741
+ `REDIS_CLIENTS_URL` and encrypts records at rest, so it
423
742
  needs a key.
424
743
 
425
744
  | Variable | Default | Description |
426
745
  |---|---|---|
427
- | `FEDERATION_TOKEN_STORE_TYPE` | `memory` | Federation token store backend: `memory` or `redis` |
746
+ | `ADAPTERS_FEDERATION_TOKEN_STORE` | `memory` | Federation token store backend: `memory` or `redis` |
428
747
  | `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY` | — | AES-256-GCM key for records at rest: 32 bytes, base64-encoded (`openssl rand -base64 32`). **Required** with `redis` unless the mode below is `allow-plaintext` |
429
- | `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE` | `required` | `required` or `allow-plaintext`. Plaintext is refused when the config was selected by a production/staging environment (`CONFIG_ENV` or `NODE_ENV`) and under `DEPLOYMENT_MODE=multi` in any environment, unless `FEDERATION_TOKENS_ALLOW_INSECURE=1` is also set — development only |
748
+ | `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE` | `required` | `required` or `allow-plaintext`. Plaintext is refused when the config was selected by a production/staging environment (`CONFIG_ENV` or `NODE_ENV`) and under `CORE_DEPLOYMENT_MODE=multi` in any environment, unless `FEDERATION_TOKENS_ALLOW_INSECURE=1` is also set — development only |
430
749
 
431
750
  `ttl` (seconds; keep it above the upstream refresh-token lifetime) and the #291
432
- `scanFallback` migration flag live under `redisFederationTokenStore` in a
751
+ `scanFallback` migration flag live under `redis-federation-token-store` in a
433
752
  config layer rather than behind an environment variable.
434
753
 
435
754
  ### Consent Store
@@ -438,12 +757,87 @@ Where an end-user's consent to a client that is not first-party is recorded,
438
757
  together with the `/authorize` request parked while the consent page asks
439
758
  (#527, #552). One switch wires both. `none` by default: such clients are
440
759
  refused and only first-party clients are served. `memory` forks per replica and
441
- is refused under `DEPLOYMENT_MODE=multi`; `redis` (#561) shares both over the
442
- socket configured by `REFRESH_TOKEN_FAMILY_STORE_REDIS_URL`.
760
+ is refused under `CORE_DEPLOYMENT_MODE=multi`; `redis` (#561) shares both over the
761
+ socket configured by `REDIS_CLIENTS_URL`.
443
762
 
444
763
  | Variable | Default | Description |
445
764
  |---|---|---|
446
- | `CONSENT_STORE_ADAPTER` | `none` | Consent store for clients that are not first-party (#527): `none` (such clients are refused), `memory` (single replica) or `redis` (shared, #561) |
765
+ | `ADAPTERS_CONSENT_STORE` | `none` | Consent store for clients that are not first-party (#527): `none` (such clients are refused), `memory` (single replica) or `redis` (shared, #561) |
766
+
767
+ ### Multi-factor authentication
768
+
769
+ A second factor after a password login, and a step-up when a relying party
770
+ asks for one at `/authorize` — the [MFA package](../../packages/mfa/README.md).
771
+ `MFA_MODE` is the template's own switch, `mfaMode`
772
+ ([`config/reference.conf`](config/reference.conf)): `required`, the default
773
+ (every password login has a second factor), and `optional` (a user with a
774
+ factor is challenged, nobody is forced) install the MFA module, the TOTP, recovery-code and email factors (email off
775
+ unless `MFA_EMAIL_FACTOR_ENABLED`), the operator reset
776
+ (`handle.components.mfaReset`), the session package's login completion and
777
+ the two MFA stores `adapters` selects. The template then adds `mfa` to
778
+ `core.sessionRequirements.expected`, names it
779
+ `core.sessionRequirements.secondFactorAuthority` and writes `mfa.mode` from
780
+ the switch, so
781
+ a composition that asks for a second factor and cannot enforce one is refused
782
+ at boot rather than let a password alone sign in; `off` installs nothing of
783
+ MFA. A value that is none of the three is refused before boot, naming
784
+ `mfaMode` and `MFA_MODE`.
785
+
786
+ **On by default.** In development (`make dev`) MFA needs nothing more: the
787
+ sample key in `config/development.conf`, the sender that logs each code, and
788
+ `docker-compose.yml`'s Redis for the two stores. Outside development a
789
+ deployment either sets what MFA needs — the two stores on `redis` (or the
790
+ factors in the `store`), `MFA_ENCRYPTION_KEY` and the SMTP relay
791
+ (`STANDARD_SMTP_MAIL_SENDER_HOST` and `_FROM`, or a sender of its own through
792
+ `buildModules`' `mailSenderModules`) — or sets `MFA_MODE=off`. Until it
793
+ decides, the boot is refused before the app is created: the stores' refusal
794
+ comes first, naming each of those and `MFA_MODE=off`. The MFA page is never
795
+ refused: `MFA_PAGE_URL` defaults to `/mfa`, which the deployment must serve,
796
+ in development too, or every password login stops there.
797
+
798
+ | Variable | Default | Description |
799
+ |---|---|---|
800
+ | `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` |
802
+ | `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
+ | `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
+ | `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` |
805
+ | `STANDARD_SMTP_MAIL_SENDER_FROM` | — | The one sender address of that mail |
806
+ | `STANDARD_SMTP_MAIL_SENDER_PORT` | `587` | The relay's port |
807
+ | `STANDARD_SMTP_MAIL_SENDER_SECURE` | `starttls` | `starttls`, `tls`, or `none` to a loopback relay only |
808
+ | `STANDARD_SMTP_MAIL_SENDER_USER` | — | The relay account, with `STANDARD_SMTP_MAIL_SENDER_PASSWORD` |
809
+ | `STANDARD_SMTP_MAIL_SENDER_PASSWORD` | — | That account's password |
810
+
811
+ The rest of the MFA settings — each factor's, the lock, a transaction's life
812
+ — are the MFA package's, with their variables, in its
813
+ [README](../../packages/mfa/README.md#configuration). A few things to know:
814
+
815
+ - **The stores.** `memory`, the shipped selection, is for development and
816
+ test alone: a restart loses every factor, lock and recorded email proof,
817
+ after which whoever holds a password can bind a factor of their own. With
818
+ MFA on it is refused before boot unless the configuration's name, and
819
+ `CONFIG_ENV` and `NODE_ENV` wherever set, each say `development` or
820
+ `test`, and core refuses it under `CORE_DEPLOYMENT_MODE=multi`. Production
821
+ needs `redis` for both, or `store` for the factors: `redis` shares the
822
+ socket `REDIS_CLIENTS_URL` opens; `store` (factors only) keeps them behind the Store's four endpoints
823
+ (`FOUNDATION_MFA_FACTOR_STORE_*`,
824
+ [foundation's README](../../packages/foundation/README.md)), sent the same
825
+ bearer token as the user repository.
826
+ - **The enrollment witness.** With `ADAPTERS_USER_REPOSITORY=http`, set
827
+ `REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL` ([User Repository](#user-repository));
828
+ without it, boot warns once (`mfa_enrollment_witness_unwritable`).
829
+ - **`acr`.** With MFA on, the template adds `"urn:o3co:acr:mfa" = ["mfa"]`
830
+ to `oauth.authorize.acrValues`, unless your configuration writes that
831
+ entry, and discovery advertises it. With MFA off it adds nothing: the acr
832
+ table and discovery are what they are without MFA.
833
+ - **The mail in development.** Under `CONFIG_ENV=development` the template's
834
+ sender logs each code (`mail_code_issued`) and sends nothing. To run the
835
+ SMTP sender and read what it sends, use the
836
+ [Mailpit overlay](#mailpit-for-the-smtp-sender-development-only).
837
+ - **One switch.** A file that writes `mfaMode` while `MFA_MODE` is set to
838
+ something else, and a configuration that writes `mfa.mode` other than
839
+ what the switch says, are refused before boot, naming the keys. While MFA
840
+ is off, nothing the configuration writes under `mfa` is handed to boot.
447
841
 
448
842
  ### Federation Grants
449
843
 
@@ -459,29 +853,35 @@ needs before it issues a refresh token is in
459
853
 
460
854
  | Variable | Default | Description |
461
855
  |---|---|---|
462
- | `FEDERATION_GRANTS_ENABLED` | `false` | Installs the routes, the two stores below and the subject-revocation service |
856
+ | `FEDERATION_GRANTS_ENABLED` | `false` | Installs the routes, the two stores selected below (none until selected) and the subject-revocation service |
463
857
  | `FEDERATION_GRANTS_CONSENT_URL` | — | The deployment's consent page for grants: a path, or an absolute URL on the provider's origin. No default — enabling grants is a statement that the page exists, and boot refuses without it |
464
858
  | `FEDERATION_GRANTS_IDENTITY_LOOKUP` | `required` | Whether the connect callback refuses an upstream account already linked to another local user. `required` needs a user repository that covers every connection's registration (below); `unsupported` records that the check is not made |
465
- | `FEDERATION_GRANT_STORE_ADAPTER` | `redis` | Where grants live: `memory` (one replica; lost on restart, every user reconnects) or `redis` (the shared socket) |
466
- | `FEDERATION_GRANT_INTENT_STORE_ADAPTER` | `redis` | Where acquisition's records live — the intent a backend lodged, the consent challenge, the connect transaction: `memory` (one replica; a restart loses flows in progress and nothing else) or `redis` |
467
- | `FEDERATION_GRANTS_ENCRYPTION_MODE` | `required` | `required` or `allow-plaintext`. Plaintext is refused in production/staging and under `DEPLOYMENT_MODE=multi` unless `FEDERATION_TOKENS_ALLOW_INSECURE=1` |
859
+ | `ADAPTERS_FEDERATION_GRANT_STORE` | `none` | Where grants live: `none` (no store; an enabled feature refuses boot naming it), `memory` (one replica; lost on restart, every user reconnects) or `redis` (the shared socket) |
860
+ | `ADAPTERS_FEDERATION_GRANT_INTENT_STORE` | `none` | Where acquisition's records live — the intent a backend lodged, the consent challenge, the connect transaction: `none` (no store; an enabled feature refuses boot naming it), `memory` (one replica; a restart loses flows in progress and nothing else) or `redis` |
861
+ | `REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE` | `required` | `required` or `allow-plaintext`. Plaintext is refused in production/staging and under `CORE_DEPLOYMENT_MODE=multi` unless `FEDERATION_TOKENS_ALLOW_INSECURE=1` |
468
862
  | `FEDERATION_GRANTS_ALLOW_KEEP_ON_SUBJECT_REVOCATION` | `false` | Whether a subject-wide revocation may be *asked* to leave established grants standing. An allowance, not an instruction |
469
863
  | `REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX` | `fg:` | Key namespace of the Redis grant store |
864
+ | `REDIS_FEDERATION_GRANT_INTENT_STORE_KEY_PREFIX` | `fg:` | Key namespace of the Redis intent store; set it with the grant store's — the template refuses to boot with the grant store's set and this one left at its default |
470
865
 
471
- **Two things have no environment form**, because a list is HOCON's: the
472
- connections, and the encryption key ring. Write them in a deployment-owned
473
- layer — `config/production.conf`, say:
866
+ The feature's own settings are the `federation-grants {}` section; the Redis
867
+ grant store's — its key ring, its encryption mode, its prefix — are
868
+ `redis-federation-grant-store {}`. **Two things have no environment form**,
869
+ because a list is HOCON's: the connections, and the encryption key ring.
870
+ Write them in a deployment-owned layer — `config/production.conf`, say:
474
871
 
475
872
  ```hocon
476
- federationGrants {
873
+ redis-federation-grant-store {
477
874
  encryptionKeys = [
478
875
  # The first key seals; every listed key opens. GRANT_KEY_2026_09 is a name
479
876
  # you choose (openssl rand -base64 32), not a template override.
480
877
  { id = "2026-09", key = ${GRANT_KEY_2026_09} }
481
878
  ]
879
+ }
880
+
881
+ federation-grants {
482
882
  connections {
483
883
  files {
484
- federation = "entra-files" # an enabled federations.<name> of type "oidc", with an app registration of its own
884
+ federation = "entra-files" # an enabled core.federations.<name> of type "oidc", with an app registration of its own
485
885
  scopes = ["openid", "profile", "offline_access", "Files.Read"]
486
886
  boundary = "production"
487
887
  maxAccessTokenLifetime = 3600
@@ -501,15 +901,15 @@ enabled, or on one whose adapter lacks the delegated capability — only the
501
901
  generic OIDC adapter has it, so a Google connection is a `type = "oidc"`
502
902
  federation; `required` with a user repository that does not cover a
503
903
  connection's registration; Redis grants beside memory user-session stores
504
- (`USER_SESSION_STORES_ADAPTER=memory` — the grants would outlive the boundary
904
+ (`ADAPTERS_USER_SESSION_STORES=memory` — the grants would outlive the boundary
505
905
  that ends them; both compose files set `redis`); the Redis grant store under
506
906
  `encryptionMode = "required"` with no key in the ring (the memory store seals
507
- nothing and needs none); `memory` for either store under `DEPLOYMENT_MODE=multi`.
907
+ nothing and needs none); `memory` for either store under `CORE_DEPLOYMENT_MODE=multi`.
508
908
 
509
909
  **The user repository.** Under `required`, boot asks the repository whether it
510
910
  covers each connection's registration, and the connect callback asks it who
511
911
  holds the upstream account. The template's `http` repository asks your Store
512
- (#613): set `CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` to the
912
+ (#613): set `REPOSITORIES_USER_HTTP_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` to the
513
913
  endpoint, and declare what that endpoint covers — one entry per registration,
514
914
  with the claims the Store's strategy needs — in a HOCON layer:
515
915
 
@@ -560,9 +960,21 @@ is `/oauth/consent`'s, so one page can serve both. `GET
560
960
  duration after approval, not a date), `continues_after_logout` (which the page
561
961
  must show), and `expires_in` (what is left of the flow). `POST` with
562
962
  `challenge` and `decision` (`accept` | `deny`) answers `303` — to the upstream,
563
- or back to the client. Every acquisition and renewal goes through it.
564
-
565
- **The login page** is the one `ENDPOINTS_LOGIN_URL` names, reached with
963
+ or back to the client. Every acquisition and renewal goes through it. The
964
+ answer is held to the session module's CSRF policy: post it as a form from the
965
+ page, and serve the page with `Referrer-Policy: same-origin` — not
966
+ `no-referrer`, whether by the header, `<meta name="referrer">` or
967
+ `rel="noreferrer"` on the form, under which the browser sends `Origin: null`
968
+ and the answer is refused. This app's `helmet()` sends `no-referrer` on every
969
+ response, so a page it serves sets `Referrer-Policy: same-origin` on its own
970
+ route. Behind a proxy, `HTTP_TRUST_PROXY` names it, and it forwards
971
+ `X-Forwarded-Proto` and `X-Forwarded-Host` (or keeps the browser's `Host`). A
972
+ user agent that sends neither `Origin` nor `Referer` echoes the token
973
+ `GET /session/csrf` hands out, as for `POST /session/login`; a page may always
974
+ include it, since it counts only when neither is sent. The package README has
975
+ the rule.
976
+
977
+ **The login page** is the one `SESSION_LOGIN_PAGE_URL` names, reached with
566
978
  `redirect_to=<the connect link>`; it signs the user in and navigates back to
567
979
  that link unchanged, as it does for `/oauth/authorize`. Do not submit the link
568
980
  to `POST /session/login` as its `redirect_to`: that route's exact-match
@@ -586,10 +998,10 @@ auth.provider instances cannot collide in the same database:
586
998
  | Variable | Default | Description |
587
999
  |---|---|---|
588
1000
  | `REDIS_SESSION_STORES_KEY_PREFIX` | `ss:` | Outer prefix for user sessions, RP registry, session-family index, and session-federation index. |
589
- | `REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX` | `rtfam:` | Prefix for refresh-token family records. |
590
- | `CLIENT_CODE_KEY_PREFIX` | `oauth:code:` | Prefix for OAuth authorization codes. |
1001
+ | `REDIS_REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX` | `rtfam:` | Prefix for refresh-token family records. |
1002
+ | `REDIS_CODE_REPOSITORY_KEY_PREFIX` | `oauth:code:` | Prefix for OAuth authorization codes. |
591
1003
  | `REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX` | `ft:` | Prefix for federation token records, their per-session index and their lock keys. |
592
- | `REDIS_CONSENT_STORE_KEY_PREFIX` | `consent:` | Prefix for consent records and for parked consent requests with their per-session index (`CONSENT_STORE_ADAPTER=redis`, #561). |
1004
+ | `REDIS_CONSENT_STORE_KEY_PREFIX` | `consent:` | Prefix for consent records and for parked consent requests with their per-session index (`ADAPTERS_CONSENT_STORE=redis`, #561). |
593
1005
 
594
1006
  Use values that include the deployment name, for example `tenant-a:ss:`,
595
1007
  `tenant-a:rtfam:`, `tenant-a:code:`, `tenant-a:ft:`, and `tenant-a:consent:`.
@@ -598,40 +1010,30 @@ Use values that include the deployment name, for example `tenant-a:ss:`,
598
1010
 
599
1011
  | Variable | Default | Description |
600
1012
  |---|---|---|
601
- | `ENDPOINTS_LOGIN_URL` | `/login` | URL of the login page (for redirects) |
602
- | `ENDPOINTS_CONSENT_URL` | `/consent` | URL of the consent page a client that is not first-party is routed through, with `?challenge=<id>` (#527) |
1013
+ | `SESSION_LOGIN_PAGE_URL` | `/login` | URL of the login page (for redirects) |
1014
+ | `OAUTH_CONSENT_PAGE_URL` | `/consent` | URL of the consent page a client that is not first-party is routed through, with `?challenge=<id>` (#527) |
603
1015
 
604
- `ENDPOINTS_CLIENT_URL` and `ENDPOINTS_AUTH_CALLBACK_URL` were removed (IH-10) —
605
- no production consumer read them, and the config lines leaked values into
606
- `AppConfig` that nothing consumed. Federation callback URLs are configured
607
- per-provider (`FEDERATIONS_GOOGLE_CALLBACK_URL`).
1016
+ There is no deployment-wide client or callback URL: federation callback URLs
1017
+ are configured per federation (`CORE_FEDERATIONS_GOOGLE_CALLBACK_URL`,
1018
+ `CORE_FEDERATIONS_OIDC_CALLBACK_URL`).
608
1019
 
609
1020
  ## Module Composition Order
610
1021
 
611
- `src/buildModules.mts` is the single source of truth for the manifest; this is its order. The boot planner resolves `requires` / `provides` topologically, so list position matters only where a module says so — `sessionStoreModule` must stay first, because it wires the `express-session` middleware every session-consuming route needs. Entries marked *adapter switch* are one of a memory/Redis pair, chosen by config; both provide the same slot, so exactly one is wired.
612
-
613
- 1. **`sessionStoreModule`** (`@o3co/auth-provider-session`) — `express-session` middleware and its store (`session.storage.type`)
614
- 2. **`oauthModule`** — `POST /oauth/token`, `POST /oauth/introspect`, `POST /oauth/revoke`, `GET /oauth/authorize`, and OIDC discovery when an issuer is configured
615
- 3. **`oauthSessionModule`** — session grant (exchanges an authenticated session for a token)
616
- 4. **`oauthAuthorizationModule`** — authorization-code and refresh-token grants
617
- 5. **`jwksModule`** (`@o3co/auth-provider-core`) — `GET /.well-known/jwks.json` (or `oauth.jwt.jwksPath`); a signing provider publishes its verification keys whether or not an issuer is set
618
- 6. **`sessionModule`** — `POST /session/login`, `POST /session/logout`, `GET/POST /session/oauth/federation/*`. `/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).
619
- 7. **`googleFederationModule`** + **`googleFederationConfigModule`** — only when `federations.google.enabled = true`
620
- 8. **`keyStoreModule`** — the signing key store from `oauth.jwt.signingKey`
621
- 9. **`repositoriesModule`** — client and user repositories from `repositories.client` / `repositories.user`
622
- 10. **`auditSinkModule`** — always wired; `audit.sink.type` picks `logger` (default) or `console`
623
- 11. **`standaloneRedisClientsModule`** — the one shared ioredis connection; present whenever any switch below is `"redis"`
624
- 12. **`redisFederationTokenStoreModule`** / **`inMemoryFederationTokenStoreModule`** — *adapter switch*: upstream federation tokens per session (`federationTokenStore.type`; see [Multi-replica deployments](#multi-replica-deployments))
625
- 13. **`redisSessionStoresModule`** / **`inMemorySessionStoresModule`** — *adapter switch*: user-session stores (`userSessionStores.adapter`)
626
- 14. **`redisRateLimiterModule`** / **`memoryRateLimiterModule`** — *adapter switch*: the rate limiter behind the OAuth endpoints and `POST /session/login` (`rateLimiter.adapter`)
627
- 15. **`redisCodeRepositoryModule`** / **`inMemoryCodeRepositoryModule`** — *adapter switch*: authorization codes (`oauth.code.adapter`)
628
- 16. **`redisAccessTokenDenylistModule`** / **`memoryAccessTokenDenylistModule`** — *adapter switch*: RFC 7009 access-token denylist (`accessTokenDenylist.adapter`)
629
- 17. **`redisRefreshTokenFamilyStoreModule`** — refresh-token families; Redis unless a test override swaps in the memory store
630
- 18. **`defaultRefreshTokenFamilyRotationModule`**, **`defaultRefreshTokenFamilyRevocationModule`** — rotation and revocation policies over the family store
1022
+ [`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
+
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`.
1026
+ 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
+ 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
+ 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.
1031
+
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).
631
1033
 
632
1034
  ## Built-in Routes
633
1035
 
634
- `src/app.mts` mounts these on the host app ahead of the composed auth router, so they keep answering while the auth pipeline is degraded — which is when an operator needs them. The JWKS route is not one of them: `jwksModule` contributes it (see above).
1036
+ `src/routes.mts` mounts these on the host app ahead of the composed auth router, so they keep answering while the auth pipeline is degraded — which is when an operator needs them — and core's terminal error handler after all of them, so an error one of them lets through is answered as the auth routes' are (`500 server_error`, logged once as `unhandled_request_error`). The JWKS route is not one of them: `jwksModule` contributes it (see above).
635
1037
 
636
1038
  | Method | Path | Description |
637
1039
  |---|---|---|
@@ -663,11 +1065,13 @@ make test
663
1065
  The `docker-compose.yml` starts the auth server together with a Redis container. Configure environment variables in `.env`.
664
1066
 
665
1067
  `docker-compose.yml` is a **development** file: it builds the `develop` target and bind-mounts `./src` and `./config`, so deploying it ships a hot-reload server running your working tree. The deployable shape is
666
- [`docker-compose.production.yml`](docker-compose.production.yml) — `runtime` target, no source mounts, restart policies, a network-internal persistent Redis, and a **required** `.env` (a boot without a real `SESSION_SECRET` must fail loudly). Its header comments state what it deliberately leaves to you: TLS termination in front, and the [multi-replica](#multi-replica-deployments) steps before any `--scale`.
1068
+ [`docker-compose.production.yml`](docker-compose.production.yml) — `runtime` target, no source mounts, restart policies, a network-internal persistent Redis, and a **required** `.env` (a boot without a real `SESSION_STORE_SECRET` must fail loudly). Its header comments state what it deliberately leaves to you: TLS termination in front, and the [multi-replica](#multi-replica-deployments) steps before any `--scale`.
667
1069
 
668
1070
  `HTTP_TRUST_PROXY` is an explicit `${HTTP_TRUST_PROXY:?…}` entry there, so `docker compose up` **refuses to start** until you name the hop in `.env`. That is deliberate: there is no address the file could default to that would not silently trust a hop you never chose, and without the variable the Secure cookie the file pins is never set, the CSRF origin check 403s every browser POST, and every IP-keyed rate limit shares one bucket.
669
1071
 
670
- Every store whose records must outlive one process is named in that file's `environment:` block rather than inherited — including `USER_SESSION_STORES_ADAPTER=redis`, which has to travel with `SESSION_STORAGE_TYPE=redis`. `DEPLOYMENT_MODE=single` will not tell you when it does not: the replica guard answers "can these stores be shared", not "do these two stores have the same lifetime". Split them and a restart leaves every browser holding a surviving express-session that still reads `isAuthenticated` with no `UserSession` behind it — `/authorize` bounces to login, the cookie bounces it back, and the loop clears only when the user deletes the cookie.
1072
+ Every store whose records must outlive one process is named in that file's `environment:` block rather than inherited — including `ADAPTERS_USER_SESSION_STORES=redis`, which has to travel with `SESSION_STORE_STORAGE_TYPE=redis`. `CORE_DEPLOYMENT_MODE=single` will not tell you when it does not: the replica guard answers "can these stores be shared", not "do these two stores have the same lifetime". Split them and a restart leaves every browser holding a surviving express-session that still reads `isAuthenticated` with no `UserSession` behind it — `/authorize` bounces to login, the cookie bounces it back, and the loop clears only when the user deletes the cookie.
1073
+
1074
+ The two MFA stores are the exception, since MFA is on by default and whether to keep it is the deployment's decision: the production file leaves them at `memory`, which the template refuses in production before boot, naming the stores, `MFA_ENCRYPTION_KEY`, the SMTP relay, `MFA_PAGE_URL` and `MFA_MODE=off`. Set them in `.env` — `ADAPTERS_MFA_FACTOR_STORE=redis` and `ADAPTERS_MFA_TRANSACTION_STORE=redis`, on the file's persistent Redis, with the rest of [Multi-factor authentication](#multi-factor-authentication) — or set `MFA_MODE=off`. `docker-compose.yml` keeps both on its Redis, so that a hot reload does not empty them while the sessions survive.
671
1075
 
672
1076
  ```bash
673
1077
  # The signing keys are a required input: EdDSA is the default and there is no
@@ -676,20 +1080,33 @@ openssl genpkey -algorithm ed25519 -out jwt-private.pem
676
1080
  openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
677
1081
  chmod 600 jwt-private.pem
678
1082
 
1083
+ # The client registry is a required input too: the scaffold's
1084
+ # config/clients.yaml, or one made from config/clients.yaml.example.
1085
+
679
1086
  docker compose -f docker-compose.production.yml up -d --build
680
1087
  ```
681
1088
 
682
1089
  **Keys in containers.** The pem pair reaches the container as a compose
683
1090
  **secret**, mounted read-only at `/run/secrets/`, and the compose file points
684
- `OAUTH_JWT_*_KEY_PATH` there. Do not park them in `config/`: the `Dockerfile`
685
- `COPY`s that directory, so a key placed there is baked into an image layer and
686
- travels with every push — a key you then have to rotate rather than one you
687
- merely stored badly. The same applies to `.env`; keep both out of git.
688
-
689
- The production file also pins `SESSION_SECURE=true` and the `__Host-` cookie
1091
+ `KEY_STORE_LOCAL_*_KEY_PATH` there. Do not park them in `config/`: the `Dockerfile`
1092
+ `COPY`s that directory. `.dockerignore` keeps every `*.pem` and `*.key` out of
1093
+ the build context, as `.gitignore` keeps them out of git, but a key under any
1094
+ other name placed there is baked into an image layer and travels with every
1095
+ push — a key you then have to rotate rather than one you merely stored badly.
1096
+ The same applies to `.env`; keep both out of git.
1097
+
1098
+ **The client registry in containers.** `config/clients.yaml` arrives the same
1099
+ way, as the compose secret `client_registry`, with `REPOSITORIES_CLIENT_YAML_PATH` pointed at it.
1100
+ It is per-deployment and may hold client secrets, so `.dockerignore` keeps it
1101
+ out of the build context: no image carries a registry, and an image built from
1102
+ a working copy is the same as one built from a clean checkout. Compose refuses
1103
+ to start until the file exists. Run the image some other way and you mount it
1104
+ and set `REPOSITORIES_CLIENT_YAML_PATH` yourself.
1105
+
1106
+ The production file also pins `SESSION_STORE_SECURE=true` and the `__Host-` cookie
690
1107
  name in its `environment:` block, which wins over `env_file`. `.env.example`
691
- ships `SESSION_SECURE=false` so a plain-HTTP local run works, and this file
692
- requires that same `.env` — behind the TLS it assumes, a non-Secure session
1108
+ ships `SESSION_STORE_SECURE=false` so that a plain-HTTP compose run (`make dev`) does
1109
+ not trip the `__Host-` cookie check, and this file requires that same `.env` — behind the TLS it assumes, a non-Secure session
693
1110
  cookie is the session handed to anyone who can read one plaintext hop.
694
1111
 
695
1112
  The image installs with `pnpm install --frozen-lockfile`, so a committed
@@ -699,7 +1116,7 @@ the result. Rebuilding the same source then produces the same dependency tree.
699
1116
 
700
1117
  The production image sets `ENV HTTP_PORT=3000` and uses it for both `EXPOSE`
701
1118
  and the Docker-native healthcheck against `/_healthcheck`. The HOCON config
702
- also reads `${?HTTP_PORT}` for `http.port`, so `app.listen()` and the
1119
+ also reads `${?HTTP_PORT}` for `http.port`, so the listener and the
703
1120
  healthcheck cannot drift apart. To run on a different port:
704
1121
 
705
1122
  ```bash
@@ -709,6 +1126,47 @@ docker run -e HTTP_PORT=8080 -p 8080:8080 my-auth-provider
709
1126
  `EXPOSE` is image metadata and does not publish ports by itself, so the
710
1127
  explicit `-p` mapping is still required.
711
1128
 
1129
+ ### Mailpit for the SMTP sender (development only)
1130
+
1131
+ [`docker-compose.mailpit.yml`](docker-compose.mailpit.yml) is an overlay on
1132
+ `docker-compose.yml` that adds [Mailpit](https://mailpit.axllent.org/), a mail
1133
+ catcher, so a development run with MFA on sends its mail — the account-email
1134
+ proof, the email factor's codes — through the SMTP sender instead of the
1135
+ development sender, which only logs each code:
1136
+
1137
+ ```bash
1138
+ docker compose -f docker-compose.yml -f docker-compose.mailpit.yml up --build
1139
+ ```
1140
+
1141
+ Read the mail at <http://localhost:8025>. Mailpit keeps it and delivers
1142
+ nothing onward. A plain `docker compose up` (`make dev`) starts no Mailpit, and
1143
+ the production file names none. It is for development only: a deployment
1144
+ names a real relay with the `STANDARD_SMTP_MAIL_SENDER_*` variables
1145
+ ([Multi-factor authentication](#multi-factor-authentication)).
1146
+
1147
+ What the overlay sets on the app service:
1148
+
1149
+ - `CONFIG_ENV=mailpit`. Under `development` the template installs the sender
1150
+ 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.
1154
+ - Nothing of `MFA_MODE`: MFA is on as the template ships it, or as your
1155
+ `.env` sets it. With MFA off nothing sends mail.
1156
+ - `ADAPTERS_MFA_FACTOR_STORE=redis` and `ADAPTERS_MFA_TRANSACTION_STORE=redis`,
1157
+ on the compose Redis, as `docker-compose.yml` sets them: under a name other
1158
+ than `development` or `test` the MFA stores may not be kept in memory.
1159
+ - `STANDARD_SMTP_MAIL_SENDER_HOST=localhost`,
1160
+ `STANDARD_SMTP_MAIL_SENDER_PORT=1025`, `STANDARD_SMTP_MAIL_SENDER_SECURE=none`
1161
+ and `STANDARD_SMTP_MAIL_SENDER_FROM=auth@example.com`. The SMTP sender sends
1162
+ in plaintext only to a loopback host, so Mailpit runs in the app container's
1163
+ network namespace (`network_mode: service:app`) and listens on its
1164
+ `localhost`; the app service therefore publishes Mailpit's web UI, on
1165
+ loopback.
1166
+
1167
+ It is an overlay rather than a compose profile because a profile only adds
1168
+ services: it cannot change the app service's environment or ports.
1169
+
712
1170
  ### Health endpoints
713
1171
 
714
1172
  Two routes, answering two different questions. Both are mounted on the host
@@ -756,7 +1214,8 @@ changing the image.
756
1214
  ```
757
1215
 
758
1216
  The failure's *reason* is deliberately absent from the body and goes to the log
759
- instead (`readiness_probe_failed`, with the full per-check detail). A driver
1217
+ instead (`readiness_probe_failed`, with each failing check's error as core's
1218
+ `loggableError` projection). A driver
760
1219
  error reads `connect ECONNREFUSED 10.0.3.14:6379` — an internal host and port —
761
1220
  and this endpoint is unauthenticated because an orchestrator has no credentials
762
1221
  to present. Set `includeErrorDetail: true` on `createReadinessRouter` only when
@@ -772,9 +1231,10 @@ rules.
772
1231
  Probes are registered by whichever builder opened the connection, so the list
773
1232
  reflects what this deployment actually wired: the shared ioredis client
774
1233
  (`redis`) and the connect-redis session client (`session-store`) when the
775
- Redis adapters are selected, and **nothing at all** for a memory-only
776
- deployment — which is therefore always ready, because it has no dependency to
777
- be unready for.
1234
+ Redis adapters are selected, and **nothing at all** for a composition with no
1235
+ Redis-backed module — which would therefore always be ready, having no
1236
+ dependency to be unready for. The shipped composition is never that: its
1237
+ refresh-token families are always in Redis, so `redis` is always probed.
778
1238
 
779
1239
  `http.readinessTimeoutMs` (env `HTTP_READINESS_TIMEOUT_MS`, default `1000`)
780
1240
  is the per-probe deadline. Keep it below the orchestrator's own probe timeout;
@@ -783,43 +1243,24 @@ unready one.
783
1243
 
784
1244
  ## Adding Custom Modules
785
1245
 
786
- To add a custom module, import it in `src/buildModules.mts` and include it in the `modules` array passed to `createApp`:
787
-
788
- ```typescript
789
- // src/buildModules.mts
790
- import { myCustomModule } from "./modules/my-custom-module.mjs";
791
-
792
- export function buildModules(config: AppConfig, overrides: BuildModulesOverrides = {}): Module[] {
793
- const googleEnabled =
794
- (config.federations?.google as { enabled?: boolean } | undefined)?.enabled === true;
795
-
796
- return [
797
- oauthModule({ config }),
798
- oauthSessionModule({ config }),
799
- oauthAuthorizationModule({ config }),
800
- sessionModule,
801
- ...(googleEnabled ? [googleFederationModule, googleFederationConfigModule] : []),
802
- myCustomModule, // add here
803
- overrides.keyStoreModule ?? keyStoreModule,
804
- overrides.repositoriesModule ?? repositoriesModule,
805
- overrides.storesModule ?? storesModule,
806
- ];
807
- }
808
-
809
- // src/app.mts
810
- const handle = await createApp({
811
- modules: buildModules(config),
812
- bootstrapComponents: {
813
- config,
814
- pathResolver: import.meta.resolve,
815
- },
816
- });
817
-
818
- app.use(handle.router);
819
- const server = app.listen(config.http.port);
820
- installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
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:
1247
+
1248
+ ```diff
1249
+ // src/buildModules.mts
1250
+ +import { myCustomModule } from "./my-custom-module.mjs";
1251
+ …
1252
+ return [
1253
+ sessionStoreModuleFor(config),
1254
+ …
1255
+ ...(federationGrantsEnabled || mfaInstalled ? [subjectRevocationServiceModule] : []),
1256
+ + myCustomModule,
1257
+ ];
821
1258
  ```
822
1259
 
1260
+ Keep the other rules there too: the session store module stays first, and a module that fills a store slot replaces that slot's adapter switch rather than being added beside it. [`src/app.mts`](src/app.mts) needs no change: it passes `buildModules(config, …)` to `createApp`, mounts the router `createApp` returns, and wires the server's lifetime — `installGracefulShutdown` (below) drains it and calls `handle.dispose()`. The exception is a mail sender of your own: it goes in through the `mailSenderModules` override in that call (rule 7), not into the list, where it would collide with the bundled sender on the `mailSender` slot.
1261
+
1262
+ A module that contributes a session requirement changes what "logged in" means, so its name goes in `core.sessionRequirements.expected`, which `config/application.conf` ships as `[]`. Boot compares the list with what the modules register. A name listed that nothing registers refuses the boot (`session-requirement-missing`), and so does a registered requirement the list leaves out (`session-requirements-undeclared`). When `MFA_MODE` installs MFA, the template adds `mfa` to the list (`expectedSessionRequirements`, [`src/configPath.mts`](src/configPath.mts)), keeping the names you wrote, and writes `core.sessionRequirements.secondFactorAuthority = "mfa"` beside it; a value of your own there other than `mfa` is refused before boot. The MFA module it installs registers `mfa` and declares the second-factor authority. Core refuses a declared authority whose requirement does not declare it (`second-factor-authority-not-declared`), a second requirement named `mfa`, and a second requirement declaring the authority (`duplicate-second-factor-authority`), so a module you add cannot stand in for it. Install MFA through `MFA_MODE`, not by adding its modules to the list: the template would install them a second time.
1263
+
823
1264
  ### Shutdown guarantees
824
1265
 
825
1266
  `src/shutdown.mts` is deliberately part of the scaffold rather than a dependency (#290): for the component that terminates every user session, *"does SIGTERM wait for in-flight requests, and for how long?"* has to be answerable from the code you deploy.
@@ -828,9 +1269,22 @@ installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
828
1269
  2. **New connections stop immediately**, and idle keep-alive sockets are released — they hold the server open with no request behind them, so a quiet server would otherwise wait out the whole deadline for nothing.
829
1270
  3. **In-flight requests get `drainTimeoutMs`** (default **10s**) to finish.
830
1271
  4. **Past the deadline the remaining connections are cut and the process exits non-zero.** An orchestrator that only ever sees `0` cannot tell a clean drain from one that ran out of time.
831
- 5. **`cleanup` runs after draining, before exit** — `handle.dispose()`, i.e. reverse-topological component cleanup plus the Redis/timer drain. A failure there is logged through this service's own logger (NDJSON, like every other line) and reflected in the exit code. A dispose that throws still exits; it never wedges the process.
1272
+ 5. **`cleanup` runs after draining, before exit** — `handle.dispose()`, i.e. reverse-topological component cleanup plus the Redis/timer drain. A failure there is logged through this service's own logger (NDJSON, like every other line) as `shutdown_cleanup_failed` (or `shutdown_cleanup_timed_out`) and reflected in the exit code; every stage is its own event (see the table below). A dispose that throws still exits; it never wedges the process. The line carries core's [`loggableError`](../../packages/core/README.md#logger) projection of the failure, never the error itself: `dispose()` rejects with an AggregateError of every cleanup's own error, and the line names each of them with its code (`aggregateErrors`, the first five) and nothing of what it holds — a store write that failed, with what it was writing, can be one of them.
1273
+
1274
+ 6. **`cleanup` gets at least the allowance the modules registered**: `handle.cleanupAllowanceMs`, the longest tail a module registered its cleanup with, which `src/app.mts` hands `installGracefulShutdown` as `cleanupAllowanceMs: () => handle.cleanupAllowanceMs`, read when the signal arrives; cleanup's budget is the longer of it and the drain's ten, and `shutdown_draining` logs it as `cleanupTimeoutMs`. An allowance that is not a whole number of milliseconds from 1 to 2147483647 is ignored. The template reads no module's settings to size it. With federation grants on, the package registers its drain's tail as `federation-grants.upstreamHardTimeoutMs` + `persistRetryBudgetMs` + `lockWaitMs` + 12 s, never below 45 s — exactly 45 s under the shipped budgets (25 + 3 + 5 + 12) — because the dispose waits for a rotated upstream credential's write. Raise a budget and the allowance grows with it; raise your orchestrator's grace to match. Off, nothing registers an allowance and the cleanup budget stays the drain's.
832
1275
 
833
- 6. **With federation grants on, `cleanup` gets the longest refresh tail plus a margin** (`cleanupAllowanceFor` in `src/shutdown.mts`): `federationGrants.upstreamHardTimeoutMs` + `persistRetryBudgetMs` + `lockWaitMs` + 12 s, never below 45 s — exactly 45 s under the shipped budgets (25 + 3 + 5 + 12) — instead of inheriting the drain's ten, because the dispose waits for a rotated upstream credential's write. Raise a budget and the allowance grows with it; raise your orchestrator's grace to match. Off, the cleanup budget stays the drain's.
1276
+ Each stage logs one line:
1277
+
1278
+ | Event | Level | Fields |
1279
+ |---|---|---|
1280
+ | `shutdown_draining` | info | `drainTimeoutMs`, `cleanupTimeoutMs` |
1281
+ | `shutdown_drain_deadline_exceeded` | error | `drainTimeoutMs` |
1282
+ | `shutdown_server_close_failed` | error | `err` |
1283
+ | `shutdown_cleanup_timed_out` | error | `cleanupTimeoutMs` |
1284
+ | `shutdown_cleanup_failed` | error | `err` |
1285
+ | `shutdown_complete` | info | `reason`, `drain`, `exitCode` |
1286
+
1287
+ `err` is the `loggableError` projection. `drain` is how the drain ended: `drained`, `drain-timeout` or `close-failed` (a listener that did not come down is not a clean drain, and exits non-zero). `reason` is what decided `exitCode`: the drain's outcome, or `cleanup-timeout` / `cleanup-failed` when the cleanup then timed out or threw, so `reason: "drained"` never sits beside a non-zero code.
834
1288
 
835
1289
  **Size `drainTimeoutMs` and `cleanupTimeoutMs` together below your orchestrator's kill grace period.** Kubernetes `terminationGracePeriodSeconds` is 30s by default and compose's `stop_grace_period` is 10s; with federation grants on, the worst case is drain (10s) + cleanup (45s) = 55s, so **set the grace to 60s or more** — the shipped compose files do, and a Kubernetes deployment must set `terminationGracePeriodSeconds: 60` itself, or a rolling restart SIGKILLs the process in the middle of the write the cleanup exists to finish. The point is to close on your terms before `SIGKILL` arrives on someone else's.
836
1290
 
@@ -839,9 +1293,9 @@ installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
839
1293
  | Script | Description |
840
1294
  |---|---|
841
1295
  | `pnpm run build` | Compile TypeScript to `dist/` |
842
- | `pnpm start` | Start the compiled server |
1296
+ | `pnpm run start` | Start the compiled server |
843
1297
  | `pnpm run debug` | Start with hot reload via `tsx watch` (development mode) |
844
- | `pnpm test` | Run tests with Vitest |
1298
+ | `pnpm run test` | Typecheck (`tsc --noEmit`), then run the tests with Vitest |
845
1299
 
846
1300
  ## See Also
847
1301
 
@@ -857,8 +1311,9 @@ installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
857
1311
  ### Logging
858
1312
 
859
1313
  The provider logs newline-delimited JSON on stdout via [pino](https://getpino.io),
860
- which is what a log aggregator ingests without a parser. `LOG_LEVEL` (HOCON
861
- `logging.level`) sets the threshold; `trace` and `debug` carry request-shaped
1314
+ which is what a log aggregator ingests without a parser. `LOGGING_LEVEL` (HOCON
1315
+ `logging.level`, the `logging` module's section, read before boot with that
1316
+ module's schema) sets the threshold; `trace` and `debug` carry request-shaped
862
1317
  detail and are off by default, and pino drops sub-threshold calls before
863
1318
  formatting, so they cost nothing in production rather than being emitted and
864
1319
  filtered downstream.
@@ -866,7 +1321,7 @@ filtered downstream.
866
1321
  The logger is wired into the boot planner's `logger` component slot, so every
867
1322
  module that declares `optional: ["logger"]` — oauth, session, dpop, mtls,
868
1323
  token-exchange — logs through this one instance. Filling that slot is what makes
869
- `LOG_LEVEL` reach them; a composition root that leaves it empty gets each
1324
+ `LOGGING_LEVEL` reach them; a composition root that leaves it empty gets each
870
1325
  module's own `consoleLogger` default instead, and configuring a level changes
871
1326
  nothing.
872
1327
 
@@ -881,7 +1336,7 @@ shape, so a pino-compatible logger needs no adapter.
881
1336
 
882
1337
  Security-relevant events — who authenticated, what was issued, what was refused,
883
1338
  and when the rate limiter could not answer — go to the sink named by
884
- `audit.sink.type` (env `AUDIT_SINK_TYPE`). The template ships `"logger"`, which
1339
+ `adapters.auditSink` (env `ADAPTERS_AUDIT_SINK`). The template ships `"logger"`, which
885
1340
  writes one event per line through the same pino stream as everything else:
886
1341
 
887
1342
  ```json
@@ -894,40 +1349,46 @@ The event type is also the message, so alert on the name — `authorize.rejected
894
1349
  `token.issued.failure`, `rate_limit.unavailable` — the same way you would on
895
1350
  `session_store_redis_error`.
896
1351
 
897
- `AUDIT_SINK_TYPE=console` selects core's built-in sink instead: the bare event
1352
+ `ADAPTERS_AUDIT_SINK=console` selects core's built-in sink instead: the bare event
898
1353
  as one JSON object per line, with no log envelope, for a pipeline that wants the
899
1354
  event and nothing else.
900
1355
 
901
- **`LOG_LEVEL` does not gate the audit trail.** The audit stream's level is fixed
1356
+ **`LOGGING_LEVEL` does not gate the audit trail.** The audit stream's level is fixed
902
1357
  at `info`. `warn` is an ordinary production setting and `silent` a legitimate
903
1358
  one, and either silencing this would delete the record of every authentication
904
1359
  and issuance while looking like a logging preference. Where audit events go is
905
- chosen by `audit.sink.type` — and that selector has **no `"none"`**. An unknown
906
- type fails boot naming the sinks that are registered, rather than producing a
907
- deployment with no audit trail.
1360
+ chosen by `adapters.auditSink` — and that selector has **no `"none"`**. An unknown
1361
+ sink fails boot naming the sinks that are registered, rather than producing a
1362
+ deployment with no audit trail. A composition that installs no sink on purpose
1363
+ says so in core's own section, `core.declaredAbsent = ["auditSink"]`; this
1364
+ template always installs one.
908
1365
 
909
1366
  To point it at a real sink — SIEM, log pipeline, message bus — register a
910
- builder in `auditSinkModule` (`src/modules.mts`) and name it in config; its
911
- options ride along in the same block:
1367
+ builder in `auditSinkModuleFor` (`src/modules.mts`) and select it; its options
1368
+ are the `audit-sink` module's section, under the sink's name:
912
1369
 
913
1370
  ```ts
914
1371
  factory.register("splunk-hec", (cfg) => createSplunkSink(cfg));
915
1372
  ```
916
1373
 
917
1374
  ```hocon
918
- audit {
919
- sink {
920
- type = "splunk-hec"
921
- splunk-hec { endpoint = ${?SPLUNK_HEC_URL}, token = ${?SPLUNK_HEC_TOKEN} }
922
- }
1375
+ adapters.auditSink = "splunk-hec"
1376
+ audit-sink {
1377
+ splunk-hec { endpoint = ${?SPLUNK_HEC_URL}, token = ${?SPLUNK_HEC_TOKEN} }
923
1378
  }
924
1379
  ```
925
1380
 
1381
+ To add an audit destination beside the configured one, contribute
1382
+ `auditHooks` from your own module; core fans out `auditSink` and `auditHooks`
1383
+ together.
1384
+
926
1385
  Sinks are fire-and-forget by contract: core dispatches without awaiting and
927
1386
  swallows rejections, so a slow or failing sink cannot add latency to — or
928
1387
  fail — an auth flow. The flip side is that a sink which cannot deliver drops
929
- events silently, and those drops are not counted yet — see **Not published
930
- yet** under Metrics below.
1388
+ the event. On its own a sink's failure is swallowed silently; once a module
1389
+ contributes `auditHooks`, the fan-out logs each failing call at error as
1390
+ `audit_sink_failed`, with the sink's position and the event's `type`. The
1391
+ drops are not counted yet — see **Not published yet** under Metrics below.
931
1392
 
932
1393
  ### Metrics
933
1394