@o3co/create-auth-provider 0.17.0 → 0.18.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 (114) hide show
  1. package/README.md +2 -198
  2. package/main.js +4 -0
  3. package/package.json +8 -30
  4. package/README.ja.md +0 -200
  5. package/dist/cli.d.mts +0 -3
  6. package/dist/cli.d.mts.map +0 -1
  7. package/dist/cli.mjs +0 -18
  8. package/dist/index.d.mts +0 -48
  9. package/dist/index.d.mts.map +0 -1
  10. package/dist/index.mjs +0 -325
  11. package/dist/internal/template-filter.d.mts +0 -14
  12. package/dist/internal/template-filter.d.mts.map +0 -1
  13. package/dist/internal/template-filter.mjs +0 -42
  14. package/templates/standalone/.dockerignore +0 -23
  15. package/templates/standalone/.env.example +0 -152
  16. package/templates/standalone/Dockerfile +0 -125
  17. package/templates/standalone/Makefile +0 -12
  18. package/templates/standalone/README.ja.md +0 -912
  19. package/templates/standalone/README.md +0 -1536
  20. package/templates/standalone/config/application.conf +0 -351
  21. package/templates/standalone/config/clients.yaml +0 -4
  22. package/templates/standalone/config/clients.yaml.example +0 -124
  23. package/templates/standalone/config/development.conf +0 -17
  24. package/templates/standalone/config/mailpit.conf +0 -13
  25. package/templates/standalone/config/production.conf +0 -15
  26. package/templates/standalone/config/reference.conf +0 -427
  27. package/templates/standalone/docker-compose.mailpit.yml +0 -39
  28. package/templates/standalone/docker-compose.production.yml +0 -229
  29. package/templates/standalone/docker-compose.test.yml +0 -16
  30. package/templates/standalone/docker-compose.yml +0 -67
  31. package/templates/standalone/gitignore +0 -23
  32. package/templates/standalone/package.json +0 -56
  33. package/templates/standalone/src/__tests__/adapters.test.mts +0 -425
  34. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +0 -87
  35. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +0 -1188
  36. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +0 -285
  37. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +0 -1154
  38. package/templates/standalone/src/__tests__/audit-sink.test.mts +0 -355
  39. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +0 -64
  40. package/templates/standalone/src/__tests__/config-notices.test.mts +0 -157
  41. package/templates/standalone/src/__tests__/configPath.test.mts +0 -69
  42. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +0 -48
  43. package/templates/standalone/src/__tests__/core-federations.test.mts +0 -252
  44. package/templates/standalone/src/__tests__/core-sections.test.mts +0 -192
  45. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +0 -95
  46. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +0 -1291
  47. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +0 -787
  48. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +0 -464
  49. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +0 -141
  50. package/templates/standalone/src/__tests__/federation-types.test.mts +0 -239
  51. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +0 -168
  52. package/templates/standalone/src/__tests__/google-session.fixture.mts +0 -318
  53. package/templates/standalone/src/__tests__/host-routes.test.mts +0 -134
  54. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +0 -29
  55. package/templates/standalone/src/__tests__/library-references.fixture.mts +0 -188
  56. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +0 -217
  57. package/templates/standalone/src/__tests__/listen.test.mts +0 -121
  58. package/templates/standalone/src/__tests__/logger-projection.test.mts +0 -77
  59. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +0 -113
  60. package/templates/standalone/src/__tests__/mail-sender.test.mts +0 -134
  61. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -393
  62. package/templates/standalone/src/__tests__/metrics.test.mts +0 -253
  63. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +0 -122
  64. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +0 -127
  65. package/templates/standalone/src/__tests__/mfa-switch.test.mts +0 -883
  66. package/templates/standalone/src/__tests__/own-modules.test.mts +0 -1480
  67. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +0 -183
  68. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +0 -283
  69. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +0 -135
  70. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +0 -214
  71. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +0 -98
  72. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +0 -249
  73. package/templates/standalone/src/__tests__/replica-safety.test.mts +0 -657
  74. package/templates/standalone/src/__tests__/repositories-logger.test.mts +0 -119
  75. package/templates/standalone/src/__tests__/router-error-answers.test.mts +0 -146
  76. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +0 -610
  77. package/templates/standalone/src/__tests__/sections.test.mts +0 -516
  78. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +0 -213
  79. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +0 -305
  80. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +0 -130
  81. package/templates/standalone/src/__tests__/session-requirements.test.mts +0 -382
  82. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +0 -274
  83. package/templates/standalone/src/__tests__/shutdown.test.mts +0 -622
  84. package/templates/standalone/src/__tests__/smoke.test.mts +0 -642
  85. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +0 -164
  86. package/templates/standalone/src/__tests__/store-credential.test.mts +0 -314
  87. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +0 -296
  88. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +0 -265
  89. package/templates/standalone/src/__tests__/two-phase-config.test.mts +0 -605
  90. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +0 -385
  91. package/templates/standalone/src/adapters.mts +0 -162
  92. package/templates/standalone/src/app.mts +0 -127
  93. package/templates/standalone/src/bootRefusal.mts +0 -117
  94. package/templates/standalone/src/buildModules.mts +0 -358
  95. package/templates/standalone/src/configPath.mts +0 -487
  96. package/templates/standalone/src/listen.mts +0 -60
  97. package/templates/standalone/src/logger.mts +0 -80
  98. package/templates/standalone/src/metrics.mts +0 -167
  99. package/templates/standalone/src/mfaSwitch.mts +0 -284
  100. package/templates/standalone/src/modules.mts +0 -705
  101. package/templates/standalone/src/rootRenames.mts +0 -95
  102. package/templates/standalone/src/routes.mts +0 -89
  103. package/templates/standalone/src/sections.mts +0 -378
  104. package/templates/standalone/src/securityHeaders.mts +0 -36
  105. package/templates/standalone/src/shutdown.mts +0 -213
  106. package/templates/standalone/tests/Makefile +0 -5
  107. package/templates/standalone/tests/index.test.js +0 -283
  108. package/templates/standalone/tests/package-lock.json +0 -1617
  109. package/templates/standalone/tests/package.json +0 -16
  110. package/templates/standalone/tests/vitest.config.mjs +0 -26
  111. package/templates/standalone/tsconfig.json +0 -31
  112. package/templates/standalone/vitest.config.mts +0 -31
  113. package/templates/standalone/vitest.supertest-loopback.mts +0 -278
  114. package/templates/versions.json +0 -20
@@ -1,1536 +0,0 @@
1
- # @o3co/auth-provider-standalone
2
-
3
- Last updated: 2026-10-07
4
-
5
- Deployable server template for auth.provider. This is the composition root — it reads config, loads modules, and starts the Express server. Generated by `@o3co/create-auth-provider`.
6
-
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 —
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
-
76
- ## Requirements
77
-
78
- - **Node.js** `>=22.0.0`
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.
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`.
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`).
82
-
83
- ## First-party clients
84
-
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.
86
-
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.
88
-
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**.
90
-
91
- ```yaml
92
- # config/clients.yaml
93
- my-app:
94
- tokenEndpointAuthMethod: "client_secret_basic"
95
- firstParty: true
96
- allowedRedirectUris: ["https://app.example/callback"]
97
- ```
98
-
99
- A machine client can authenticate with a key instead of a shared secret (#484):
100
- `tokenEndpointAuthMethod: "private_key_jwt"` plus `jwks` (inline public keys) or
101
- `jwksUri`, and it presents a signed assertion at `/oauth/token` — see the
102
- [oauth package README](../../packages/oauth/README.md#client-authentication-private_key_jwt-rfc-7523-22).
103
- Nothing secret lives in `clients.yaml` for such a client.
104
-
105
- **What this does not do.** It does not make `/authorize` safe against forced navigation for a client that *is* first-party — that remains the accepted model here. What it prevents is a client that should never have been trusted with a silent code being registered into that position by accident.
106
-
107
- **Unmarked registrations.** A registration without the field is not first-party: on the defaults it is refused at `/authorize` until you mark it, with no admit-with-warning window. Mark every client you operate before pointing it at `/authorize`. `oauth.authorize.allowUnmarkedClients` is not a setting: a config or environment that sets it (`OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS`, any value) fails at boot rather than being silently ignored: the oauth module refuses the key as removed (`config-path-relocated`), and the variable as one bound to a removed key (`environment-variable-renamed`). Mark your clients, then delete the key and unset the variable.
108
-
109
- ## Multi-replica deployments
110
-
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.
112
-
113
- When running multiple instances of the standalone server behind a load balancer, point `REDIS_CLIENTS_URL` at a **shared** Redis 7.2+ instance. Without a shared Redis URL, every replica holds refresh-token families in its own connection (and any local-only Redis), so a refresh request that lands on a replica that did not issue the token returns `invalid_grant` on every other request in a round-robin LB.
114
-
115
- **That Redis must run `maxmemory-policy noeviction`** (Redis's default). The Redis stores whose keys must stay until they expire — the attempt counter, the user-session stores' lifecycle store, the federation token store, the MFA stores, the stores that hold revocation state (the access-token denylist, the subject revocation store and the refresh-token family store) and the replay seen-set — read the policy at boot (`INFO memory`, then `CONFIG GET maxmemory-policy`), and refuse to boot on any other policy or on one the server will not report. If the connection's user may not run `INFO` or `CONFIG` (an ACL-restricted user, or a managed service that renames them) and you know the server runs `noeviction`, set `REDIS_CLIENTS_ASSUME_NO_EVICTION=true` (`redis-clients.assumeNoEviction`, default `false`). It is read only while the policy cannot be read: a policy the server reports always takes precedence, so it never lets an `allkeys-*` or `volatile-*` server through. Since those stores ask the server when they are built, and the refresh-token family store is always on Redis and the access-token denylist and the replay seen-set are by default, a boot with Redis not yet started or unreachable fails — even with every other `adapters` selection on memory: start Redis before the provider.
116
-
117
- **Set `HTTP_TRUST_PROXY` to your proxy's address behind a load balancer.** It defaults to `false`, which makes `req.ip` the *load balancer's* address rather than the client's. Every rate limit keyed by IP — the OAuth endpoint limiters and the `POST /session/login` brute-force guard — then shares **one bucket across all users**: the first 20 login attempts from anyone exhaust the window and every subsequent user gets `429`. The failure reads like an attack rather than a misconfiguration, which is what makes it expensive to diagnose at 3am. Set it whenever anything terminates TLS or proxies in front of this service, and make sure that hop is the one setting `X-Forwarded-For`. An audit event with no `ip` means the request's address was not an address — `req.ip` came from an `X-Forwarded-For` that held something else — so check that `HTTP_TRUST_PROXY` trusts only the hop that sets the header.
118
-
119
- It takes four shapes, all of them Express's own:
120
-
121
- | Value | Meaning |
122
- |---|---|
123
- | `false` (default) | Trust nothing; `req.ip` is the socket peer |
124
- | `10.0.0.0/8,192.168.0.0/16` | Trust exactly these hops. IP literals, CIDR ranges, or the named ranges `loopback` / `linklocal` / `uniquelocal`. Comma-separate for the env var |
125
- | `1` | Trust that many hops back from the socket peer |
126
- | `true` | Trust every hop — the leftmost `X-Forwarded-For` entry, from whoever opened the connection |
127
-
128
- **Prefer the address list.** `true` believes a forwarded client address from anyone who can reach this process; a client that routes to it directly, or through one hop more than you accounted for, then chooses its own `req.ip` and with it any rate-limit identity keyed on it. Entries are validated at boot, so a hostname or a typo'd range fails loudly instead of becoming a rule that never matches.
129
-
130
- An allowlist is a network control, not a cryptographic one. The edge must also **strip** inbound `X-Forwarded-*` headers rather than appending to them, and the hop between it and this process must not be reachable by anyone able to spoof a source address.
131
-
132
- **Point `adapters.rateLimiter` at Redis.** It defaults to `"memory"`, which is per-process: with N replicas every configured limit is effectively N times larger and resets on every deploy. The memory adapter is also **evadable under bucket exhaustion**: it caps itself at 10,000 buckets and, at the cap, admitting a new key evicts the bucket closest to reset — so an attacker who can present many source IPs (and `req.ip` is client-influenced whenever `HTTP_TRUST_PROXY` is broader than your actual hops) can churn the table until a target's counter is evicted and starts over. That is acceptable for one dev process; it is not a production rate limit. `/session/login` does not run on this component, nor does `POST /oauth/device/verification` once you add the device grant: each one's own attempt limit (`session.rateLimit.login`, `device-grant.rateLimit`) is counted on the attempt counter, and no limiter setting changes it.
133
-
134
- **The MFA routes' budget is the limiter's.** The MFA package ships no budget for its routes: every `/session/mfa` POST is limited per client address under the prefix `mfa` by the wired limiter alone. `config/reference.conf` gives both limiters `limits.mfa { limit = 60, windowSeconds = 300 }` (`core-rate-limiter-memory.limits.mfa`, `redis-rate-limiter.limits.mfa`), so switching `adapters.rateLimiter` keeps it. It is a default there, so the limiter the composition does not wire carries it without boot naming its section; change it for the wired limiter in `config/application.conf` or a layer of your own. Without an entry the limiter's `defaultLimit`, 60 per 60 s, applies. The MFA lock — attempts per transaction, the backoff, the weekly failures — is the MFA package's own and no limiter setting changes it.
135
-
136
- **Point `adapters.attemptCounter` at Redis.** It defaults to `"memory"`, which wires no counter: the login — and device verification, when you add the device grant — counts its attempts per process, so with N replicas `session.rateLimit.login` (and `device-grant.rateLimit`) allows N times its limit and resets on every deploy, and `CORE_DEPLOYMENT_MODE=multi` refuses the boot. `"redis"` (`ADAPTERS_ATTEMPT_COUNTER=redis`) installs `redisAttemptCounterModule` on the shared Redis socket. Its Redis must run `maxmemory-policy noeviction` (Redis's default): an evicted window would start the count over. The module refuses to boot on any other policy. It also refuses on a policy the server will not report, unless `REDIS_CLIENTS_ASSUME_NO_EVICTION=true` (above). Both fail closed: while the counter cannot answer, `/session/login` and `POST /oauth/device/verification` answer `503`, whatever `redis-rate-limiter.failMode` says.
137
-
138
- **Behind a BFF, raise `limits.token` before you need to.** The OAuth-endpoint
139
- rate limits key on `req.ip` (`packages/core/src/ratelimit/guard.mts` builds the
140
- bucket key as `<endpoint>:ip:<req.ip>`), which is the right identity when the
141
- client is the browser or a native app talking to this provider directly. It is
142
- the wrong one in a backend-for-frontend topology, where a server-side app holds
143
- the session and exchanges codes and refreshes on its users' behalf: every
144
- `/oauth/token` and `/oauth/introspect` call then arrives from the BFF's single
145
- address and the whole deployment shares one bucket. At the default 60 requests
146
- per 60 s that caps you at roughly **60 session-grant exchanges a minute across
147
- all users** — and it does not surface as a rate limit. The BFF gets a `429` it
148
- was not written to expect, turns it into a `502` for its own caller, and the
149
- symptom your users report is "sign-in is broken sometimes", at the traffic
150
- level where it starts happening and not before.
151
-
152
- `HTTP_TRUST_PROXY` does not fix this and is not meant to. It teaches `req.ip`
153
- to see through a *proxy* — a hop that forwards someone else's request and says
154
- so in `X-Forwarded-For`. A BFF is not forwarding anyone; it is the client, and
155
- its address is the honest answer to "who called". There is no per-user identity
156
- in these requests to key on.
157
-
158
- The knob is the per-endpoint budget. Both limiter adapters take
159
- `limits { <prefix> { limit, windowSeconds } }` in their own sections, keyed by
160
- the endpoint prefix:
161
-
162
- ```hocon
163
- redis-rate-limiter {
164
- limits {
165
- token { limit = 600, windowSeconds = 60 }
166
- introspect { limit = 600, windowSeconds = 60 }
167
- }
168
- }
169
- ```
170
-
171
- (`core-rate-limiter-memory.limits` for the memory adapter, same shape.) Size it from
172
- your BFF's peak sign-ins and refreshes per minute with headroom, and remember
173
- that under `adapters.rateLimiter = "redis"` the budget is shared across replicas
174
- — which is what you want here, and what makes the number mean something.
175
-
176
- The defaults are deliberately left alone: 60/60 s per source IP is a sensible
177
- brute-force bound for a deployment whose clients really are distinct IPs, and
178
- raising it globally to accommodate one topology would weaken it for the other.
179
- Put the throttling that protects the BFF's *own* users in front of the BFF,
180
- where per-user identity still exists.
181
-
182
- **Set `CORE_DEPLOYMENT_MODE=multi` once you run more than one replica.** Boot then *fails* if any in-memory store that has to be shared is still wired, naming every offender and what it costs — user sessions forking (back-channel logout reaches one replica, a logged-out session stays valid on the others), rate-limit counters multiplying, access-token revocation not propagating, a single-use client assertion or WebAuthn challenge replayable once per replica. The check reads the declaration each installed module carries on its own manifest rather than a list of library module names, so this template's own in-memory modules — the user-session stores (`ADAPTERS_USER_SESSION_STORES=memory`), the authorization-code repository (`ADAPTERS_CODE_REPOSITORY=memory`) and the federation token store (`ADAPTERS_FEDERATION_TOKEN_STORE=memory`, the default) — are refused by name too. So are express-session's own store under `SESSION_STORE_STORAGE_TYPE=memory` (#474) and the default memory rate limiter (`core-rate-limiter-memory`, `ADAPTERS_RATE_LIMITER=memory`). With the mode unset nothing is refused: every one of them is listed in a single `replica_unsafe_adapters` warning at boot. (The WebAuthn-options route carries a per-process fallback limiter that warns on its own, but only in a composition that wires no `rateLimiter` at all, which this template never does. The login counts per process under `ADAPTERS_ATTEMPT_COUNTER=memory`: `multi` refuses the boot, naming the login, and an unset mode warns `attempt_counter_not_shared`; see the operator runbook.) With `CORE_DEPLOYMENT_MODE=single` the check is silent, because you have said there is one replica. This template does not install DPoP; a composition that adds it records every accepted proof in the same replay seen-set as `private_key_jwt` (`ADAPTERS_REPLAY_SEEN_SET`), so it gets the same answer — `memory` is refused under `CORE_DEPLOYMENT_MODE=multi` and listed in the unset-mode warning, and the shipped `redis` shares DPoP's records across replicas — see the dpop package's [operator requirements](../../packages/dpop/README.md#operator-requirements).
183
-
184
- The variable sets `core.deployment.mode`. `DEPLOYMENT_MODE`, its old name, refuses boot while it is set, alone or beside `CORE_DEPLOYMENT_MODE` at any value: unset it.
185
-
186
- Be aware of what this check *cannot* do: if you scale to N replicas without ever setting `CORE_DEPLOYMENT_MODE`, nothing fails. A process holding all its state in its own memory has no shared medium through which to notice peers — the condition is undetectable from inside exactly when it is true. Set the variable as part of scaling, not after something breaks.
187
-
188
- **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.**
189
-
190
- Other multi-replica considerations covered by the default modules:
191
-
192
- - 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.
193
- - 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.
194
- - 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)).
195
- - 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.
196
- - 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).
197
- - 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).
198
- - 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).
199
-
200
- ## Usage
201
-
202
- `pnpm run debug` runs `src/app.mts` under `tsx watch` on this machine. It reads
203
- no `.env` file — its configuration is `config/` plus the environment of the
204
- shell that starts it — and it does not boot until that environment supplies:
205
-
206
- - `OAUTH_JWT_ISSUER` — this server's origin (`http` is accepted for a loopback
207
- host);
208
- - a signing key pair, `KEY_STORE_LOCAL_PRIVATE_KEY_PATH` / `KEY_STORE_LOCAL_PUBLIC_KEY_PATH`
209
- (see [OAuth JWT](#oauth-jwt));
210
- - `SESSION_STORE_SECRET`, at least 32 bytes;
211
- - `REPOSITORIES_USER_HTTP_AUTHENTICATE_URL` and `REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL` —
212
- your user service ("the Store"), `https`, or `http` on a loopback host. Boot
213
- checks their form, not that anything answers; logins fail until something
214
- does (see [User Repository](#user-repository));
215
- - a Redis it can reach at `redis://localhost:6379`. The shipped configuration
216
- keeps the browser session store there (`SESSION_STORE_STORAGE_REDIS_URL`), and
217
- opens one shared connection (`REDIS_CLIENTS_URL`) for the
218
- refresh-token families, the authorization codes, the access-token denylist
219
- and the replay seen-set — and for the user-session stores, with the setting
220
- below.
221
-
222
- Plain HTTP also needs `SESSION_STORE_SECURE=false` and a session cookie name without
223
- the `__Host-` prefix (see [Session](#session)). And set
224
- `ADAPTERS_USER_SESSION_STORES=redis`: the browser session store is in Redis, the
225
- user-session stores default to memory, and `tsx watch` restarts the process on
226
- every save — split, a restart leaves the browser's session pointing at a
227
- `UserSession` that is gone, and `/authorize` loops (see [Docker](#docker)).
228
- MFA is on by default, and its two stores default to memory too: set
229
- `ADAPTERS_MFA_FACTOR_STORE=redis` and `ADAPTERS_MFA_TRANSACTION_STORE=redis`
230
- for the same reason, or every save empties them while the sessions survive —
231
- or `MFA_MODE=off` to run without MFA (see
232
- [Multi-factor authentication](#multi-factor-authentication)).
233
-
234
- It also reads the client registry, `config/clients.yaml` (`REPOSITORIES_CLIENT_YAML_PATH`). That
235
- file is per-deployment: the scaffold creates an empty one, and `.gitignore`
236
- keeps it out of the project's repository, so a fresh clone has none and boot
237
- fails naming the path until you create it — from `config/clients.yaml.example`,
238
- or empty for a registry with no clients.
239
-
240
- The key pair below is generated with Node, which every machine running this
241
- already has. `openssl genpkey -algorithm ed25519` works too, with OpenSSL 1.1.1
242
- or later — not with the LibreSSL that macOS ships as `/usr/bin/openssl`.
243
-
244
- ```bash
245
- pnpm install
246
-
247
- # A signing key pair (Ed25519) and a local Redis
248
- node -e '
249
- const { generateKeyPairSync } = require("node:crypto");
250
- const fs = require("node:fs");
251
- const { privateKey, publicKey } = generateKeyPairSync("ed25519");
252
- fs.writeFileSync("jwt-private.pem", privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 });
253
- fs.writeFileSync("jwt-public.pem", publicKey.export({ type: "spki", format: "pem" }));
254
- '
255
- docker run -d --name auth-redis -p 127.0.0.1:6379:6379 redis:7.2-alpine
256
-
257
- # What boot requires (replace the user-service URLs with your Store's)
258
- export OAUTH_JWT_ISSUER=http://localhost:3000 \
259
- KEY_STORE_LOCAL_PRIVATE_KEY_PATH=./jwt-private.pem \
260
- KEY_STORE_LOCAL_PUBLIC_KEY_PATH=./jwt-public.pem \
261
- SESSION_STORE_SECRET="$(openssl rand -hex 32)" \
262
- SESSION_STORE_SECURE=false SESSION_STORE_NAME=auth.sid \
263
- ADAPTERS_USER_SESSION_STORES=redis \
264
- ADAPTERS_MFA_FACTOR_STORE=redis ADAPTERS_MFA_TRANSACTION_STORE=redis \
265
- REPOSITORIES_USER_HTTP_AUTHENTICATE_URL=http://localhost:8080/authenticate \
266
- REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL=http://localhost:8080/authenticate-by-token
267
-
268
- # Start in development mode (hot reload)
269
- pnpm run debug
270
-
271
- # Build, then run the compiled server (CONFIG_ENV or NODE_ENV selects config/<env>.conf)
272
- pnpm run build
273
- pnpm run start
274
- ```
275
-
276
- `.env` is read only by the compose files (`env_file`). `make dev`
277
- (`docker compose up --build`) runs the same debug server in a container beside
278
- a Redis service, with its environment from `.env` — copied from
279
- `.env.example`, whose Redis URLs name that service. Values there are resolved
280
- inside the container, which holds no key files: only `src/`, `config/`, the
281
- package manifests and what installing and building them produced. A key path or
282
- a `localhost` URL that works on the host does not work there. The deployable
283
- image and its compose file are under [Docker](#docker).
284
-
285
- ## Configuration
286
-
287
- 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.
288
-
289
- ### Environment-specific config overlay
290
-
291
- `src/app.mts` loads configuration in layers, highest precedence first:
292
-
293
- 1. **`config/{ENV}.conf`** — overlay for the current environment, where
294
- `ENV = CONFIG_ENV || NODE_ENV || "development"`.
295
- 2. **`config/application.conf`** — this deployment's settings.
296
- 3. **the `reference.conf` of each package the loaded modules come from**, then
297
- **`@o3co/auth-provider-core`'s** — the defaults, resolved from the
298
- installed packages: each module declares its package's file, and core's
299
- `moduleReferences(modules)` lists them, core's last. The template's own
300
- modules declare `config/reference.conf`, which holds the defaults of the
301
- sections they own — `logging`, `http` (with its CORS list,
302
- `http.cors`), `key-store`, the shared Redis connection's `redis-clients`,
303
- `repositories`, the in-process code repository's
304
- `standalone-in-memory-code-repository` and `audit-sink` — and of the
305
- composition root's own `adapters` and `mfaMode` (below), and the MFA
306
- routes' budget in both rate limiters' sections
307
- (`core-rate-limiter-memory.limits.mfa`, `redis-rate-limiter.limits.mfa`;
308
- see [Multi-replica deployments](#multi-replica-deployments)). Set a deployment's own value in
309
- the two files above, not there. `config/reference.conf` binds each of these
310
- keys' variables beside its default, so a value either file above sets wins
311
- over the variable, except for `HTTP_PORT`, `HTTP_TRUST_PROXY`,
312
- `HTTP_CORS_ALLOWED_ORIGINS` and `REDIS_CLIENTS_URL` /
313
- `REDIS_CLIENTS_PASSWORD`, which `application.conf` binds again in its last
314
- lines, so each of those wins over a value that file sets above them. A key
315
- neither file above sets takes its value from here.
316
-
317
- It reads the two files above once, under one snapshot of the environment
318
- (`readOwnLayers`), and builds two phases from that one read
319
- ([#728](https://github.com/o3co/auth.provider/issues/728);
320
- [`src/configPath.mts`](src/configPath.mts)) — so a file replaced, or a
321
- variable changed, while the process starts cannot make boot parse something
322
- other than what the modules were chosen by. First, before it knows its
323
- modules, it reads what `buildModules` chooses them by — never a federation
324
- entry — from the two files above over the template's `config/reference.conf`
325
- alone (`readSwitches`). It reads `adapters` — which adapter fills each slot,
326
- the composition root's own section — with the template's own schema
327
- (`readAdapters`): a selection still written at the path it moved from, or a
328
- variable renamed with it, is refused there, before any module is chosen,
329
- naming the new path and variable. It reads the MFA switch `mfaMode`
330
- (`MFA_MODE`), the composition root's own key, the same way, with the
331
- template's own schema (`readMfaSwitch`), and beside them the user
332
- repository's HTTP settings, unparsed, which the Store-backed MFA factor store
333
- is built over. It reads whether federation grants are installed from
334
- `federation-grants.enabled` (`FEDERATION_GRANTS_ENABLED`, which
335
- `application.conf` binds for this), with core's `coerceBooleanFromEnv`: a
336
- value that does not read as `false` installs the federation-grants modules,
337
- so that boot refuses a bad value at `federation-grants.enabled` rather than
338
- the feature reading as off, and a setting at the section's old path,
339
- `federationGrants`, installs them too, for boot to refuse naming the new
340
- path. Boot is not handed `adapters` or `mfaMode`. Phase one reads no other
341
- key, and sees nothing a package's `reference.conf` alone sets — none is
342
- layered yet. A module you add to `buildModules` reads its own section at
343
- boot (`deps.section`), never when it is built. The log level is read before boot too, as
344
- the `logging` module's section, with that module's schema, over the template's
345
- `reference.conf` (`readLogging`): the logger exists before boot, since the
346
- template logs while it reads its configuration and chooses its modules.
347
- What the template refuses of the configuration before boot — `adapters`,
348
- `mfaMode` and `logging` in phase one, and what it builds for boot below
349
- (`resolveForBoot`) — is a `BootError` under the reason boot raises for the
350
- same case (`config-path-relocated`, `environment-variable-renamed`,
351
- `config-validation-failed`, `module-section-path-invalid`;
352
- [`src/bootRefusal.mts`](src/bootRefusal.mts)), so an alert on a boot error's
353
- reason sees it too. Not every failure to start carries a reason: a
354
- `CONFIG_ENV` naming a file outside `config/` is a plain `Error`, a missing
355
- `{ENV}.conf` or a file HOCON cannot parse is the HOCON library's error, and
356
- a failure after boot, such as a port the listener cannot bind, is its own. Then it hands `createApp` the configuration as resolved over every
357
- loaded module's `reference.conf` (`resolveForBoot`), unparsed, with
358
- `core.sessionRequirements` as the MFA switch expects it written in — the
359
- configuration's list with `mfa` added when the switch installs MFA; a value
360
- that is not a list of names is handed on as written, for boot to refuse — and
361
- the `mfa` section as the MFA switch decides it (see [Multi-factor authentication](#multi-factor-authentication)):
362
- boot parses it once, with every loaded module's schema. What
363
- the template reads after boot — the trusted hops, the port, the readiness
364
- deadline — it reads from the `http` module: core's `httpSettings` slot and the
365
- template's `httpHostSettings`. Beside it, the template hands boot the
366
- configuration's defaults (`configDefaultsFor`): the same `reference.conf`
367
- files with neither of the two files above and no environment. Boot names a
368
- top-level section no loaded module owns by them, once, at warn, and keeps it:
369
- a section a loaded package's `reference.conf` sets for a module the
370
- composition does not load (`redis-clients` with no store on Redis, say) is
371
- silent while it is as that file sets it, and `config_sections_not_loaded`
372
- once your files or a variable change it — the setting reaches nothing; a
373
- section no loaded package sets is `config_sections_ignored`, which is where a
374
- misspelt section name shows. A renamed variable a loaded package's
375
- `reference.conf` captures and no loaded module declares is named
376
- `environment_variables_not_applied`. The [operator
377
- runbook](../../docs/operator-runbook.md) has a row for each.
378
-
379
- Values in the overlay take precedence over `application.conf`. The scaffold
380
- ships with `development.conf`, `production.conf` and `mailpit.conf` (the
381
- development overlay under the name the
382
- [Mailpit overlay](#mailpit-for-the-smtp-sender-development-only) selects). To add another
383
- environment (e.g. `staging`), create `config/staging.conf` and set
384
- `CONFIG_ENV=staging`. A missing `{ENV}.conf` is a boot-time error — typos
385
- fail fast rather than silently falling back to defaults.
386
-
387
- ### Adapters
388
-
389
- Which adapter fills each slot is the composition root's own choice, its own
390
- section `adapters`, read before any module is chosen (above). Each key has its
391
- variable; a value the template does not know, or a key it does not declare,
392
- fails before boot, naming it.
393
-
394
- | Variable | Default | Description |
395
- |---|---|---|
396
- | `ADAPTERS_RATE_LIMITER` | `memory` | `adapters.rateLimiter`: `memory` or `redis`. See [Multi-replica deployments](#multi-replica-deployments) |
397
- | `ADAPTERS_ATTEMPT_COUNTER` | `memory` | `adapters.attemptCounter`: the counter behind the login's attempt limit (and device verification's, when the device grant is added), `memory` (none: per process) or `redis`. See [Multi-replica deployments](#multi-replica-deployments) |
398
- | `ADAPTERS_USER_SESSION_STORES` | `memory` | `adapters.userSessionStores`: the user-session stores, `memory` or `redis`. Keep it with `SESSION_STORE_STORAGE_TYPE` (see [Docker](#docker)) |
399
- | `ADAPTERS_ACCESS_TOKEN_DENYLIST` | `redis` | `adapters.accessTokenDenylist`: `memory` or `redis` |
400
- | `ADAPTERS_REPLAY_SEEN_SET` | `redis` | `adapters.replaySeenSet`: the replay seen-set behind `private_key_jwt`, `memory` or `redis` |
401
- | `ADAPTERS_CONSENT_STORE` | `none` | `adapters.consentStore`: `none`, `memory` or `redis`. See [Consent Store](#consent-store) |
402
- | `ADAPTERS_FEDERATION_TOKEN_STORE` | `memory` | `adapters.federationTokenStore`: `memory` or `redis`. See [Federation Token Store](#federation-token-store) |
403
- | `ADAPTERS_FEDERATION_GRANT_STORE` | `none` | `adapters.federationGrantStore`: `none`, `memory` or `redis`. See [Federation Grants](#federation-grants) |
404
- | `ADAPTERS_FEDERATION_GRANT_INTENT_STORE` | `none` | `adapters.federationGrantIntentStore`: `none`, `memory` or `redis`. See [Federation Grants](#federation-grants) |
405
- | `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) |
406
- | `ADAPTERS_MFA_TRANSACTION_STORE` | `memory` | `adapters.mfaTransactionStore`: `memory` (development and test only) or `redis`, read while `MFA_MODE` installs MFA |
407
- | `ADAPTERS_CODE_REPOSITORY` | `redis` | `adapters.codeRepository`: the authorization-code repository, `memory` or `redis`. See [Code Repository](#code-repository) |
408
- | `ADAPTERS_CLIENT_REPOSITORY` | `yaml` | `adapters.clientRepository`: `yaml`, or `static` (core's alias of `yaml`). See [Client Repository](#client-repository) |
409
- | `ADAPTERS_USER_REPOSITORY` | `http` | `adapters.userRepository`: `http`, `yaml` or `static` (core's alias of `yaml`). See [User Repository](#user-repository) |
410
- | `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) |
411
-
412
- A selection still written where it was — `rateLimiter.adapter`,
413
- `oauth.code.adapter`, `audit.sink.type`, `repositories.user.type` and the
414
- like — or a variable renamed with it (`RATE_LIMITER_ADAPTER`, `CLIENT_TYPE`,
415
- …) set, alone or beside the new one at any value, fails before boot, naming
416
- the new path and variable: unset the old variable.
417
-
418
- ### HTTP
419
-
420
- | Variable | Default | Description |
421
- |---|---|---|
422
- | `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 |
423
- | `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) |
424
- | `HTTP_READINESS_TIMEOUT_MS` | `1000` | Per-probe deadline for `/readyz` (see [Health endpoints](#health-endpoints)) |
425
- | `LOGGING_LEVEL` | `info` | Minimum level emitted: `trace`\|`debug`\|`info`\|`warn`\|`error`\|`fatal`\|`silent`. Does **not** gate the audit trail — see [Audit trail](#audit-trail) |
426
-
427
- ### OAuth JWT
428
-
429
- The signing key is the `key-store` module's section: `key-store.provider`
430
- (`KEY_STORE_PROVIDER`, `local`) and the local key store's `key-store.local`
431
- (`KEY_STORE_LOCAL_*`). The issuer and the token format stay under `oauth.jwt`.
432
-
433
- | Variable | Default | Description |
434
- |---|---|---|
435
- | `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. |
436
- | `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. |
437
- | `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. |
438
- | `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. |
439
- | `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`. |
440
- | `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. |
441
- | `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`. |
442
- | `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. |
443
- | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_DENIED_HOSTS` | — | Comma-separated hosts refused even when allowed above. |
444
- | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_BYTES` | `5120` | Byte cap on a document (the draft recommends 5 KB). |
445
- | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_TIMEOUT_MS` | `5000` | Fetch timeout. |
446
- | `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. |
447
- | `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. |
448
- | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_STALE_IF_ERROR_MS` | `0` | How long past its expiry 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 429, a timeout). `0`, the default, serves none: an expired document whose revalidation fails leaves the client unresolved (`invalid_client`) until a fetch succeeds again and for at least `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_NEGATIVE_CACHE_MS` after the failure. A window keeps a working client through an outage at its host, and honours the redirect allowlist last validated for that long. A document that was *rejected* is dropped immediately. |
449
- | `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. |
450
- | `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. |
451
- | `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) |
452
- | `KEY_STORE_LOCAL_PRIVATE_KEY` | — | PEM-encoded private key (asymmetric algorithms) |
453
- | `KEY_STORE_LOCAL_PRIVATE_KEY_PATH` | — | Path to PEM private key file |
454
- | `KEY_STORE_LOCAL_PUBLIC_KEY` | — | PEM-encoded public key |
455
- | `KEY_STORE_LOCAL_PUBLIC_KEY_PATH` | — | Path to PEM public key file |
456
- | `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. |
457
- | `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. |
458
-
459
- **Signing keys are required.** The default algorithm is `EdDSA` and there is no
460
- key-material default: a deployment that sets none fails at boot naming the keys
461
- to set, rather than quietly signing with something guessable. Generate an
462
- Ed25519 pair:
463
-
464
- ```bash
465
- openssl genpkey -algorithm ed25519 -out jwt-private.pem
466
- openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
467
- ```
468
-
469
- then point `KEY_STORE_LOCAL_PRIVATE_KEY_PATH` / `KEY_STORE_LOCAL_PUBLIC_KEY_PATH` at them
470
- (or inline the PEMs via `KEY_STORE_LOCAL_PRIVATE_KEY` / `KEY_STORE_LOCAL_PUBLIC_KEY`).
471
-
472
- `HS256` is still selectable (`KEY_STORE_LOCAL_ALGORITHM=HS256` plus a ≥32-byte
473
- `KEY_STORE_LOCAL_SECRET`), but understand what it costs: a symmetric key has no
474
- public half, so `/.well-known/jwks.json` answers `404 jwks_not_published` and
475
- every relying party must be handed the shared secret — which also lets it
476
- **mint** tokens, not merely verify them.
477
-
478
- ### Token Expiry
479
-
480
- | Variable | Default | Description |
481
- |---|---|---|
482
- | `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`). |
483
- | `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. |
484
- | `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | Refresh token lifetime in seconds. Whole positive number, at most one year (`31536000`). |
485
-
486
- Each is read only as decimal digits. Exporting one as an empty string is a
487
- boot failure, not a fallback (HOCON resolves `FOO=` to `""`), and so is a hex
488
- (`0x10`), exponent (`1e3`), sign (`+5`) or fraction (`5.0`).
489
-
490
- Only token exchange (RFC 8693) reads an `expires_in` request parameter; every
491
- other grant ignores it and mints the default.
492
-
493
- The default's old name, `OAUTH_ACCESS_TOKEN_EXPIRES_IN` (config key
494
- `oauth.accessToken.expiresIn`), refuses boot while it is set, alone or beside
495
- `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` at any value. The old key refuses boot at any value. Move the value to
496
- the new name rather than deleting it: without it, the default is `3600`.
497
-
498
- ### Grant Types
499
-
500
- | Variable | Default | Description |
501
- |---|---|---|
502
- | `OAUTH_SESSION_ENABLED` | `true` | Enable the session grant type |
503
- | `OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED` | `true` | Enable the authorization code grant type |
504
- | `OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED` | `true` | Enable the refresh token grant type |
505
- | `OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED` | `false` | Enable the client credentials grant type |
506
- | `OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED` | `false` | Enable the jwt-bearer grant type (RFC 7523) |
507
-
508
- Each switch is its module's key — `oauth-session.enabled`, and
509
- `oauth-authorization.grants.<grant>.enabled` for the others. Both modules are
510
- always loaded and read their switches at boot, from the configuration boot
511
- parses; the template reads none of them before boot. `OAUTH_GRANTS_<GRANT>_ENABLED`,
512
- the old names, refuse boot while they are set, alone or beside the new name at
513
- any value.
514
-
515
- ### Session
516
-
517
- | Variable | Default | Description |
518
- |---|---|---|
519
- | `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`. |
520
- | `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`. |
521
- | `SESSION_STORE_MAX_AGE` | `3600000` | Session cookie max age in milliseconds. Whole positive number, at most one year (`31536000000`). |
522
- | `SESSION_STORE_SECURE` | `true` | Set `Secure` flag on session cookie |
523
- | `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. |
524
- | `SESSION_STORE_DOMAIN` | — | Cookie domain (unset by default) |
525
- | `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. |
526
- | `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) |
527
- | `SESSION_STORE_STORAGE_REDIS_URL` | `redis://localhost:6379` | Redis connection URL for session storage |
528
- | `SESSION_STORE_STORAGE_REDIS_PASSWORD` | — | Redis password for session storage |
529
-
530
- The cookie and its store are the session store's section, `session-store`; the
531
- CSRF token's lifetime, the login page and the login's budget are the session
532
- module's, `session`. `SESSION_<KEY>`, the old names of the `SESSION_STORE_*`
533
- variables, refuse boot while they are set, alone or beside the new name at any
534
- value.
535
-
536
- If you set `SESSION_STORE_SECURE=false` for local HTTP development or set `SESSION_STORE_DOMAIN`
537
- for shared-domain cookies, also set `SESSION_STORE_NAME` to a non-`__Host-` value such as
538
- `auth.sid` — with `SESSION_STORE_SECURE=false`, one with no prefix: a `__Secure-` name
539
- needs `SESSION_STORE_SECURE=true` too. The server fails fast when a `__Host-` or
540
- `__Secure-` cookie name is combined with attributes that browsers reject for that
541
- prefix (in any case), on a `SESSION_STORE_NAME` that is not a cookie name (an RFC 6265
542
- token: no space, `;` or other separator), and on a `SESSION_STORE_DOMAIN` that is not a
543
- host name (a scheme, a port or a path).
544
-
545
- #### CSRF on `/session/login` and `/session/logout`
546
-
547
- Both routes accept a request carrying **either** a same-origin (or trusted)
548
- `Origin` / `Referer`, **or** a valid double-submit CSRF token, and reject one
549
- carrying neither (#272). Browsers satisfy this automatically; a scripted client
550
- calls `GET /session/csrf` first and sends the returned `csrf_token` back as
551
- both the `<SESSION_STORE_NAME>.csrf` cookie and an `x-csrf-token` header (or a
552
- `csrf_token` form field).
553
-
554
- Two configuration notes:
555
-
556
- - Behind a TLS-terminating proxy, set `HTTP_TRUST_PROXY` (to the proxy's
557
- address or CIDR range, not `true`). Without it `req.protocol` reads `http`
558
- while the browser sends `Origin: https://…`, and the origin arm rejects every
559
- request.
560
- - If your login UI is served from a **different origin** than the provider,
561
- list that origin under `session.csrf.trustedOrigins` in your HOCON config.
562
- A listed origin can also answer federation-grant consents and device
563
- verification, so a client's origin is never listed (the federation-grants
564
- ADR's D7). `http.cors.allowedOrigins` does not confer CSRF trust — see
565
- [CORS](#cors) for what it does confer.
566
-
567
- ### CORS
568
-
569
- | Variable | Default | Description |
570
- |---|---|---|
571
- | `HTTP_CORS_ALLOWED_ORIGINS` | *(empty — CORS off)* | Comma-separated list of browser origins allowed to read the token, userinfo, revocation and discovery/JWKS responses |
572
-
573
- A browser app served from a different origin than this provider — an SPA on
574
- `https://app.example.com` against a provider on `https://auth.example.com` —
575
- cannot call the token endpoint at all unless that origin is on
576
- `http.cors.allowedOrigins`. Set it and the provider answers preflights and stamps
577
- `Access-Control-Allow-Origin` on the five endpoints a browser has a legitimate
578
- reason to call cross-origin:
579
-
580
- | Endpoint | Methods | Why |
581
- |---|---|---|
582
- | `/oauth/token` | `POST` | The PKCE code exchange and refresh — the call an SPA cannot avoid |
583
- | `/oauth/userinfo` | `GET`, `POST` | OIDC Core §5.3 defines both |
584
- | `/oauth/revoke` | `POST` | RFC 7009 §2.1 — a public client revoking its own tokens on sign-out |
585
- | `/.well-known/openid-configuration` | `GET` | Discovery, fetched by browser client libraries |
586
- | `/.well-known/jwks.json` | `GET` | Same; follows `jwks.path` when you override it |
587
-
588
- `/oauth/introspect` and `/oauth/authorize` are deliberately **not** included.
589
- Introspection is server-to-server and already refuses public clients, so no
590
- browser could use it; `/authorize` is a top-level navigation rather than a
591
- `fetch`, and CORS has no say over where a browser may navigate.
592
-
593
- ```hocon
594
- http.cors {
595
- allowedOrigins = ["https://app.example.com", "http://localhost:5173"]
596
- }
597
- ```
598
-
599
- or `HTTP_CORS_ALLOWED_ORIGINS=https://app.example.com,http://localhost:5173`.
600
- Those are the two spellings; a value that is neither — a number, an
601
- object, a boolean, which only a configuration file can write — fails boot
602
- naming `http.cors.allowedOrigins` rather than reading as no origins.
603
-
604
- The list is part of the `http` module's section, with its default in
605
- `config/reference.conf`. The `http` module hands it to core's CORS middleware
606
- in the `httpSettings` slot, beside the trusted hops, and core reads the slot,
607
- not the configuration: a composition with no `httpSettings` provider mounts
608
- no CORS.
609
-
610
- **Matching is exact string equality against the `Origin` header**, and every
611
- shape that could not match fails at boot naming its index rather than sitting
612
- in the config admitting nobody. So: no trailing slash
613
- (`https://app.example.com/`), no explicit default port (`:443`), no path, no
614
- uppercase host, and **no wildcard** — there is no subdomain matching and there
615
- is not going to be. `https` is required except for a loopback host
616
- (`localhost`, `127.0.0.0/8`, `[::1]`), which is the carve-out that lets a
617
- front-end dev server work without a certificate.
618
-
619
- **Credentials are never allowed.** The provider sends no
620
- `Access-Control-Allow-Credentials`, so an allowlisted origin can read these
621
- responses and cannot use the browser's session cookie. That is deliberate: a
622
- cross-origin SPA here is a public client using PKCE and needs no cookie, while
623
- the cookie it would otherwise gain reaches the `session` grant — which
624
- exchanges an authenticated browser session for tokens. If your app needs that
625
- grant, serve it same-origin or put a BFF in front.
626
-
627
- Every response from these five routes carries `Vary: Origin`, including the
628
- ones with no CORS headers, so a shared cache cannot hand one origin's response
629
- to another. An empty list (the default) means the middleware is not mounted at
630
- all — no headers, no `Vary`, nothing changed.
631
-
632
- ### Google Federation
633
-
634
- The federations are core's: `core.federations`, one map keyed by the name
635
- each is reached at (`/session/oauth/federation/<name>`). A key of one is bound
636
- to the variable named after its path, `CORE_FEDERATIONS_<NAME>_<KEY>`. The map
637
- written at the top level (`federations { ... }`) refuses boot, naming each
638
- key's path under `core.federations`; a `FEDERATIONS_GOOGLE_*` or
639
- `FEDERATIONS_OIDC_*` variable set, alone or beside its new name at any value,
640
- is refused before any module is chosen.
641
-
642
- Every entry names its `type`, and boot hands each enabled entry to the module
643
- that handles that type, under the entry's name. The template always loads the
644
- two types it bundles — `google` (`@o3co/auth-provider-federation-google`) and
645
- `oidc` (`@o3co/auth-provider-federation-oidc`) — whatever the map says, and
646
- reads no entry itself: a key an entry carries is read, and a malformed one
647
- refused at its path (`core.federations.<name>.<key>`), by core and by the
648
- type's schema. An entry with no `type`, enabled or not, is refused at
649
- `core.federations.<name>.type` (`config-validation-failed`); an enabled entry
650
- of a type no loaded module handles refuses the boot
651
- (`federation-type-unhandled`), naming the entry.
652
- GitHub and Apple are not bundled: a deployment that wants one adds its
653
- package and its module to `buildModules` — GitHub's type module,
654
- `githubFederationTypeModule()`, with entries of `type = "github"`; Apple's
655
- per its [README](../../packages/federation-apple/README.md).
656
-
657
- `core.federations.google` ships with `type = "google"`. A second Google
658
- client is a second entry of that type, under its own name and with its own
659
- callback:
660
-
661
- ```hocon
662
- core.federations {
663
- google-work {
664
- enabled = true
665
- type = "google"
666
- clientId = ${GOOGLE_WORK_CLIENT_ID}
667
- clientSecret = ${GOOGLE_WORK_CLIENT_SECRET}
668
- callbackURL = "https://auth.example.com/session/oauth/federation/google-work/callback"
669
- }
670
- }
671
- ```
672
-
673
- The keys a `google` entry takes beside the variables below — `redirectAllowlist`,
674
- `sessionDomain`, `authCallbackUrl`, `clientUrl`,
675
- `requireAuthorizationResponseIss`, `endSessionEndpoint` — are listed in full
676
- in [the package's README](../../packages/federation-google/README.md);
677
- `config/application.conf` shows every one of them but `endSessionEndpoint`.
678
- A key the type does not name refuses the entry.
679
-
680
- | Variable | Default | Description |
681
- |---|---|---|
682
- | `CORE_FEDERATIONS_GOOGLE_ENABLED` | `false` | Enable Google OAuth federation |
683
- | `CORE_FEDERATIONS_GOOGLE_CLIENT_ID` | — | Google OAuth client ID |
684
- | `CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET` | — | Google OAuth client secret |
685
- | `CORE_FEDERATIONS_GOOGLE_CALLBACK_URL` | `http://localhost:3000/session/oauth/federation/google/callback` | Google OAuth callback URL |
686
- | `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) |
687
-
688
- ### OIDC Federation (any OpenID Connect provider)
689
-
690
- One instance ships in `config/application.conf` under `core.federations.oidc`
691
- (`@o3co/auth-provider-federation-oidc`, #524): Okta, Entra ID, Auth0, Keycloak
692
- or your own tenant, selected by issuer. Discovery runs at boot and a failure
693
- refuses boot. The identity handed to the Store is `oidc:<sub>`; an identity
694
- the Store does not know is refused with 401 — nothing is provisioned.
695
-
696
- | Variable | Default | Description |
697
- |---|---|---|
698
- | `CORE_FEDERATIONS_OIDC_ENABLED` | `false` | Enable the OIDC federation |
699
- | `CORE_FEDERATIONS_OIDC_ISSUER` | — | Issuer identifier (https), exactly as the IdP writes `iss` |
700
- | `CORE_FEDERATIONS_OIDC_CLIENT_ID` | — | Client ID registered at the IdP |
701
- | `CORE_FEDERATIONS_OIDC_CLIENT_SECRET` | — | Client secret (`client_secret_basic`); set `privateKey` in the config file for `private_key_jwt` instead |
702
- | `CORE_FEDERATIONS_OIDC_CALLBACK_URL` | `http://localhost:3000/session/oauth/federation/oidc/callback` | Where the IdP sends the browser back |
703
-
704
- A second IdP is another section with `type = "oidc"` and its own callback:
705
-
706
- ```hocon
707
- core.federations {
708
- okta {
709
- enabled = true
710
- type = "oidc"
711
- issuer = "https://dev-123.okta.com"
712
- clientId = ${OKTA_CLIENT_ID}
713
- clientSecret = ${OKTA_CLIENT_SECRET}
714
- callbackURL = "https://auth.example.com/session/oauth/federation/okta/callback"
715
- redirectAllowlist = ["https://app.example.com/welcome"]
716
- }
717
- }
718
- ```
719
-
720
- The entry's `type` names the implementation, never its name: an entry named
721
- `google` with `type = "oidc"` is a generic OIDC federation named `google`.
722
-
723
- Every field the package accepts — `scopes`, `discovery` / `endpoints`,
724
- `privateKey`, `userInfo`, `idTokenSignedResponseAlg`, `clockToleranceSeconds` —
725
- is documented in [its README](../../packages/federation-oidc/README.md).
726
-
727
- ### Client Repository
728
-
729
- | Variable | Default | Description |
730
- |---|---|---|
731
- | `ADAPTERS_CLIENT_REPOSITORY` | `yaml` | Client store backend: `yaml`, or `static`, core's alias of `yaml`, which reads `repositories.client.static.path` (no default) |
732
- | `REPOSITORIES_CLIENT_YAML_PATH` | `./config/clients.yaml` | Path to the YAML client registry |
733
-
734
- The registry is per-deployment configuration: `.gitignore` keeps
735
- `config/clients.yaml` out of git and `.dockerignore` keeps it out of the image,
736
- and the production compose file mounts it (see [Docker](#docker)).
737
-
738
- ### User Repository
739
-
740
- | Variable | Default | Description |
741
- |---|---|---|
742
- | `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) |
743
- | `REPOSITORIES_USER_YAML_PATH` | `./config/users.yaml` | The YAML user file the `yaml` backend reads |
744
- | `REPOSITORIES_USER_HTTP_AUTHENTICATE_URL` | — | URL for password-based user authentication. **https required** (see below) |
745
- | `REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL` | — | URL for token-based user authentication. **https required** (see below) |
746
- | `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** |
747
- | `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** |
748
- | `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** |
749
- | `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) |
750
- | `REPOSITORIES_USER_HTTP_TIMEOUT` | `5000` | HTTP request timeout in milliseconds. Positive integer ≤ `2147483647` |
751
- | `REPOSITORIES_USER_HTTP_MAX_RESPONSE_BYTES` | `1048576` | Largest upstream response body accepted, in bytes |
752
-
753
- **The `yaml` users file** is keyed by username; each entry holds `password`,
754
- and optionally `id` (the username when absent) and claims. A password is plain
755
- text, or a bcrypt hash: anything starting with `$2` is read as one, and must be
756
- a well-formed `$2a$`, `$2b$` or `$2y$` hash (the last compared as `$2b$`) at a
757
- cost from 04 to 15 (a compare at 15 takes about 2 s, at 10 about 60 ms, on a
758
- thread of Node's libuv pool). `$2b$` and `$2y$` compare only the first 72
759
- bytes of a password (about 24 Japanese characters in UTF-8). `$2a$` reads the
760
- same 72 bytes of a password up to 254 bytes long, but counts a longer one's
761
- length modulo 256, so a password of 255 bytes or more can compare as a
762
- different one; use `$2b$` for new entries. A plain-text entry is compared over
763
- its full length.
764
- Every bcrypt entry uses one cost. Boot refuses a password that breaks these
765
- rules, bcrypt entries at more than one cost (naming the costs), an empty `id`
766
- or username, two users with the same id, and a `username` key inside an entry,
767
- naming the field and the user or the costs, never a value. Each start with the `yaml` backend logs
768
- `user_repository_in_memory` at warn on the template's logger (`LOGGING_LEVEL`
769
- applies), whatever the environment: the users file is meant for development
770
- and tests. See
771
- [the core package's README](../../packages/core/README.md#loading-clients-and-users-from-yaml).
772
-
773
- Both user-authentication URLs carry **plaintext user credentials** to the
774
- upstream store, so both must be absolute `https://` URLs. `http://` is accepted
775
- only for a loopback host — `localhost`, an address in `127.0.0.0/8`, or `[::1]`
776
- — so that local development needs no certificate. A private-range address
777
- (`http://10.0.0.5/…`) or a container-network service name
778
- (`http://user-service/…`) still requires `https://`: those cross a network the
779
- deployment does not control end to end. Boot fails if either URL, the timeout,
780
- or the response cap is unusable, rather than at the first login attempt.
781
-
782
- Each URL must be the endpoint that answers, not one that redirects: no request
783
- follows a `3xx`, so a URL that redirects fails every call — see
784
- [What the Store must enforce itself](../../packages/foundation/README.md#what-the-store-must-enforce-itself).
785
-
786
- **Who may call the Store.** What the token-based login and account linking
787
- send is an identifier, not a secret, so a Store that answers any caller
788
- resolves a known identity to its user — or links one to any account — for
789
- anyone who can reach it. Set `REPOSITORIES_USER_HTTP_BEARER_TOKEN` and have the Store
790
- answer every request whose `Authorization` is not exactly `Bearer <that token>`
791
- with `401` and `WWW-Authenticate: Bearer error="invalid_token"` (or `403` and
792
- `error="insufficient_scope"`). The one token goes to every Store URL above, so
793
- those endpoints must be one trust domain. With `ADAPTERS_USER_REPOSITORY=http` — this
794
- template's default — boot fails if the token is weaker than 32 bytes
795
- (measured like `SESSION_STORE_SECRET`), malformed, or exported but empty; it appears
796
- in no error this server throws. With that challenge, a token the Store does not
797
- accept is an outage on every Store call: logins answer
798
- `503 temporarily_unavailable` and log an error naming the refused credential,
799
- not the token, and so do federation-grants connects
800
- (`federation_grant_callback_unavailable`). A `401` or `403` without the
801
- challenge keeps its old meaning — at login "no such user", at a `?link=1`
802
- link a refusal, and at the federation-grants identity lookup an outage like
803
- any other non-`2xx` — so a mismatch then shows only as every login failing
804
- and every link refused; and a Store must not put a `Bearer` challenge on a
805
- user's wrong password or a link it refuses. A Store that cannot be reached,
806
- closes the connection before a complete response, or answers with a malformed
807
- HTTP response is logged as such, with at most a transport code
808
- (`ECONNREFUSED`, `UND_ERR_SOCKET`, `ERR_SSL_WRONG_VERSION_NUMBER`, …), and
809
- never with what the transport quoted. Without the token, admit only this server to the Store
810
- by network policy or platform mutual TLS. Rotation, what each caller logs, and
811
- what the Store checks are in the same foundation README section.
812
-
813
- ### Code Repository
814
-
815
- `adapters.codeRepository` (`ADAPTERS_CODE_REPOSITORY`) chooses the
816
- repository; each reads its own section. The Redis one runs on the shared
817
- connection `REDIS_CLIENTS_URL` configures, so it takes no URL or password of
818
- its own.
819
-
820
- | Variable | Default | Description |
821
- |---|---|---|
822
- | `REDIS_CODE_REPOSITORY_DEFAULT_EXPIRES_IN` | `600` | `redis-code-repository.defaultExpiresIn`: the Redis repository's default authorization-code lifetime, in seconds |
823
- | `REDIS_CODE_REPOSITORY_KEY_PREFIX` | `oauth:code:` | `redis-code-repository.keyPrefix`: the Redis repository's key namespace (see [Redis Namespacing](#redis-namespacing)) |
824
- | `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 |
825
-
826
- `CLIENT_CODE_DEFAULT_EXPIRES_IN` and `CLIENT_CODE_KEY_PREFIX` were renamed
827
- with them, and `CLIENT_CODE_ENDPOINT_URI` and `CLIENT_CODE_PASSWORD` removed. A
828
- renamed one set, alone or beside its new name at any value, and a removed one
829
- set at all, fails boot naming what to set instead.
830
-
831
- ### Federation Token Store
832
-
833
- The upstream IdP tokens (a Google refresh token, say) held on behalf of a
834
- session. Memory by default, which forks per replica and is refused under
835
- `CORE_DEPLOYMENT_MODE=multi`; the Redis store shares the socket configured by
836
- `REDIS_CLIENTS_URL` and encrypts records at rest, so it
837
- needs a key.
838
-
839
- | Variable | Default | Description |
840
- |---|---|---|
841
- | `ADAPTERS_FEDERATION_TOKEN_STORE` | `memory` | Federation token store backend: `memory` or `redis` |
842
- | `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` |
843
- | `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 |
844
-
845
- `ttl` (seconds; keep it above the upstream refresh-token lifetime) and the #291
846
- `scanFallback` migration flag live under `redis-federation-token-store` in a
847
- config layer rather than behind an environment variable.
848
-
849
- ### Consent Store
850
-
851
- Where an end-user's consent to a client that is not first-party is recorded,
852
- together with the `/authorize` request parked while the consent page asks
853
- (#527, #552). One switch wires both. `none` by default: such clients are
854
- refused and only first-party clients are served. `memory` forks per replica and
855
- is refused under `CORE_DEPLOYMENT_MODE=multi`; `redis` (#561) shares both over the
856
- socket configured by `REDIS_CLIENTS_URL`.
857
-
858
- | Variable | Default | Description |
859
- |---|---|---|
860
- | `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) |
861
-
862
- ### Multi-factor authentication
863
-
864
- A second factor after a password login, and a step-up when a relying party
865
- asks for one at `/authorize` — the [MFA package](../../packages/mfa/README.md).
866
- `MFA_MODE` is the template's own switch, `mfaMode`
867
- ([`config/reference.conf`](config/reference.conf)): `required`, the default
868
- (every password login has a second factor), and `optional` (a user with a
869
- factor is challenged, nobody is forced) install the MFA module, the TOTP, recovery-code and email factors (email off
870
- unless `MFA_EMAIL_FACTOR_ENABLED`), the operator reset
871
- (`handle.components.mfaReset`), the session package's login completion and
872
- the two MFA stores `adapters` selects. The template then adds `mfa` to
873
- `core.sessionRequirements.expected`, names it
874
- `core.sessionRequirements.secondFactorAuthority` and writes `mfa.mode` from
875
- the switch, so
876
- a composition that asks for a second factor and cannot enforce one is refused
877
- at boot rather than let a password alone sign in; `off` installs nothing of
878
- MFA. A value that is none of the three is refused before boot, naming
879
- `mfaMode` and `MFA_MODE`.
880
-
881
- **On by default.** In development (`make dev`) MFA needs nothing more: the
882
- sample key in `config/development.conf`, the sender that logs each code, and
883
- `docker-compose.yml`'s Redis for the two stores. Outside development a
884
- deployment either sets what MFA needs — the two stores on `redis` (or the
885
- factors in the `store`), `MFA_ENCRYPTION_KEY` and the SMTP relay
886
- (`STANDARD_SMTP_MAIL_SENDER_HOST` and `_FROM`, or a sender of its own through
887
- `buildModules`' `mailSenderModules`) — or sets `MFA_MODE=off`. Until it
888
- decides, the boot is refused before the app is created: the stores' refusal
889
- comes first, naming each of those and `MFA_MODE=off`. The MFA page is never
890
- refused: `MFA_PAGE_URL` defaults to `/mfa`, which the deployment must serve,
891
- in development too, or every password login stops there.
892
-
893
- | Variable | Default | Description |
894
- |---|---|---|
895
- | `MFA_MODE` | `required` | `mfaMode`: `required`, `optional` or `off` |
896
- | `MFA_ENCRYPTION_KEY` | — | The MFA key ring's first key, which seals every factor's data: canonical base64 of 32 bytes (`openssl rand -base64 32`). Required with MFA on. Under `CONFIG_ENV=development`, `config/development.conf` puts the MFA package's published sample key in its place: write your own key there instead, since this variable set beside that ring refuses the boot. The sample key is accepted only where `CONFIG_ENV` and `NODE_ENV`, each where set, say `development` or `test`, and is refused under `CORE_DEPLOYMENT_MODE=multi` |
897
- | `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) |
898
- | `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 |
899
- | `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` |
900
- | `STANDARD_SMTP_MAIL_SENDER_FROM` | — | The one sender address of that mail |
901
- | `STANDARD_SMTP_MAIL_SENDER_PORT` | `587` | The relay's port |
902
- | `STANDARD_SMTP_MAIL_SENDER_SECURE` | `starttls` | `starttls`, `tls`, or `none` to a loopback relay only |
903
- | `STANDARD_SMTP_MAIL_SENDER_USER` | — | The relay account, with `STANDARD_SMTP_MAIL_SENDER_PASSWORD` |
904
- | `STANDARD_SMTP_MAIL_SENDER_PASSWORD` | — | That account's password |
905
-
906
- The rest of the MFA settings — each factor's, the lock, a transaction's life
907
- — are the MFA package's, with their variables, in its
908
- [README](../../packages/mfa/README.md#configuration). A few things to know:
909
-
910
- - **The stores.** `memory`, the shipped selection, is for development and
911
- test alone: a restart loses every factor, lock and recorded email proof,
912
- after which whoever holds a password can bind a factor of their own. With
913
- MFA on it is refused before boot unless the configuration's name, and
914
- `CONFIG_ENV` and `NODE_ENV` wherever set, each say `development` or
915
- `test`, and core refuses it under `CORE_DEPLOYMENT_MODE=multi`. Production
916
- needs `redis` for both, or `store` for the factors: `redis` shares the
917
- socket `REDIS_CLIENTS_URL` opens; `store` (factors only) keeps them behind the Store's four endpoints
918
- (`FOUNDATION_MFA_FACTOR_STORE_*`,
919
- [foundation's README](../../packages/foundation/README.md)), sent the same
920
- bearer token as the user repository.
921
- - **The enrollment witness.** With `ADAPTERS_USER_REPOSITORY=http`, set
922
- `REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL` ([User Repository](#user-repository));
923
- without it, boot warns once (`mfa_enrollment_witness_unwritable`).
924
- - **`acr`.** With MFA on, the template adds `"urn:o3co:acr:mfa" = ["mfa"]`
925
- to `oauth.authorize.acrValues`, unless your configuration writes that
926
- entry, and discovery advertises it. With MFA off it adds nothing: the acr
927
- table and discovery are what they are without MFA.
928
- - **The mail in development.** Under `CONFIG_ENV=development` the template's
929
- sender logs each code (`mail_code_issued`) and sends nothing. To run the
930
- SMTP sender and read what it sends, use the
931
- [Mailpit overlay](#mailpit-for-the-smtp-sender-development-only).
932
- - **One switch.** A file that writes `mfaMode` while `MFA_MODE` is set to
933
- something else, and a configuration that writes `mfa.mode` other than
934
- what the switch says, are refused before boot, naming the keys. While MFA
935
- is off, nothing the configuration writes under `mfa` is handed to boot.
936
-
937
- ### Federation Grants
938
-
939
- Offline delegation of an upstream IdP's tokens (#593): a user consents once
940
- that a client — a backend, an agent — may obtain access tokens for one
941
- upstream connection on their behalf, and the client obtains them over HTTP
942
- later, with the user not present. A grant **survives logout**; a credential
943
- change ends it through the subject-revocation service. Off by default, and
944
- off installs nothing. The routes and what each answer means are in the
945
- [package README](../../packages/federation-grants/README.md); what each IdP
946
- needs before it issues a refresh token is in
947
- [`docs/offline-access.md`](../../packages/federation-grants/docs/offline-access.md).
948
-
949
- | Variable | Default | Description |
950
- |---|---|---|
951
- | `FEDERATION_GRANTS_ENABLED` | `false` | Installs the routes, the two stores selected below (none until selected) and the subject-revocation service |
952
- | `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 |
953
- | `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 |
954
- | `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) |
955
- | `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` |
956
- | `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` |
957
- | `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 |
958
- | `REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX` | `fg:` | Key namespace of the Redis grant store |
959
- | `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 |
960
-
961
- The feature's own settings are the `federation-grants {}` section; the Redis
962
- grant store's — its key ring, its encryption mode, its prefix — are
963
- `redis-federation-grant-store {}`. **Two things have no environment form**,
964
- because a list is HOCON's: the connections, and the encryption key ring.
965
- Write them in a deployment-owned layer — `config/production.conf`, say:
966
-
967
- ```hocon
968
- redis-federation-grant-store {
969
- encryptionKeys = [
970
- # The first key seals; every listed key opens. GRANT_KEY_2026_09 is a name
971
- # you choose (openssl rand -base64 32), not a template override.
972
- { id = "2026-09", key = ${GRANT_KEY_2026_09} }
973
- ]
974
- }
975
-
976
- federation-grants {
977
- connections {
978
- files {
979
- federation = "entra-files" # an enabled core.federations.<name> of type "oidc", with an app registration of its own
980
- scopes = ["openid", "profile", "offline_access", "Files.Read"]
981
- boundary = "production"
982
- maxAccessTokenLifetime = 3600
983
- # The grant flow's own callback on this provider's origin — not the
984
- # federation's login callback.
985
- callbackURL = "https://auth.example/session/federation-grants/callback/files"
986
- allowScopeSubsets = false
987
- identityClaims = ["oid", "tid"]
988
- }
989
- }
990
- }
991
- ```
992
-
993
- **What boot refuses**, each named, rather than met by a user mid-flow: no
994
- consent page; a connection without `callbackURL`, on a federation that is not
995
- enabled, or on one whose adapter lacks the delegated capability — only the
996
- generic OIDC adapter has it, so a Google connection is a `type = "oidc"`
997
- federation; `required` with a user repository that does not cover a
998
- connection's registration; Redis grants beside memory user-session stores
999
- (`ADAPTERS_USER_SESSION_STORES=memory` — the grants would outlive the boundary
1000
- that ends them; both compose files set `redis`); the Redis grant store under
1001
- `encryptionMode = "required"` with no key in the ring (the memory store seals
1002
- nothing and needs none); `memory` for either store under `CORE_DEPLOYMENT_MODE=multi`.
1003
-
1004
- **The user repository.** Under `required`, boot asks the repository whether it
1005
- covers each connection's registration, and the connect callback asks it who
1006
- holds the upstream account. The template's `http` repository asks your Store
1007
- (#613): set `REPOSITORIES_USER_HTTP_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` to the
1008
- endpoint, and declare what that endpoint covers — one entry per registration,
1009
- with the claims the Store's strategy needs — in a HOCON layer:
1010
-
1011
- ```hocon
1012
- repositories.user.http.federatedIdentityLookupCoverage = [
1013
- { provider = "entra-files", issuer = "https://login.microsoftonline.com/<tenant>/v2.0",
1014
- clientId = "<the grants app registration>", requiredClaims = ["tid", "oid"] }
1015
- ]
1016
- ```
1017
-
1018
- Boot then holds every connection to it: one whose registration is not
1019
- declared, or whose `identityClaims` misses a required claim, is refused by
1020
- name. The wire contract the Store implements — and what "unlinked" obliges it
1021
- to have checked — is in the
1022
- [foundation README](../../packages/foundation/README.md#the-identity-lookup-613);
1023
- the Store is not asked at boot. The bundled in-memory repository covers no
1024
- registration. Without a covering Store, set
1025
- `FEDERATION_GRANTS_IDENTITY_LOOKUP=unsupported` — the recorded decision not to
1026
- refuse an upstream account another local user already holds. With no
1027
- connection configured nothing is required.
1028
-
1029
- **Registering a client.** In `config/clients.yaml`, a confidential client that
1030
- may hold grants names the connections it may ask for and where the browser
1031
- may be returned. Neither its `allowedRedirectUris` nor `firstParty` grants
1032
- either:
1033
-
1034
- ```yaml
1035
- worker:
1036
- tokenEndpointAuthMethod: "client_secret_basic"
1037
- clientSecret: "$2b$10$…"
1038
- allowedFederationGrantConnections: ["files"]
1039
- federationGrantRedirectUris: ["https://worker.example/connected"]
1040
- ```
1041
-
1042
- **The flow, from the client's side.** `POST /oauth/federation-grants`
1043
- (client-authenticated) lodges an intent for a `sub` and answers a `grant_id`
1044
- and a `connect_uri`; the client sends the user's browser there. The provider
1045
- signs the user in if needed, shows your consent page, sends them to the
1046
- upstream, and returns the browser to the client's `redirect_uri` with
1047
- `grant_id` and `state` — never a token. The client then calls
1048
- `/oauth/federation-grants/:grantId/token`, `/status` and `/revoke` with its
1049
- own credentials and the user's `sub`, with no user present.
1050
-
1051
- **The consent page** is yours, same-origin with the provider, and its contract
1052
- is `/oauth/consent`'s, so one page can serve both. `GET
1053
- /session/federation-grants/consent?challenge=…` answers JSON: `client_id`,
1054
- `client_name`, `connection`, `scopes`, `resource`, `grant_expires_in` (a
1055
- duration after approval, not a date), `continues_after_logout` (which the page
1056
- must show), and `expires_in` (what is left of the flow). `POST` with
1057
- `challenge` and `decision` (`accept` | `deny`) answers `303` — to the upstream,
1058
- or back to the client. Every acquisition and renewal goes through it. The
1059
- answer is held to the session module's CSRF policy: post it as a form from the
1060
- page, and serve the page with `Referrer-Policy: same-origin` — not
1061
- `no-referrer`, whether by the header, `<meta name="referrer">` or
1062
- `rel="noreferrer"` on the form, under which the browser sends `Origin: null`
1063
- and the answer is refused. This app's `helmet()` sends `no-referrer` on every
1064
- response, so a page it serves sets `Referrer-Policy: same-origin` on its own
1065
- route. Behind a proxy, `HTTP_TRUST_PROXY` names it, and it forwards
1066
- `X-Forwarded-Proto` and `X-Forwarded-Host` (or keeps the browser's `Host`). A
1067
- user agent that sends neither `Origin` nor `Referer` echoes the token
1068
- `GET /session/csrf` hands out, as for `POST /session/login`; a page may always
1069
- include it, since it counts only when neither is sent. The package README has
1070
- the rule.
1071
-
1072
- **The login page** is the one `SESSION_LOGIN_PAGE_URL` names, reached with
1073
- `redirect_to=<the connect link>`; it signs the user in and navigates back to
1074
- that link unchanged, as it does for `/oauth/authorize`. Do not submit the link
1075
- to `POST /session/login` as its `redirect_to`: that route's exact-match
1076
- allowlist is for landing pages and refuses a per-flow handle.
1077
-
1078
- **Operating it.** Rotate the key ring by the procedure in the
1079
- [operator runbook](../../docs/operator-runbook.md) — new key last, then first,
1080
- old key kept 365 days. A shutdown gives cleanup 45 seconds while the feature
1081
- is on — more if you raise `upstreamHardTimeoutMs`, `persistRetryBudgetMs` or
1082
- `lockWaitMs`, since the allowance is their sum plus a margin — and both
1083
- compose files give the process 60; see
1084
- [Shutdown guarantees](#shutdown-guarantees). Disabling the feature is not
1085
- revoking: grants stay in Redis until revoked, so end them first, and keep the
1086
- keys and the revocation boundaries through any temporary shutdown.
1087
-
1088
- ### Redis Namespacing
1089
-
1090
- For multi-tenant Redis clusters, set deployment-specific key prefixes so two
1091
- auth.provider instances cannot collide in the same database:
1092
-
1093
- | Variable | Default | Description |
1094
- |---|---|---|
1095
- | `REDIS_SESSION_STORES_KEY_PREFIX` | `ss:` | Outer prefix for user sessions, the subject session index, the subject-revocation boundaries and the session lifecycle records. |
1096
- | `REDIS_REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX` | `rtfam:` | Prefix for refresh-token family records. |
1097
- | `REDIS_CODE_REPOSITORY_KEY_PREFIX` | `oauth:code:` | Prefix for OAuth authorization codes. |
1098
- | `REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX` | `ft:` | Prefix for federation token records, their per-session index and their lock keys. |
1099
- | `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). |
1100
-
1101
- Use values that include the deployment name, for example `tenant-a:ss:`,
1102
- `tenant-a:rtfam:`, `tenant-a:code:`, `tenant-a:ft:`, and `tenant-a:consent:`.
1103
-
1104
- ### Endpoints
1105
-
1106
- | Variable | Default | Description |
1107
- |---|---|---|
1108
- | `SESSION_LOGIN_PAGE_URL` | `/login` | URL of the login page (for redirects) |
1109
- | `OAUTH_CONSENT_PAGE_URL` | `/consent` | URL of the consent page a client that is not first-party is routed through, with `?challenge=<id>` (#527) |
1110
-
1111
- There is no deployment-wide client or callback URL: federation callback URLs
1112
- are configured per federation (`CORE_FEDERATIONS_GOOGLE_CALLBACK_URL`,
1113
- `CORE_FEDERATIONS_OIDC_CALLBACK_URL`).
1114
-
1115
- ## Module Composition Order
1116
-
1117
- [`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:
1118
-
1119
- 1. **`sessionStoreModule` stays first.** It mounts the `express-session` middleware and declares no `before` / `after`, so its position in the list is what puts it ahead of every route that reads `req.session`. It declares its replica safety from its own section, so `session-store.storage.type = "memory"` is replica-unsafe and refused under `core.deployment.mode = "multi"`.
1120
- 2. **Under `/oauth`, the order does not matter.** `federationGrantsModules` (while federation grants are enabled), `oauthEndpointsModule` and any module of your own may all mount routes under `/oauth`. `oauthEndpointsModule`'s router parses the bodies of its own routes only, so each module's requests reach its own parsers whatever the list order — provided every module under `/oauth` parses its own body and scopes its parsers to exactly its own paths, as the shipped ones do — a route (`router.all(path, parser)` or the route's own handler list), not `router.use(path, parser)`, which matches every path beneath `path` as well. The federation grants browser half orders itself after the session middleware with its own `after`.
1121
- 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.
1122
- 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`).
1123
- 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.
1124
- 6. **The federation types are always composed.** `googleFederationTypeModule()` and `oidcFederationTypeModule()` are listed whatever `core.federations` says: each contributes its type, and boot builds one federation per enabled entry of it, under the entry's name, so a federation is added or removed in configuration alone. Nothing in the list reads an entry. A module that contributes a federation under an entry's name directly beside an entry dispatched by its type is a boot-time `duplicate-contribute`: add a federation of another kind as a type module, and give its entries that type.
1125
- 7. **The mail sender follows the environment.** `@o3co/auth-provider-standard`'s development sender, which logs each code, where the configuration was selected as `development`; its module installs only where that name, and `CONFIG_ENV` and `NODE_ENV` wherever they are set, each read `development` or `test`, and refuses the boot otherwise, or where `core.deployment.mode` is `multi`. Under any other name, the SMTP sender's module, whose section is `standard-smtp-mail-sender` ([the package's README](../../packages/standard/README.md)); its sender is built only where something reads the `mailSender` slot, and there the boot needs `STANDARD_SMTP_MAIL_SENDER_HOST` and `STANDARD_SMTP_MAIL_SENDER_FROM`. While `MFA_MODE` is `off` nothing reads the slot, so the template boots without them; with MFA on, the MFA module and the operator reset read it. A sender of your own replaces this choice: pass its module where [`src/app.mts`](src/app.mts) calls `buildModules`, as `buildModules(switches, { environment: env, mailSenderModules: [mySenderModule] })`. Given, `mailSenderModules` is installed under every environment name and the bundled sender is not, so the two never both provide the slot; an empty list installs no sender, and a module that needs one (the MFA email factor, or the MFA module with `requireEmailProof = "always"`) then refuses the boot.
1126
-
1127
- `jwksModule` (from core) is always composed: a provider that signs tokens publishes its verification keys whether or not an issuer is configured. What each route module mounts is in its package's README. The template composes core's `sessionLifecycleModule`: a login opens its session's record, a code exchange joins it, and both `POST /session/logout` and `POST /oauth/logout` close the session through the lifecycle, which revokes the session's refresh-token families and tells its relying parties. See [Which logout endpoint invalidates what](../../docs/operator-runbook.md#which-logout-endpoint-invalidates-what).
1128
-
1129
- ## Built-in Routes
1130
-
1131
- `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).
1132
-
1133
- | Method | Path | Description |
1134
- |---|---|---|
1135
- | `GET` | `/_healthcheck` | Liveness — `200 {"status":"ok"}` whenever the process is up, whatever Redis is doing. See [Health endpoints](#health-endpoints) |
1136
- | `GET` | `/readyz` | Readiness — runs every registered dependency probe (`redis`, `session-store`, …) under `HTTP_READINESS_TIMEOUT_MS`; `200 {"status":"ready"}` or `503 {"status":"unready"}`, never cached |
1137
- | `GET` | `/metrics` | Prometheus text exposition; dependency gauges are re-probed on every scrape. Keep it off the public listener — see [Metrics](#metrics) |
1138
-
1139
- ## Docker
1140
-
1141
- The Dockerfile uses multi-stage builds with the following targets:
1142
-
1143
- | Target | Purpose |
1144
- |---|---|
1145
- | `runtime` | Production image (compiled JS only) |
1146
- | `develop` | Development image with hot reload (`pnpm run debug`) |
1147
- | `test` | Test runner image |
1148
-
1149
- ```bash
1150
- # Build the production image
1151
- make build IMAGE=my-auth-provider
1152
-
1153
- # Start with docker compose (development)
1154
- make dev
1155
-
1156
- # Run tests in Docker
1157
- make test
1158
- ```
1159
-
1160
- The `docker-compose.yml` starts the auth server together with a Redis container. Configure environment variables in `.env`.
1161
-
1162
- `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
1163
- [`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`.
1164
-
1165
- `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.
1166
-
1167
- 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.
1168
-
1169
- 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.
1170
-
1171
- ```bash
1172
- # The signing keys are a required input: EdDSA is the default and there is no
1173
- # key-material default, so a deployment that generates none fails at boot.
1174
- openssl genpkey -algorithm ed25519 -out jwt-private.pem
1175
- openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
1176
- chmod 600 jwt-private.pem
1177
-
1178
- # The client registry is a required input too: the scaffold's
1179
- # config/clients.yaml, or one made from config/clients.yaml.example.
1180
-
1181
- docker compose -f docker-compose.production.yml up -d --build
1182
- ```
1183
-
1184
- **Keys in containers.** The pem pair reaches the container as a compose
1185
- **secret**, mounted read-only at `/run/secrets/`, and the compose file points
1186
- `KEY_STORE_LOCAL_*_KEY_PATH` there. Do not park them in `config/`: the `Dockerfile`
1187
- `COPY`s that directory. `.dockerignore` keeps every `*.pem` and `*.key` out of
1188
- the build context, as `.gitignore` keeps them out of git, but a key under any
1189
- other name placed there is baked into an image layer and travels with every
1190
- push — a key you then have to rotate rather than one you merely stored badly.
1191
- The same applies to `.env`; keep both out of git.
1192
-
1193
- **The client registry in containers.** `config/clients.yaml` arrives the same
1194
- way, as the compose secret `client_registry`, with `REPOSITORIES_CLIENT_YAML_PATH` pointed at it.
1195
- It is per-deployment and may hold client secrets, so `.dockerignore` keeps it
1196
- out of the build context: no image carries a registry, and an image built from
1197
- a working copy is the same as one built from a clean checkout. Compose refuses
1198
- to start until the file exists. Run the image some other way and you mount it
1199
- and set `REPOSITORIES_CLIENT_YAML_PATH` yourself.
1200
-
1201
- The production file also pins `SESSION_STORE_SECURE=true` and the `__Host-` cookie
1202
- name in its `environment:` block, which wins over `env_file`. `.env.example`
1203
- ships `SESSION_STORE_SECURE=false` so that a plain-HTTP compose run (`make dev`) does
1204
- not trip the `__Host-` cookie check, and this file requires that same `.env` — behind the TLS it assumes, a non-Secure session
1205
- cookie is the session handed to anyone who can read one plaintext hop.
1206
-
1207
- The image installs with `pnpm install --frozen-lockfile`, so a committed
1208
- `pnpm-lock.yaml` is a required build input — `create-auth-provider` generates
1209
- one at scaffold time; if yours is missing, run `pnpm install` once and commit
1210
- the result. Rebuilding the same source then produces the same dependency tree.
1211
-
1212
- The production image sets `ENV HTTP_PORT=3000` and uses it for both `EXPOSE`
1213
- and the Docker-native healthcheck against `/_healthcheck`. The HOCON config
1214
- also reads `${?HTTP_PORT}` for `http.port`, so the listener and the
1215
- healthcheck cannot drift apart. To run on a different port:
1216
-
1217
- ```bash
1218
- docker run -e HTTP_PORT=8080 -p 8080:8080 my-auth-provider
1219
- ```
1220
-
1221
- `EXPOSE` is image metadata and does not publish ports by itself, so the
1222
- explicit `-p` mapping is still required.
1223
-
1224
- ### Mailpit for the SMTP sender (development only)
1225
-
1226
- [`docker-compose.mailpit.yml`](docker-compose.mailpit.yml) is an overlay on
1227
- `docker-compose.yml` that adds [Mailpit](https://mailpit.axllent.org/), a mail
1228
- catcher, so a development run with MFA on sends its mail — the account-email
1229
- proof, the email factor's codes — through the SMTP sender instead of the
1230
- development sender, which only logs each code:
1231
-
1232
- ```bash
1233
- docker compose -f docker-compose.yml -f docker-compose.mailpit.yml up --build
1234
- ```
1235
-
1236
- Before the first run, write a key of your own into `.env` as
1237
- `MFA_ENCRYPTION_KEY` (`openssl rand -base64 32`): this run does not use the
1238
- development sample key, and without a key the boot is refused.
1239
-
1240
- Read the mail at <http://localhost:8025>. Mailpit keeps it and delivers
1241
- nothing onward. A plain `docker compose up` (`make dev`) starts no Mailpit, and
1242
- the production file names none. It is for development only: a deployment
1243
- names a real relay with the `STANDARD_SMTP_MAIL_SENDER_*` variables
1244
- ([Multi-factor authentication](#multi-factor-authentication)).
1245
-
1246
- What the overlay sets on the app service:
1247
-
1248
- - `CONFIG_ENV=mailpit`. Under `development` the template installs the sender
1249
- that logs each code ([Module Composition Order](#module-composition-order),
1250
- rule 7). [`config/mailpit.conf`](config/mailpit.conf) does not include
1251
- `development.conf`, whose one setting is the MFA sample key: the MFA
1252
- package accepts that key only where every environment name set says
1253
- `development` or `test`. The key ring's first key is `MFA_ENCRYPTION_KEY`,
1254
- as the MFA package's `reference.conf` binds it.
1255
- - Nothing of `MFA_MODE`: MFA is on as the template ships it, or as your
1256
- `.env` sets it. With MFA off nothing sends mail.
1257
- - `ADAPTERS_MFA_FACTOR_STORE=redis` and `ADAPTERS_MFA_TRANSACTION_STORE=redis`,
1258
- on the compose Redis, as `docker-compose.yml` sets them: under a name other
1259
- than `development` or `test` the MFA stores may not be kept in memory.
1260
- - `STANDARD_SMTP_MAIL_SENDER_HOST=localhost`,
1261
- `STANDARD_SMTP_MAIL_SENDER_PORT=1025`, `STANDARD_SMTP_MAIL_SENDER_SECURE=none`
1262
- and `STANDARD_SMTP_MAIL_SENDER_FROM=auth@example.com`. The SMTP sender sends
1263
- in plaintext only to a loopback host, so Mailpit runs in the app container's
1264
- network namespace (`network_mode: service:app`) and listens on its
1265
- `localhost`; the app service therefore publishes Mailpit's web UI, on
1266
- loopback.
1267
-
1268
- It is an overlay rather than a compose profile because a profile only adds
1269
- services: it cannot change the app service's environment or ports.
1270
-
1271
- ### Health endpoints
1272
-
1273
- Two routes, answering two different questions. Both are mounted on the host
1274
- app ahead of the auth router, so they keep answering while the auth pipeline
1275
- is degraded.
1276
-
1277
- | Route | Question | Answer |
1278
- |---|---|---|
1279
- | `GET /_healthcheck` | Is the process up? | Always `200 {"status":"ok"}` |
1280
- | `GET /readyz` | Can this replica serve right now? | `200 {"status":"ready", …}` or `503 {"status":"unready", …}` |
1281
-
1282
- Use them for different probes:
1283
-
1284
- ```yaml
1285
- livenessProbe:
1286
- httpGet: { path: /_healthcheck, port: 3000 }
1287
- readinessProbe:
1288
- httpGet: { path: /readyz, port: 3000 }
1289
- ```
1290
-
1291
- Pointing liveness at `/readyz` is the mistake worth avoiding: a Redis
1292
- partition would then look like a broken container and Kubernetes would
1293
- restart every replica in a loop, which reconnects nothing and adds cold
1294
- starts to an incident. Losing Redis is a reason to stop *routing* to a
1295
- replica, not to kill it.
1296
-
1297
- The image's `HEALTHCHECK` probes `/_healthcheck` for the same reason. Docker
1298
- has exactly one health signal, and on Swarm and ECS an unhealthy container is
1299
- stopped and replaced — so wiring it to `/readyz` would reintroduce the restart
1300
- loop for every non-Kubernetes orchestrator. If you want
1301
- `depends_on: condition: service_healthy` to mean "ready" rather than "alive",
1302
- override `healthcheck:` for that one service in your compose file instead of
1303
- changing the image.
1304
-
1305
- `/readyz` runs one probe per backing connection and reports each by name:
1306
-
1307
- ```json
1308
- {
1309
- "status": "unready",
1310
- "checks": [
1311
- { "name": "redis", "ok": true, "durationMs": 2 },
1312
- { "name": "session-store", "ok": false, "durationMs": 1001 }
1313
- ]
1314
- }
1315
- ```
1316
-
1317
- The failure's *reason* is deliberately absent from the body and goes to the log
1318
- instead (`readiness_probe_failed`, with each failing check's error as core's
1319
- `loggableError` projection). A driver
1320
- error reads `connect ECONNREFUSED 10.0.3.14:6379` — an internal host and port —
1321
- and this endpoint is unauthenticated because an orchestrator has no credentials
1322
- to present. Set `includeErrorDetail: true` on `createReadinessRouter` only when
1323
- the endpoint is reachable solely from inside the deployment.
1324
-
1325
- For the same reason, keep `/readyz` off the public listener. It sits ahead of
1326
- the auth router, so it is outside that router's rate limiter, and each request
1327
- issues one command per probe against the very dependency it reports on —
1328
- enough for an anonymous request loop to add load during an incident. Bind it to
1329
- the internal network, an admin listener, or your ingress' internal-only path
1330
- rules.
1331
-
1332
- Probes are registered by whichever builder opened the connection, so the list
1333
- reflects what this deployment actually wired: the shared ioredis client
1334
- (`redis`) and the connect-redis session client (`session-store`) when the
1335
- Redis adapters are selected, and **nothing at all** for a composition with no
1336
- Redis-backed module — which would therefore always be ready, having no
1337
- dependency to be unready for. The shipped composition is never that: its
1338
- refresh-token families are always in Redis, so `redis` is always probed.
1339
-
1340
- `http.readinessTimeoutMs` (env `HTTP_READINESS_TIMEOUT_MS`, default `1000`)
1341
- is the per-probe deadline. Keep it below the orchestrator's own probe timeout;
1342
- otherwise an unreachable dependency reads as a slow replica rather than an
1343
- unready one.
1344
-
1345
- ## Adding Custom Modules
1346
-
1347
- To add a custom module, import it in `src/buildModules.mts` and add it to the array `buildModules` returns. The boot planner resolves dependencies by what modules `require` and `provide`, but it mounts routes and middleware in list order wherever no `before` / `after` says otherwise. So position matters for middleware that other routes depend on — the session store module, rule 1 of [Module Composition Order](#module-composition-order). A route under a prefix another module also uses, such as `/oauth`, needs no particular position (rule 2), provided it parses its own body and scopes its parsers to exactly its own paths: `oauthEndpointsModule`'s router parses only its own routes', and a parser that ran for every request under the prefix would read other modules' bodies for them. A module that mounts nothing of the first kind can go at the end:
1348
-
1349
- ```diff
1350
- // src/buildModules.mts
1351
- +import { myCustomModule } from "./my-custom-module.mjs";
1352
- …
1353
- return [
1354
- sessionStoreModule,
1355
- …
1356
- ...(federationGrantsEnabled || mfaInstalled ? [subjectRevocationServiceModule] : []),
1357
- + myCustomModule,
1358
- ];
1359
- ```
1360
-
1361
- 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.
1362
-
1363
- 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.
1364
-
1365
- ### Shutdown guarantees
1366
-
1367
- `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.
1368
-
1369
- 1. **`SIGTERM` and `SIGINT`** both start it. A second signal is ignored rather than starting a second dispose over the first one's stores.
1370
- 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.
1371
- 3. **In-flight requests get `drainTimeoutMs`** (default **10s**) to finish.
1372
- 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.
1373
- 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.
1374
-
1375
- 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.
1376
-
1377
- Each stage logs one line:
1378
-
1379
- | Event | Level | Fields |
1380
- |---|---|---|
1381
- | `shutdown_draining` | info | `drainTimeoutMs`, `cleanupTimeoutMs` |
1382
- | `shutdown_drain_deadline_exceeded` | error | `drainTimeoutMs` |
1383
- | `shutdown_server_close_failed` | error | `err` |
1384
- | `shutdown_cleanup_timed_out` | error | `cleanupTimeoutMs` |
1385
- | `shutdown_cleanup_failed` | error | `err` |
1386
- | `shutdown_complete` | info | `reason`, `drain`, `exitCode` |
1387
-
1388
- `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.
1389
-
1390
- **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.
1391
-
1392
- ## npm Scripts
1393
-
1394
- | Script | Description |
1395
- |---|---|
1396
- | `pnpm run build` | Compile TypeScript to `dist/` |
1397
- | `pnpm run start` | Start the compiled server |
1398
- | `pnpm run debug` | Start with hot reload via `tsx watch` (development mode) |
1399
- | `pnpm run test` | Typecheck (`tsc --noEmit`), then run the tests with Vitest |
1400
-
1401
- ## See Also
1402
-
1403
- - [Operator runbook](../../docs/operator-runbook.md) — deployment shapes and boot refusals, probes, what each dependency failure looks like, events to alert on, Redis key families and failure timing, key rotation, upgrading and rollback
1404
- - [`@o3co/auth-provider-core`](../../packages/core) — Application factory and config schema
1405
- - [`@o3co/auth-provider-oauth`](../../packages/oauth) — OAuth module
1406
- - [`@o3co/auth-provider-session`](../../packages/session) — Session module
1407
- - [`@o3co/auth-provider-foundation`](../../packages/foundation) — Built-in adapter registration
1408
- - [`@o3co/create-auth-provider`](../../create-app) — CLI scaffolder that generates this template
1409
-
1410
- ## Observability
1411
-
1412
- ### Logging
1413
-
1414
- The provider logs newline-delimited JSON on stdout via [pino](https://getpino.io),
1415
- which is what a log aggregator ingests without a parser. `LOGGING_LEVEL` (HOCON
1416
- `logging.level`, the `logging` module's section, read before boot with that
1417
- module's schema) sets the threshold; `trace` and `debug` carry request-shaped
1418
- detail and are off by default, and pino drops sub-threshold calls before
1419
- formatting, so they cost nothing in production rather than being emitted and
1420
- filtered downstream.
1421
-
1422
- The logger is wired into the boot planner's `logger` component slot, so every
1423
- module that declares `optional: ["logger"]` — oauth, session, dpop, mtls,
1424
- token-exchange — logs through this one instance. Filling that slot is what makes
1425
- `LOGGING_LEVEL` reach them; a composition root that leaves it empty gets each
1426
- module's own `consoleLogger` default instead, and configuring a level changes
1427
- nothing.
1428
-
1429
- Events are structured and named: `logger.error({ err }, "session_store_redis_error")`.
1430
- Alert on the name, not on the message text.
1431
-
1432
- To swap the backend, replace `createAppLogger` in `src/logger.mts`. Anything
1433
- satisfying core's `Logger` works — the interface has pino's two-overload call
1434
- shape, so a pino-compatible logger needs no adapter.
1435
-
1436
- ### Audit trail
1437
-
1438
- Security-relevant events — who authenticated, what was issued, what was refused,
1439
- and when the rate limiter could not answer — go to the sink named by
1440
- `adapters.auditSink` (env `ADAPTERS_AUDIT_SINK`). The template ships `"logger"`, which
1441
- writes one event per line through the same pino stream as everything else:
1442
-
1443
- ```json
1444
- {"level":30,"time":1787841564013,"name":"audit","audit":{"timestamp":"2026-08-27T14:39:24.013Z","type":"token.issued.failure","ip":"203.0.113.7","details":{"reason":"unsupported_grant_type"}},"msg":"token.issued.failure"}
1445
- ```
1446
-
1447
- One output style, so an aggregator ingests application logs and audit events
1448
- through one parser and separates them on `name` (`"provider"` vs `"audit"`).
1449
- The event type is also the message, so alert on the name — `authorize.rejected`,
1450
- `token.issued.failure`, `rate_limit.unavailable` — the same way you would on
1451
- `session_store_redis_error`.
1452
-
1453
- `ADAPTERS_AUDIT_SINK=console` selects core's built-in sink instead: the bare event
1454
- as one JSON object per line, with no log envelope, for a pipeline that wants the
1455
- event and nothing else.
1456
-
1457
- **`LOGGING_LEVEL` does not gate the audit trail.** The audit stream's level is fixed
1458
- at `info`. `warn` is an ordinary production setting and `silent` a legitimate
1459
- one, and either silencing this would delete the record of every authentication
1460
- and issuance while looking like a logging preference. Where audit events go is
1461
- chosen by `adapters.auditSink` — and that selector has **no `"none"`**. An unknown
1462
- sink fails boot naming the sinks that are registered, rather than producing a
1463
- deployment with no audit trail. A composition that installs no sink on purpose
1464
- says so in core's own section, `core.declaredAbsent = ["auditSink"]`; this
1465
- template always installs one.
1466
-
1467
- To point it at a real sink — SIEM, log pipeline, message bus — register a
1468
- builder in `auditSinkModuleFor` (`src/modules.mts`) and select it; its options
1469
- are the `audit-sink` module's section, under the sink's name:
1470
-
1471
- ```ts
1472
- factory.register("splunk-hec", (cfg) => createSplunkSink(cfg));
1473
- ```
1474
-
1475
- ```hocon
1476
- adapters.auditSink = "splunk-hec"
1477
- audit-sink {
1478
- splunk-hec { endpoint = ${?SPLUNK_HEC_URL}, token = ${?SPLUNK_HEC_TOKEN} }
1479
- }
1480
- ```
1481
-
1482
- To add an audit destination beside the configured one, contribute
1483
- `auditHooks` from your own module; core fans out `auditSink` and `auditHooks`
1484
- together.
1485
-
1486
- Sinks are fire-and-forget by contract: core dispatches without awaiting and
1487
- swallows rejections, so a slow or failing sink cannot add latency to — or
1488
- fail — an auth flow. The flip side is that a sink which cannot deliver drops
1489
- the event. On its own a sink's failure is swallowed silently; once a module
1490
- contributes `auditHooks`, the fan-out logs each failing call at error as
1491
- `audit_sink_failed`, with the sink's position and the event's `type`. The
1492
- drops are not counted yet — see **Not published yet** under Metrics below.
1493
-
1494
- ### Metrics
1495
-
1496
- `GET /metrics` serves the Prometheus text exposition format.
1497
-
1498
- | Metric | Type | What it answers |
1499
- |---|---|---|
1500
- | `http_request_duration_seconds` | histogram | Request rate, error rate and latency, by `method` / `route` / `status` |
1501
- | `auth_dependency_up` | gauge | Whether each backing dependency answered its readiness probe (`dependency="redis"`, `"session-store"`) |
1502
- | `auth_provider_*` | various | Node process defaults — event-loop lag, heap, GC, handles |
1503
-
1504
- `auth_dependency_up` is the series that separates "Redis is gone" from "the app
1505
- is slow", which is otherwise only distinguishable by grepping container logs. It
1506
- reuses the probes the adapter builders already registered, so it reflects what
1507
- this deployment actually wired rather than a second, drifting list. Dependencies
1508
- are re-sampled on every scrape — a cached verdict would report healthy for
1509
- exactly as long as an incident lasts — and in-flight probes are joined rather
1510
- than re-issued, so a scrape loop does not stack commands on a dependency that is
1511
- already in trouble.
1512
-
1513
- Route labels use the Express route **pattern** (`/oauth/token`,
1514
- `/api/widgets/:id`), never the URL, and unmatched requests collapse to
1515
- `route="unmatched"`. Labelling by path would mint a series per distinct URL, and
1516
- this server's paths carry opaque values — that is how a metrics endpoint takes
1517
- down the monitoring that was supposed to watch it.
1518
-
1519
- A scrape example:
1520
-
1521
- ```yaml
1522
- scrape_configs:
1523
- - job_name: auth-provider
1524
- static_configs:
1525
- - targets: ["auth-provider:3000"]
1526
- ```
1527
-
1528
- Keep `/metrics` off the public listener, for the same reason as `/readyz`: it is
1529
- unauthenticated, it sits ahead of the auth router and so outside its rate
1530
- limiter, and each scrape samples the readiness probes.
1531
-
1532
- **Not published yet:** rate-limiter fail-closed counts and audit-sink drops.
1533
- Both happen inside `@o3co/auth-provider-core` and are not observable from the
1534
- composition root; counting them needs a metrics hook in core rather than
1535
- something this template can reach. Until then, alert on the
1536
- `rate_limiter_failed_closed` and `rate_limit.unavailable` log events.