@o3co/create-auth-provider 0.16.0 → 0.17.0-rc.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.ja.md +66 -21
- package/README.md +76 -31
- package/dist/index.d.mts +33 -10
- package/dist/index.d.mts.map +1 -1
- package/dist/index.mjs +118 -59
- package/dist/internal/template-filter.d.mts +7 -10
- package/dist/internal/template-filter.d.mts.map +1 -1
- package/dist/internal/template-filter.mjs +7 -10
- package/package.json +6 -4
- package/templates/standalone/.dockerignore +15 -2
- package/templates/standalone/.env.example +59 -29
- package/templates/standalone/Dockerfile +6 -2
- package/templates/standalone/README.ja.md +324 -169
- package/templates/standalone/README.md +618 -208
- package/templates/standalone/config/application.conf +211 -352
- package/templates/standalone/config/clients.yaml +2 -0
- package/templates/standalone/config/clients.yaml.example +4 -0
- package/templates/standalone/config/development.conf +13 -4
- package/templates/standalone/config/mailpit.conf +8 -0
- package/templates/standalone/config/production.conf +5 -6
- package/templates/standalone/config/reference.conf +418 -0
- package/templates/standalone/docker-compose.mailpit.yml +36 -0
- package/templates/standalone/docker-compose.production.yml +63 -32
- package/templates/standalone/docker-compose.yml +13 -3
- package/templates/standalone/gitignore +1 -0
- package/templates/standalone/package.json +9 -6
- package/templates/standalone/src/__tests__/adapters.test.mts +419 -0
- package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +287 -198
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +71 -35
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +148 -94
- package/templates/standalone/src/__tests__/audit-sink.test.mts +84 -81
- package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
- package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
- package/templates/standalone/src/__tests__/core-federations.test.mts +249 -0
- package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
- package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +10 -15
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +970 -288
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +421 -86
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +87 -72
- package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +16 -8
- package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +97 -93
- package/templates/standalone/src/__tests__/host-routes.test.mts +4 -11
- package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
- package/templates/standalone/src/__tests__/library-references.fixture.mts +188 -0
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
- package/templates/standalone/src/__tests__/listen.test.mts +6 -13
- package/templates/standalone/src/__tests__/logger-projection.test.mts +1 -1
- package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +326 -0
- package/templates/standalone/src/__tests__/metrics.test.mts +4 -9
- package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +825 -0
- package/templates/standalone/src/__tests__/own-modules.test.mts +1486 -0
- package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +10 -14
- package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +1 -5
- package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +116 -81
- package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
- package/templates/standalone/src/__tests__/replica-safety.test.mts +265 -146
- package/templates/standalone/src/__tests__/router-error-answers.test.mts +4 -7
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +278 -103
- package/templates/standalone/src/__tests__/sections.test.mts +480 -0
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +69 -61
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
- package/templates/standalone/src/__tests__/session-requirements.test.mts +382 -0
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
- package/templates/standalone/src/__tests__/shutdown.test.mts +163 -96
- package/templates/standalone/src/__tests__/smoke.test.mts +176 -258
- package/templates/standalone/src/__tests__/store-credential.test.mts +88 -52
- package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +131 -102
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +604 -0
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +382 -0
- package/templates/standalone/src/adapters.mts +162 -0
- package/templates/standalone/src/app.mts +65 -79
- package/templates/standalone/src/bootRefusal.mts +117 -0
- package/templates/standalone/src/buildModules.mts +220 -309
- package/templates/standalone/src/configPath.mts +449 -29
- package/templates/standalone/src/listen.mts +12 -18
- package/templates/standalone/src/logger.mts +31 -60
- package/templates/standalone/src/metrics.mts +24 -47
- package/templates/standalone/src/mfaSwitch.mts +285 -0
- package/templates/standalone/src/modules.mts +423 -629
- package/templates/standalone/src/rootRenames.mts +96 -0
- package/templates/standalone/src/routes.mts +8 -14
- package/templates/standalone/src/sections.mts +367 -0
- package/templates/standalone/src/shutdown.mts +45 -123
- package/templates/standalone/tests/index.test.js +6 -5
- package/templates/standalone/tests/package-lock.json +1615 -2223
- package/templates/standalone/tests/package.json +2 -2
- package/templates/standalone/tests/vitest.config.mjs +26 -0
- package/templates/standalone/vitest.config.mts +8 -7
- package/templates/standalone/vitest.supertest-loopback.mts +97 -69
- package/templates/versions.json +18 -15
- package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -135
- package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -199
- package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -138
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @o3co/auth-provider-standalone
|
|
2
2
|
|
|
3
|
-
Last updated: 2026-
|
|
3
|
+
Last updated: 2026-10-06
|
|
4
4
|
|
|
5
5
|
Deployable server template for auth.provider. This is the composition root — it reads config, loads modules, and starts the Express server. Generated by `@o3co/create-auth-provider`.
|
|
6
6
|
|
|
@@ -11,20 +11,27 @@ not a library: nothing imports it. `@o3co/create-auth-provider` copies it into
|
|
|
11
11
|
a new project, and from then on the operator owns and edits the copy. It sits
|
|
12
12
|
on top of the packages: it composes `@o3co/auth-provider-oauth`, `-session`,
|
|
13
13
|
the Google and generic OpenID Connect federation adapters, `-federation-grants`,
|
|
14
|
-
`-redis` and `-
|
|
14
|
+
`-mfa`, `-redis`, `-foundation` and `-standard`'s mail senders over
|
|
15
|
+
`@o3co/auth-provider-core`.
|
|
15
16
|
|
|
16
17
|
**Owns** the choices that are specific to one deployment:
|
|
17
18
|
|
|
18
19
|
- which modules are composed, in what order, and which adapter fills each
|
|
19
20
|
store slot — [`src/buildModules.mts`](src/buildModules.mts) (see
|
|
20
|
-
[Module Composition Order](#module-composition-order))
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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;
|
|
28
35
|
- where configuration comes from and how its layers stack —
|
|
29
36
|
[`src/configPath.mts`](src/configPath.mts) and [`config/`](config/);
|
|
30
37
|
- the host process: the Express app, its security headers and startup —
|
|
@@ -71,7 +78,7 @@ repository.
|
|
|
71
78
|
- **Node.js** `>=22.0.0`
|
|
72
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.
|
|
73
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`.
|
|
74
|
-
- **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 `
|
|
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`).
|
|
75
82
|
|
|
76
83
|
## First-party clients
|
|
77
84
|
|
|
@@ -79,7 +86,7 @@ For a client marked `firstParty: true`, `GET /authorize` mints an authorization
|
|
|
79
86
|
|
|
80
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.
|
|
81
88
|
|
|
82
|
-
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 (`
|
|
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**.
|
|
83
90
|
|
|
84
91
|
```yaml
|
|
85
92
|
# config/clients.yaml
|
|
@@ -97,13 +104,13 @@ Nothing secret lives in `clients.yaml` for such a client.
|
|
|
97
104
|
|
|
98
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.
|
|
99
106
|
|
|
100
|
-
**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
|
|
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.
|
|
101
108
|
|
|
102
109
|
## Multi-replica deployments
|
|
103
110
|
|
|
104
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.
|
|
105
112
|
|
|
106
|
-
When running multiple instances of the standalone server behind a load balancer, point `
|
|
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.
|
|
107
114
|
|
|
108
115
|
**Set `HTTP_TRUST_PROXY` to your proxy's address behind a load balancer.** It defaults to `false`, which makes `req.ip` the *load balancer's* address rather than the client's. Every rate limit keyed by IP — the OAuth endpoint limiters and the `POST /session/login` brute-force guard — then shares **one bucket across all users**: the first 20 login attempts from anyone exhaust the window and every subsequent user gets `429`. The failure reads like an attack rather than a misconfiguration, which is what makes it expensive to diagnose at 3am. Set it whenever anything terminates TLS or proxies in front of this service, and make sure that hop is the one setting `X-Forwarded-For`. An audit event with no `ip` means the request's address was not an address — `req.ip` came from an `X-Forwarded-For` that held something else — so check that `HTTP_TRUST_PROXY` trusts only the hop that sets the header.
|
|
109
116
|
|
|
@@ -120,7 +127,11 @@ It takes four shapes, all of them Express's own:
|
|
|
120
127
|
|
|
121
128
|
An allowlist is a network control, not a cryptographic one. The edge must also **strip** inbound `X-Forwarded-*` headers rather than appending to them, and the hop between it and this process must not be reachable by anyone able to spoof a source address.
|
|
122
129
|
|
|
123
|
-
**Point `rateLimiter
|
|
130
|
+
**Point `adapters.rateLimiter` at Redis.** It defaults to `"memory"`, which is per-process: with N replicas every configured limit is effectively N times larger and resets on every deploy. The memory adapter is also **evadable under bucket exhaustion**: it caps itself at 10,000 buckets and, at the cap, admitting a new key evicts the bucket closest to reset — so an attacker who can present many source IPs (and `req.ip` is client-influenced whenever `HTTP_TRUST_PROXY` is broader than your actual hops) can churn the table until a target's counter is evicted and starts over. That is acceptable for one dev process; it is not a production rate limit. `/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.
|
|
131
|
+
|
|
132
|
+
**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.
|
|
133
|
+
|
|
134
|
+
**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 reads; when the server will not tell it (`INFO` and `CONFIG` refused or renamed) it boots and warns `attempt_counter_durability_unchecked`, so confirm the policy where the server is configured. Both fail closed: while the counter cannot answer, `/session/login` and `POST /oauth/device/verification` answer `503`, whatever `redis-rate-limiter.failMode` says.
|
|
124
135
|
|
|
125
136
|
**Behind a BFF, raise `limits.token` before you need to.** The OAuth-endpoint
|
|
126
137
|
rate limits key on `req.ip` (`packages/core/src/ratelimit/guard.mts` builds the
|
|
@@ -143,10 +154,11 @@ its address is the honest answer to "who called". There is no per-user identity
|
|
|
143
154
|
in these requests to key on.
|
|
144
155
|
|
|
145
156
|
The knob is the per-endpoint budget. Both limiter adapters take
|
|
146
|
-
`limits { <prefix> { limit, windowSeconds } }
|
|
157
|
+
`limits { <prefix> { limit, windowSeconds } }` in their own sections, keyed by
|
|
158
|
+
the endpoint prefix:
|
|
147
159
|
|
|
148
160
|
```hocon
|
|
149
|
-
|
|
161
|
+
redis-rate-limiter {
|
|
150
162
|
limits {
|
|
151
163
|
token { limit = 600, windowSeconds = 60 }
|
|
152
164
|
introspect { limit = 600, windowSeconds = 60 }
|
|
@@ -154,9 +166,9 @@ redisRateLimiter {
|
|
|
154
166
|
}
|
|
155
167
|
```
|
|
156
168
|
|
|
157
|
-
(`
|
|
169
|
+
(`core-rate-limiter-memory.limits` for the memory adapter, same shape.) Size it from
|
|
158
170
|
your BFF's peak sign-ins and refreshes per minute with headroom, and remember
|
|
159
|
-
that under `rateLimiter
|
|
171
|
+
that under `adapters.rateLimiter = "redis"` the budget is shared across replicas
|
|
160
172
|
— which is what you want here, and what makes the number mean something.
|
|
161
173
|
|
|
162
174
|
The defaults are deliberately left alone: 60/60 s per source IP is a sensible
|
|
@@ -165,21 +177,23 @@ raising it globally to accommodate one topology would weaken it for the other.
|
|
|
165
177
|
Put the throttling that protects the BFF's *own* users in front of the BFF,
|
|
166
178
|
where per-user identity still exists.
|
|
167
179
|
|
|
168
|
-
**Set `
|
|
180
|
+
**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).
|
|
181
|
+
|
|
182
|
+
The variable sets `core.deployment.mode`. `DEPLOYMENT_MODE`, its old name, refuses boot set alone or beside `CORE_DEPLOYMENT_MODE` at a different value; beside it at the same value, it boots.
|
|
169
183
|
|
|
170
|
-
Be aware of what this check *cannot* do: if you scale to N replicas without ever setting `
|
|
184
|
+
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.
|
|
171
185
|
|
|
172
|
-
**Access-token revocation needs a denylist, and the template ships one.** `POST /oauth/revoke` revokes an access token by writing its `jti` to the `accessTokenDenylist`, which token verification and introspection consult. Boot *fails* if the endpoint is mounted with no denylist behind it: RFC 7009 obliges the endpoint to answer `200`, so an unwired denylist would tell you a token is revoked while it keeps working until it expires. `accessTokenDenylist
|
|
186
|
+
**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.**
|
|
173
187
|
|
|
174
188
|
Other multi-replica considerations covered by the default modules:
|
|
175
189
|
|
|
176
|
-
- The `express-session` store (`sessionStoreModule`) is its own connection: `
|
|
177
|
-
- The user-session stores switch on `userSessionStores
|
|
178
|
-
- The authorization-code repository switches on `
|
|
179
|
-
- The replay seen-set — the `jti` single-use record behind `private_key_jwt` client authentication (#484) — switches on `replaySeenSet
|
|
180
|
-
- The consent step for clients that are not first-party (#527) switches on `consentStore
|
|
181
|
-
- The federation token store defaults to memory. Set `
|
|
182
|
-
- Federation grants (#593), when enabled, keep two more stores: the grants themselves (`
|
|
190
|
+
- 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.
|
|
191
|
+
- 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.
|
|
192
|
+
- 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)).
|
|
193
|
+
- 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.
|
|
194
|
+
- 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).
|
|
195
|
+
- 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).
|
|
196
|
+
- 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).
|
|
183
197
|
|
|
184
198
|
## Usage
|
|
185
199
|
|
|
@@ -189,26 +203,37 @@ shell that starts it — and it does not boot until that environment supplies:
|
|
|
189
203
|
|
|
190
204
|
- `OAUTH_JWT_ISSUER` — this server's origin (`http` is accepted for a loopback
|
|
191
205
|
host);
|
|
192
|
-
- a signing key pair, `
|
|
206
|
+
- a signing key pair, `KEY_STORE_LOCAL_PRIVATE_KEY_PATH` / `KEY_STORE_LOCAL_PUBLIC_KEY_PATH`
|
|
193
207
|
(see [OAuth JWT](#oauth-jwt));
|
|
194
|
-
- `
|
|
195
|
-
- `
|
|
208
|
+
- `SESSION_STORE_SECRET`, at least 32 bytes;
|
|
209
|
+
- `REPOSITORIES_USER_HTTP_AUTHENTICATE_URL` and `REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL` —
|
|
196
210
|
your user service ("the Store"), `https`, or `http` on a loopback host. Boot
|
|
197
211
|
checks their form, not that anything answers; logins fail until something
|
|
198
212
|
does (see [User Repository](#user-repository));
|
|
199
213
|
- a Redis it can reach at `redis://localhost:6379`. The shipped configuration
|
|
200
|
-
keeps the browser session store there (`
|
|
201
|
-
opens one shared connection (`
|
|
214
|
+
keeps the browser session store there (`SESSION_STORE_STORAGE_REDIS_URL`), and
|
|
215
|
+
opens one shared connection (`REDIS_CLIENTS_URL`) for the
|
|
202
216
|
refresh-token families, the authorization codes, the access-token denylist
|
|
203
217
|
and the replay seen-set — and for the user-session stores, with the setting
|
|
204
218
|
below.
|
|
205
219
|
|
|
206
|
-
Plain HTTP also needs `
|
|
220
|
+
Plain HTTP also needs `SESSION_STORE_SECURE=false` and a session cookie name without
|
|
207
221
|
the `__Host-` prefix (see [Session](#session)). And set
|
|
208
|
-
`
|
|
222
|
+
`ADAPTERS_USER_SESSION_STORES=redis`: the browser session store is in Redis, the
|
|
209
223
|
user-session stores default to memory, and `tsx watch` restarts the process on
|
|
210
224
|
every save — split, a restart leaves the browser's session pointing at a
|
|
211
225
|
`UserSession` that is gone, and `/authorize` loops (see [Docker](#docker)).
|
|
226
|
+
MFA is on by default, and its two stores default to memory too: set
|
|
227
|
+
`ADAPTERS_MFA_FACTOR_STORE=redis` and `ADAPTERS_MFA_TRANSACTION_STORE=redis`
|
|
228
|
+
for the same reason, or every save empties them while the sessions survive —
|
|
229
|
+
or `MFA_MODE=off` to run without MFA (see
|
|
230
|
+
[Multi-factor authentication](#multi-factor-authentication)).
|
|
231
|
+
|
|
232
|
+
It also reads the client registry, `config/clients.yaml` (`REPOSITORIES_CLIENT_YAML_PATH`). That
|
|
233
|
+
file is per-deployment: the scaffold creates an empty one, and `.gitignore`
|
|
234
|
+
keeps it out of the project's repository, so a fresh clone has none and boot
|
|
235
|
+
fails naming the path until you create it — from `config/clients.yaml.example`,
|
|
236
|
+
or empty for a registry with no clients.
|
|
212
237
|
|
|
213
238
|
The key pair below is generated with Node, which every machine running this
|
|
214
239
|
already has. `openssl genpkey -algorithm ed25519` works too, with OpenSSL 1.1.1
|
|
@@ -229,13 +254,14 @@ docker run -d --name auth-redis -p 127.0.0.1:6379:6379 redis:7.2-alpine
|
|
|
229
254
|
|
|
230
255
|
# What boot requires (replace the user-service URLs with your Store's)
|
|
231
256
|
export OAUTH_JWT_ISSUER=http://localhost:3000 \
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
257
|
+
KEY_STORE_LOCAL_PRIVATE_KEY_PATH=./jwt-private.pem \
|
|
258
|
+
KEY_STORE_LOCAL_PUBLIC_KEY_PATH=./jwt-public.pem \
|
|
259
|
+
SESSION_STORE_SECRET="$(openssl rand -hex 32)" \
|
|
260
|
+
SESSION_STORE_SECURE=false SESSION_STORE_NAME=auth.sid \
|
|
261
|
+
ADAPTERS_USER_SESSION_STORES=redis \
|
|
262
|
+
ADAPTERS_MFA_FACTOR_STORE=redis ADAPTERS_MFA_TRANSACTION_STORE=redis \
|
|
263
|
+
REPOSITORIES_USER_HTTP_AUTHENTICATE_URL=http://localhost:8080/authenticate \
|
|
264
|
+
REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL=http://localhost:8080/authenticate-by-token
|
|
239
265
|
|
|
240
266
|
# Start in development mode (hot reload)
|
|
241
267
|
pnpm run debug
|
|
@@ -256,63 +282,179 @@ image and its compose file are under [Docker](#docker).
|
|
|
256
282
|
|
|
257
283
|
## Configuration
|
|
258
284
|
|
|
259
|
-
Configuration is loaded from `config/application.conf` (HOCON format). Each value can be overridden with the corresponding environment variable.
|
|
285
|
+
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.
|
|
260
286
|
|
|
261
287
|
### Environment-specific config overlay
|
|
262
288
|
|
|
263
|
-
`src/app.mts` loads configuration in
|
|
289
|
+
`src/app.mts` loads configuration in layers, highest precedence first:
|
|
264
290
|
|
|
265
291
|
1. **`config/{ENV}.conf`** — overlay for the current environment, where
|
|
266
292
|
`ENV = CONFIG_ENV || NODE_ENV || "development"`.
|
|
267
293
|
2. **`config/application.conf`** — this deployment's settings.
|
|
268
|
-
3.
|
|
269
|
-
resolved from the
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
294
|
+
3. **the `reference.conf` of each package the loaded modules come from**, then
|
|
295
|
+
**`@o3co/auth-provider-core`'s** — the defaults, resolved from the
|
|
296
|
+
installed packages: each module declares its package's file, and core's
|
|
297
|
+
`moduleReferences(modules)` lists them, core's last. The template's own
|
|
298
|
+
modules declare `config/reference.conf`, which holds the defaults of the
|
|
299
|
+
sections they own — `logging`, `http` (with its CORS list,
|
|
300
|
+
`http.cors`), `key-store`, the shared Redis connection's `redis-clients`,
|
|
301
|
+
`repositories`, the in-process code repository's
|
|
302
|
+
`standalone-in-memory-code-repository` and `audit-sink` — and of the
|
|
303
|
+
composition root's own `adapters` and `mfaMode` (below), and the MFA
|
|
304
|
+
routes' budget in both rate limiters' sections
|
|
305
|
+
(`core-rate-limiter-memory.limits.mfa`, `redis-rate-limiter.limits.mfa`;
|
|
306
|
+
see [Multi-replica deployments](#multi-replica-deployments)). Set a deployment's own value in
|
|
307
|
+
the two files above, not there. `config/reference.conf` binds each of these
|
|
308
|
+
keys' variables beside its default, so a value either file above sets wins
|
|
309
|
+
over the variable, except for `HTTP_PORT`, `HTTP_TRUST_PROXY`,
|
|
310
|
+
`HTTP_CORS_ALLOWED_ORIGINS` and `REDIS_CLIENTS_URL` /
|
|
311
|
+
`REDIS_CLIENTS_PASSWORD`, which `application.conf` binds again in its last
|
|
312
|
+
lines, so each of those wins over a value that file sets above them. A key
|
|
313
|
+
neither file above sets takes its value from here.
|
|
314
|
+
|
|
315
|
+
It reads the two files above once, under one snapshot of the environment
|
|
316
|
+
(`readOwnLayers`), and builds two phases from that one read
|
|
317
|
+
([#728](https://github.com/o3co/auth.provider/issues/728);
|
|
318
|
+
[`src/configPath.mts`](src/configPath.mts)) — so a file replaced, or a
|
|
319
|
+
variable changed, while the process starts cannot make boot parse something
|
|
320
|
+
other than what the modules were chosen by. First, before it knows its
|
|
321
|
+
modules, it reads what `buildModules` chooses them by — never a federation
|
|
322
|
+
entry — from the two files above over the template's `config/reference.conf`
|
|
323
|
+
alone (`readSwitches`). It reads `adapters` — which adapter fills each slot,
|
|
324
|
+
the composition root's own section — with the template's own schema
|
|
325
|
+
(`readAdapters`): a selection still written at the path it moved from, or a
|
|
326
|
+
variable renamed with it, is refused there, before any module is chosen,
|
|
327
|
+
naming the new path and variable. It reads the MFA switch `mfaMode`
|
|
328
|
+
(`MFA_MODE`), the composition root's own key, the same way, with the
|
|
329
|
+
template's own schema (`readMfaSwitch`), and beside them the user
|
|
330
|
+
repository's HTTP settings, unparsed, which the Store-backed MFA factor store
|
|
331
|
+
is built over. It reads whether federation grants are installed from
|
|
332
|
+
`federation-grants.enabled` (`FEDERATION_GRANTS_ENABLED`, which
|
|
333
|
+
`application.conf` binds for this), with core's `coerceBooleanFromEnv`: a
|
|
334
|
+
value that does not read as `false` installs the federation-grants modules,
|
|
335
|
+
so that boot refuses a bad value at `federation-grants.enabled` rather than
|
|
336
|
+
the feature reading as off, and a setting at the section's old path,
|
|
337
|
+
`federationGrants`, installs them too, for boot to refuse naming the new
|
|
338
|
+
path. Boot is not handed `adapters` or `mfaMode`. Phase one reads no other
|
|
339
|
+
key, and sees nothing a package's `reference.conf` alone sets — none is
|
|
340
|
+
layered yet. A module you add to `buildModules` reads its own section at
|
|
341
|
+
boot (`deps.section`), never when it is built. The log level is read before boot too, as
|
|
342
|
+
the `logging` module's section, with that module's schema, over the template's
|
|
343
|
+
`reference.conf` (`readLogging`): the logger exists before boot, since the
|
|
344
|
+
template logs while it reads its configuration and chooses its modules.
|
|
345
|
+
What the template refuses of the configuration before boot — `adapters`,
|
|
346
|
+
`mfaMode` and `logging` in phase one, and what it builds for boot below
|
|
347
|
+
(`resolveForBoot`) — is a `BootError` under the reason boot raises for the
|
|
348
|
+
same case (`config-path-relocated`, `environment-variable-renamed`,
|
|
349
|
+
`config-validation-failed`, `module-section-path-invalid`;
|
|
350
|
+
[`src/bootRefusal.mts`](src/bootRefusal.mts)), so an alert on a boot error's
|
|
351
|
+
reason sees it too. Not every failure to start carries a reason: a
|
|
352
|
+
`CONFIG_ENV` naming a file outside `config/` is a plain `Error`, a missing
|
|
353
|
+
`{ENV}.conf` or a file HOCON cannot parse is the HOCON library's error, and
|
|
354
|
+
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
|
|
355
|
+
loaded module's `reference.conf` (`resolveForBoot`), unparsed, with
|
|
356
|
+
`core.sessionRequirements` as the MFA switch expects it written in — the
|
|
357
|
+
configuration's list with `mfa` added when the switch installs MFA; a value
|
|
358
|
+
that is not a list of names is handed on as written, for boot to refuse — and
|
|
359
|
+
the `mfa` section as the MFA switch decides it (see [Multi-factor authentication](#multi-factor-authentication)):
|
|
360
|
+
boot parses it once, with every loaded module's schema. What
|
|
361
|
+
the template reads after boot — the trusted hops, the port, the readiness
|
|
362
|
+
deadline — it reads from the `http` module: core's `httpSettings` slot and the
|
|
363
|
+
template's `httpHostSettings`. Beside it, the template hands boot the
|
|
364
|
+
configuration's defaults (`configDefaultsFor`): the same `reference.conf`
|
|
365
|
+
files with neither of the two files above and no environment. Boot names a
|
|
366
|
+
top-level section no loaded module owns by them, once, at warn, and keeps it:
|
|
367
|
+
a section a loaded package's `reference.conf` sets for a module the
|
|
368
|
+
composition does not load (`redis-clients` with no store on Redis, say) is
|
|
369
|
+
silent while it is as that file sets it, and `config_sections_not_loaded`
|
|
370
|
+
once your files or a variable change it — the setting reaches nothing; a
|
|
371
|
+
section no loaded package sets is `config_sections_ignored`, which is where a
|
|
372
|
+
misspelt section name shows. A renamed variable a loaded package's
|
|
373
|
+
`reference.conf` captures and no loaded module declares is named
|
|
374
|
+
`environment_variables_not_applied`. The [operator
|
|
375
|
+
runbook](../../docs/operator-runbook.md) has a row for each.
|
|
273
376
|
|
|
274
377
|
Values in the overlay take precedence over `application.conf`. The scaffold
|
|
275
|
-
ships with `development.conf` and `
|
|
378
|
+
ships with `development.conf`, `production.conf` and `mailpit.conf` (the
|
|
379
|
+
development overlay under the name the
|
|
380
|
+
[Mailpit overlay](#mailpit-for-the-smtp-sender-development-only) selects). To add another
|
|
276
381
|
environment (e.g. `staging`), create `config/staging.conf` and set
|
|
277
382
|
`CONFIG_ENV=staging`. A missing `{ENV}.conf` is a boot-time error — typos
|
|
278
383
|
fail fast rather than silently falling back to defaults.
|
|
279
384
|
|
|
385
|
+
### Adapters
|
|
386
|
+
|
|
387
|
+
Which adapter fills each slot is the composition root's own choice, its own
|
|
388
|
+
section `adapters`, read before any module is chosen (above). Each key has its
|
|
389
|
+
variable; a value the template does not know, or a key it does not declare,
|
|
390
|
+
fails before boot, naming it.
|
|
391
|
+
|
|
392
|
+
| Variable | Default | Description |
|
|
393
|
+
|---|---|---|
|
|
394
|
+
| `ADAPTERS_RATE_LIMITER` | `memory` | `adapters.rateLimiter`: `memory` or `redis`. See [Multi-replica deployments](#multi-replica-deployments) |
|
|
395
|
+
| `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) |
|
|
396
|
+
| `ADAPTERS_USER_SESSION_STORES` | `memory` | `adapters.userSessionStores`: the user-session stores, `memory` or `redis`. Keep it with `SESSION_STORE_STORAGE_TYPE` (see [Docker](#docker)) |
|
|
397
|
+
| `ADAPTERS_ACCESS_TOKEN_DENYLIST` | `redis` | `adapters.accessTokenDenylist`: `memory` or `redis` |
|
|
398
|
+
| `ADAPTERS_REPLAY_SEEN_SET` | `redis` | `adapters.replaySeenSet`: the replay seen-set behind `private_key_jwt`, `memory` or `redis` |
|
|
399
|
+
| `ADAPTERS_CONSENT_STORE` | `none` | `adapters.consentStore`: `none`, `memory` or `redis`. See [Consent Store](#consent-store) |
|
|
400
|
+
| `ADAPTERS_FEDERATION_TOKEN_STORE` | `memory` | `adapters.federationTokenStore`: `memory` or `redis`. See [Federation Token Store](#federation-token-store) |
|
|
401
|
+
| `ADAPTERS_FEDERATION_GRANT_STORE` | `none` | `adapters.federationGrantStore`: `none`, `memory` or `redis`. See [Federation Grants](#federation-grants) |
|
|
402
|
+
| `ADAPTERS_FEDERATION_GRANT_INTENT_STORE` | `none` | `adapters.federationGrantIntentStore`: `none`, `memory` or `redis`. See [Federation Grants](#federation-grants) |
|
|
403
|
+
| `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) |
|
|
404
|
+
| `ADAPTERS_MFA_TRANSACTION_STORE` | `memory` | `adapters.mfaTransactionStore`: `memory` (development and test only) or `redis`, read while `MFA_MODE` installs MFA |
|
|
405
|
+
| `ADAPTERS_CODE_REPOSITORY` | `redis` | `adapters.codeRepository`: the authorization-code repository, `memory` or `redis`. See [Code Repository](#code-repository) |
|
|
406
|
+
| `ADAPTERS_CLIENT_REPOSITORY` | `yaml` | `adapters.clientRepository`: `yaml`, or `static` (core's alias of `yaml`). See [Client Repository](#client-repository) |
|
|
407
|
+
| `ADAPTERS_USER_REPOSITORY` | `http` | `adapters.userRepository`: `http`, `yaml` or `static` (core's alias of `yaml`). See [User Repository](#user-repository) |
|
|
408
|
+
| `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) |
|
|
409
|
+
|
|
410
|
+
A selection still written where it was — `rateLimiter.adapter`,
|
|
411
|
+
`oauth.code.adapter`, `audit.sink.type`, `repositories.user.type` and the
|
|
412
|
+
like — or a variable renamed with it (`RATE_LIMITER_ADAPTER`, `CLIENT_TYPE`,
|
|
413
|
+
…) set alone or to a different value, fails before boot, naming the new path
|
|
414
|
+
and variable; the old variable beside the new one at the same value is
|
|
415
|
+
accepted.
|
|
416
|
+
|
|
280
417
|
### HTTP
|
|
281
418
|
|
|
282
419
|
| Variable | Default | Description |
|
|
283
420
|
|---|---|---|
|
|
284
|
-
| `HTTP_PORT` | `3000` | Port the server listens on |
|
|
421
|
+
| `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 |
|
|
285
422
|
| `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) |
|
|
286
423
|
| `HTTP_READINESS_TIMEOUT_MS` | `1000` | Per-probe deadline for `/readyz` (see [Health endpoints](#health-endpoints)) |
|
|
287
|
-
| `
|
|
288
|
-
| `AUDIT_SINK_TYPE` | `logger` | Where security events go: `logger` (pino-enveloped NDJSON on stdout) or `console` (bare event JSON). There is no `none`. See [Audit trail](#audit-trail) |
|
|
424
|
+
| `LOGGING_LEVEL` | `info` | Minimum level emitted: `trace`\|`debug`\|`info`\|`warn`\|`error`\|`fatal`\|`silent`. Does **not** gate the audit trail — see [Audit trail](#audit-trail) |
|
|
289
425
|
|
|
290
426
|
### OAuth JWT
|
|
291
427
|
|
|
428
|
+
The signing key is the `key-store` module's section: `key-store.provider`
|
|
429
|
+
(`KEY_STORE_PROVIDER`, `local`) and the local key store's `key-store.local`
|
|
430
|
+
(`KEY_STORE_LOCAL_*`). The issuer and the token format stay under `oauth.jwt`.
|
|
431
|
+
|
|
292
432
|
| Variable | Default | Description |
|
|
293
433
|
|---|---|---|
|
|
294
|
-
| `
|
|
295
|
-
| `
|
|
434
|
+
| `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. |
|
|
435
|
+
| `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. |
|
|
296
436
|
| `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. |
|
|
297
437
|
| `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. |
|
|
298
|
-
| `
|
|
299
|
-
| `
|
|
300
|
-
| `
|
|
301
|
-
| `
|
|
302
|
-
| `
|
|
303
|
-
| `
|
|
304
|
-
| `
|
|
305
|
-
| `
|
|
306
|
-
| `
|
|
307
|
-
| `
|
|
308
|
-
| `
|
|
309
|
-
| `
|
|
310
|
-
| `
|
|
311
|
-
| `
|
|
312
|
-
| `
|
|
313
|
-
| `
|
|
314
|
-
| `
|
|
438
|
+
| `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`. |
|
|
439
|
+
| `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. |
|
|
440
|
+
| `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`. |
|
|
441
|
+
| `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. |
|
|
442
|
+
| `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_DENIED_HOSTS` | — | Comma-separated hosts refused even when allowed above. |
|
|
443
|
+
| `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_BYTES` | `5120` | Byte cap on a document (the draft recommends 5 KB). |
|
|
444
|
+
| `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_TIMEOUT_MS` | `5000` | Fetch timeout. |
|
|
445
|
+
| `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. |
|
|
446
|
+
| `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. |
|
|
447
|
+
| `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_STALE_IF_ERROR_MS` | `300000` | How long a registration already validated is still served after a revalidation that failed for a reason that is not the document's (a DNS blip, a 5xx, a timeout) — an outage should not break a working client. A document that was *rejected* is dropped immediately. `0` disables it. |
|
|
448
|
+
| `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. |
|
|
449
|
+
| `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. |
|
|
450
|
+
| `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) |
|
|
451
|
+
| `KEY_STORE_LOCAL_PRIVATE_KEY` | — | PEM-encoded private key (asymmetric algorithms) |
|
|
452
|
+
| `KEY_STORE_LOCAL_PRIVATE_KEY_PATH` | — | Path to PEM private key file |
|
|
453
|
+
| `KEY_STORE_LOCAL_PUBLIC_KEY` | — | PEM-encoded public key |
|
|
454
|
+
| `KEY_STORE_LOCAL_PUBLIC_KEY_PATH` | — | Path to PEM public key file |
|
|
315
455
|
| `OAUTH_JWT_LEGACY_TYP_ACCEPT` | `false` | Accept tokens whose `typ` header is absent. `false` rejects them, treating a typ-less token as the misconfiguration or downgrade attempt it usually is. Set `true` only for a bounded migration window while v0.4.x tokens are still in circulation. |
|
|
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. |
|
|
316
458
|
|
|
317
459
|
**Signing keys are required.** The default algorithm is `EdDSA` and there is no
|
|
318
460
|
key-material default: a deployment that sets none fails at boot naming the keys
|
|
@@ -324,11 +466,11 @@ openssl genpkey -algorithm ed25519 -out jwt-private.pem
|
|
|
324
466
|
openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
|
|
325
467
|
```
|
|
326
468
|
|
|
327
|
-
then point `
|
|
328
|
-
(or inline the PEMs via `
|
|
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`).
|
|
329
471
|
|
|
330
|
-
`HS256` is still selectable (`
|
|
331
|
-
`
|
|
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
|
|
332
474
|
public half, so `/.well-known/jwks.json` answers `404 jwks_not_published` and
|
|
333
475
|
every relying party must be handed the shared secret — which also lets it
|
|
334
476
|
**mint** tokens, not merely verify them.
|
|
@@ -339,43 +481,67 @@ every relying party must be handed the shared secret — which also lets it
|
|
|
339
481
|
|---|---|---|
|
|
340
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`). |
|
|
341
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. |
|
|
342
|
-
| `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | — | **Deprecated** alias of `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` (config key `oauth.accessToken.expiresIn`, of `oauth.accessToken.defaultExpiresIn`), read only while the new one is unset; while it still decides the default, boot logs `config_key_deprecated` (warn). Move the value to the new variable. |
|
|
343
484
|
| `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | Refresh token lifetime in seconds. Whole positive number, at most one year (`31536000`). |
|
|
344
485
|
|
|
345
|
-
|
|
346
|
-
HOCON resolves `FOO=` to `""
|
|
347
|
-
|
|
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`).
|
|
348
489
|
|
|
349
490
|
Only token exchange (RFC 8693) reads an `expires_in` request parameter; every
|
|
350
491
|
other grant ignores it and mints the default.
|
|
351
492
|
|
|
493
|
+
The default's old name, `OAUTH_ACCESS_TOKEN_EXPIRES_IN` (config key
|
|
494
|
+
`oauth.accessToken.expiresIn`), refuses boot set alone or beside
|
|
495
|
+
`OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` at a different value; beside it at the
|
|
496
|
+
same value, it boots. The old key refuses boot at any value. Move the value to
|
|
497
|
+
the new name rather than deleting it: without it, the default is `3600`.
|
|
498
|
+
|
|
352
499
|
### Grant Types
|
|
353
500
|
|
|
354
501
|
| Variable | Default | Description |
|
|
355
502
|
|---|---|---|
|
|
356
|
-
| `
|
|
357
|
-
| `
|
|
358
|
-
| `
|
|
503
|
+
| `OAUTH_SESSION_ENABLED` | `true` | Enable the session grant type |
|
|
504
|
+
| `OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED` | `true` | Enable the authorization code grant type |
|
|
505
|
+
| `OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED` | `true` | Enable the refresh token grant type |
|
|
506
|
+
| `OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED` | `false` | Enable the client credentials grant type |
|
|
507
|
+
| `OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED` | `false` | Enable the jwt-bearer grant type (RFC 7523) |
|
|
508
|
+
|
|
509
|
+
Each switch is its module's key — `oauth-session.enabled`, and
|
|
510
|
+
`oauth-authorization.grants.<grant>.enabled` for the others. Both modules are
|
|
511
|
+
always loaded and read their switches at boot, from the configuration boot
|
|
512
|
+
parses; the template reads none of them before boot. `OAUTH_GRANTS_<GRANT>_ENABLED`,
|
|
513
|
+
the old names, refuse boot set alone or beside the new name at a different
|
|
514
|
+
value; beside it at the same value, they boot.
|
|
359
515
|
|
|
360
516
|
### Session
|
|
361
517
|
|
|
362
518
|
| Variable | Default | Description |
|
|
363
519
|
|---|---|---|
|
|
364
|
-
| `
|
|
365
|
-
| `
|
|
366
|
-
| `
|
|
367
|
-
| `
|
|
368
|
-
| `
|
|
369
|
-
| `
|
|
370
|
-
| `SESSION_CSRF_TTL_SECONDS` | `7200` | Lifetime of an issued CSRF token, in seconds.
|
|
371
|
-
| `
|
|
372
|
-
| `
|
|
373
|
-
| `
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
`
|
|
378
|
-
|
|
520
|
+
| `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`. |
|
|
521
|
+
| `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`. |
|
|
522
|
+
| `SESSION_STORE_MAX_AGE` | `3600000` | Session cookie max age in milliseconds. Whole positive number, at most one year (`31536000000`). |
|
|
523
|
+
| `SESSION_STORE_SECURE` | `true` | Set `Secure` flag on session cookie |
|
|
524
|
+
| `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. |
|
|
525
|
+
| `SESSION_STORE_DOMAIN` | — | Cookie domain (unset by default) |
|
|
526
|
+
| `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. |
|
|
527
|
+
| `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) |
|
|
528
|
+
| `SESSION_STORE_STORAGE_REDIS_URL` | `redis://localhost:6379` | Redis connection URL for session storage |
|
|
529
|
+
| `SESSION_STORE_STORAGE_REDIS_PASSWORD` | — | Redis password for session storage |
|
|
530
|
+
|
|
531
|
+
The cookie and its store are the session store's section, `session-store`; the
|
|
532
|
+
CSRF token's lifetime, the login page and the login's budget are the session
|
|
533
|
+
module's, `session`. `SESSION_<KEY>`, the old names of the `SESSION_STORE_*`
|
|
534
|
+
variables, refuse boot set alone or beside the new name at a different value;
|
|
535
|
+
beside it at the same value, they boot.
|
|
536
|
+
|
|
537
|
+
If you set `SESSION_STORE_SECURE=false` for local HTTP development or set `SESSION_STORE_DOMAIN`
|
|
538
|
+
for shared-domain cookies, also set `SESSION_STORE_NAME` to a non-`__Host-` value such as
|
|
539
|
+
`auth.sid` — with `SESSION_STORE_SECURE=false`, one with no prefix: a `__Secure-` name
|
|
540
|
+
needs `SESSION_STORE_SECURE=true` too. The server fails fast when a `__Host-` or
|
|
541
|
+
`__Secure-` cookie name is combined with attributes that browsers reject for that
|
|
542
|
+
prefix (in any case), on a `SESSION_STORE_NAME` that is not a cookie name (an RFC 6265
|
|
543
|
+
token: no space, `;` or other separator), and on a `SESSION_STORE_DOMAIN` that is not a
|
|
544
|
+
host name (a scheme, a port or a path).
|
|
379
545
|
|
|
380
546
|
#### CSRF on `/session/login` and `/session/logout`
|
|
381
547
|
|
|
@@ -383,7 +549,7 @@ Both routes accept a request carrying **either** a same-origin (or trusted)
|
|
|
383
549
|
`Origin` / `Referer`, **or** a valid double-submit CSRF token, and reject one
|
|
384
550
|
carrying neither (#272). Browsers satisfy this automatically; a scripted client
|
|
385
551
|
calls `GET /session/csrf` first and sends the returned `csrf_token` back as
|
|
386
|
-
both the `<
|
|
552
|
+
both the `<SESSION_STORE_NAME>.csrf` cookie and an `x-csrf-token` header (or a
|
|
387
553
|
`csrf_token` form field).
|
|
388
554
|
|
|
389
555
|
Two configuration notes:
|
|
@@ -394,19 +560,21 @@ Two configuration notes:
|
|
|
394
560
|
request.
|
|
395
561
|
- If your login UI is served from a **different origin** than the provider,
|
|
396
562
|
list that origin under `session.csrf.trustedOrigins` in your HOCON config.
|
|
397
|
-
|
|
563
|
+
A listed origin can also answer federation-grant consents and device
|
|
564
|
+
verification, so a client's origin is never listed (the federation-grants
|
|
565
|
+
ADR's D7). `http.cors.allowedOrigins` does not confer CSRF trust — see
|
|
398
566
|
[CORS](#cors) for what it does confer.
|
|
399
567
|
|
|
400
568
|
### CORS
|
|
401
569
|
|
|
402
570
|
| Variable | Default | Description |
|
|
403
571
|
|---|---|---|
|
|
404
|
-
| `
|
|
572
|
+
| `HTTP_CORS_ALLOWED_ORIGINS` | *(empty — CORS off)* | Comma-separated list of browser origins allowed to read the token, userinfo, revocation and discovery/JWKS responses |
|
|
405
573
|
|
|
406
574
|
A browser app served from a different origin than this provider — an SPA on
|
|
407
575
|
`https://app.example.com` against a provider on `https://auth.example.com` —
|
|
408
576
|
cannot call the token endpoint at all unless that origin is on
|
|
409
|
-
`cors.allowedOrigins`. Set it and the provider answers preflights and stamps
|
|
577
|
+
`http.cors.allowedOrigins`. Set it and the provider answers preflights and stamps
|
|
410
578
|
`Access-Control-Allow-Origin` on the five endpoints a browser has a legitimate
|
|
411
579
|
reason to call cross-origin:
|
|
412
580
|
|
|
@@ -416,7 +584,7 @@ reason to call cross-origin:
|
|
|
416
584
|
| `/oauth/userinfo` | `GET`, `POST` | OIDC Core §5.3 defines both |
|
|
417
585
|
| `/oauth/revoke` | `POST` | RFC 7009 §2.1 — a public client revoking its own tokens on sign-out |
|
|
418
586
|
| `/.well-known/openid-configuration` | `GET` | Discovery, fetched by browser client libraries |
|
|
419
|
-
| `/.well-known/jwks.json` | `GET` | Same; follows `
|
|
587
|
+
| `/.well-known/jwks.json` | `GET` | Same; follows `jwks.path` when you override it |
|
|
420
588
|
|
|
421
589
|
`/oauth/introspect` and `/oauth/authorize` are deliberately **not** included.
|
|
422
590
|
Introspection is server-to-server and already refuses public clients, so no
|
|
@@ -424,12 +592,21 @@ browser could use it; `/authorize` is a top-level navigation rather than a
|
|
|
424
592
|
`fetch`, and CORS has no say over where a browser may navigate.
|
|
425
593
|
|
|
426
594
|
```hocon
|
|
427
|
-
cors {
|
|
595
|
+
http.cors {
|
|
428
596
|
allowedOrigins = ["https://app.example.com", "http://localhost:5173"]
|
|
429
597
|
}
|
|
430
598
|
```
|
|
431
599
|
|
|
432
|
-
or `
|
|
600
|
+
or `HTTP_CORS_ALLOWED_ORIGINS=https://app.example.com,http://localhost:5173`.
|
|
601
|
+
Those are the two spellings; a value that is neither — a number, an
|
|
602
|
+
object, a boolean, which only a configuration file can write — fails boot
|
|
603
|
+
naming `http.cors.allowedOrigins` rather than reading as no origins.
|
|
604
|
+
|
|
605
|
+
The list is part of the `http` module's section, with its default in
|
|
606
|
+
`config/reference.conf`. The `http` module hands it to core's CORS middleware
|
|
607
|
+
in the `httpSettings` slot, beside the trusted hops, and core reads the slot,
|
|
608
|
+
not the configuration: a composition with no `httpSettings` provider mounts
|
|
609
|
+
no CORS.
|
|
433
610
|
|
|
434
611
|
**Matching is exact string equality against the `Origin` header**, and every
|
|
435
612
|
shape that could not match fails at boot naming its index rather than sitting
|
|
@@ -455,17 +632,63 @@ all — no headers, no `Vary`, nothing changed.
|
|
|
455
632
|
|
|
456
633
|
### Google Federation
|
|
457
634
|
|
|
635
|
+
The federations are core's: `core.federations`, one map keyed by the name
|
|
636
|
+
each is reached at (`/session/oauth/federation/<name>`). A key of one is bound
|
|
637
|
+
to the variable named after its path, `CORE_FEDERATIONS_<NAME>_<KEY>`. The map
|
|
638
|
+
written at the top level (`federations { ... }`) refuses boot, naming each
|
|
639
|
+
key's path under `core.federations`; a `FEDERATIONS_GOOGLE_*` or
|
|
640
|
+
`FEDERATIONS_OIDC_*` variable set alone, or beside its new name at a different
|
|
641
|
+
value, is refused before any module is chosen.
|
|
642
|
+
|
|
643
|
+
Every entry names its `type`, and boot hands each enabled entry to the module
|
|
644
|
+
that handles that type, under the entry's name. The template always loads the
|
|
645
|
+
two types it bundles — `google` (`@o3co/auth-provider-federation-google`) and
|
|
646
|
+
`oidc` (`@o3co/auth-provider-federation-oidc`) — whatever the map says, and
|
|
647
|
+
reads no entry itself: a key an entry carries is read, and a malformed one
|
|
648
|
+
refused at its path (`core.federations.<name>.<key>`), by core and by the
|
|
649
|
+
type's schema. An entry with no `type`, enabled or not, is refused at
|
|
650
|
+
`core.federations.<name>.type` (`config-validation-failed`); an enabled entry
|
|
651
|
+
of a type no loaded module handles refuses the boot
|
|
652
|
+
(`federation-type-unhandled`), naming the entry.
|
|
653
|
+
GitHub and Apple are not bundled: a deployment that wants one adds its
|
|
654
|
+
package and its module to `buildModules` — GitHub's type module,
|
|
655
|
+
`githubFederationTypeModule()`, with entries of `type = "github"`; Apple's
|
|
656
|
+
per its [README](../../packages/federation-apple/README.md).
|
|
657
|
+
|
|
658
|
+
`core.federations.google` ships with `type = "google"`. A second Google
|
|
659
|
+
client is a second entry of that type, under its own name and with its own
|
|
660
|
+
callback:
|
|
661
|
+
|
|
662
|
+
```hocon
|
|
663
|
+
core.federations {
|
|
664
|
+
google-work {
|
|
665
|
+
enabled = true
|
|
666
|
+
type = "google"
|
|
667
|
+
clientId = ${GOOGLE_WORK_CLIENT_ID}
|
|
668
|
+
clientSecret = ${GOOGLE_WORK_CLIENT_SECRET}
|
|
669
|
+
callbackURL = "https://auth.example.com/session/oauth/federation/google-work/callback"
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
```
|
|
673
|
+
|
|
674
|
+
The keys a `google` entry takes beside the variables below — `redirectAllowlist`,
|
|
675
|
+
`sessionDomain`, `authCallbackUrl`, `clientUrl`,
|
|
676
|
+
`requireAuthorizationResponseIss`, `endSessionEndpoint` — are listed in full
|
|
677
|
+
in [the package's README](../../packages/federation-google/README.md);
|
|
678
|
+
`config/application.conf` shows every one of them but `endSessionEndpoint`.
|
|
679
|
+
A key the type does not name refuses the entry.
|
|
680
|
+
|
|
458
681
|
| Variable | Default | Description |
|
|
459
682
|
|---|---|---|
|
|
460
|
-
| `
|
|
461
|
-
| `
|
|
462
|
-
| `
|
|
463
|
-
| `
|
|
464
|
-
| `
|
|
683
|
+
| `CORE_FEDERATIONS_GOOGLE_ENABLED` | `false` | Enable Google OAuth federation |
|
|
684
|
+
| `CORE_FEDERATIONS_GOOGLE_CLIENT_ID` | — | Google OAuth client ID |
|
|
685
|
+
| `CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET` | — | Google OAuth client secret |
|
|
686
|
+
| `CORE_FEDERATIONS_GOOGLE_CALLBACK_URL` | `http://localhost:3000/session/oauth/federation/google/callback` | Google OAuth callback URL |
|
|
687
|
+
| `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) |
|
|
465
688
|
|
|
466
689
|
### OIDC Federation (any OpenID Connect provider)
|
|
467
690
|
|
|
468
|
-
One instance ships in `config/application.conf` under `federations.oidc`
|
|
691
|
+
One instance ships in `config/application.conf` under `core.federations.oidc`
|
|
469
692
|
(`@o3co/auth-provider-federation-oidc`, #524): Okta, Entra ID, Auth0, Keycloak
|
|
470
693
|
or your own tenant, selected by issuer. Discovery runs at boot and a failure
|
|
471
694
|
refuses boot. The identity handed to the Store is `oidc:<sub>`; an identity
|
|
@@ -473,16 +696,16 @@ the Store does not know is refused with 401 — nothing is provisioned.
|
|
|
473
696
|
|
|
474
697
|
| Variable | Default | Description |
|
|
475
698
|
|---|---|---|
|
|
476
|
-
| `
|
|
477
|
-
| `
|
|
478
|
-
| `
|
|
479
|
-
| `
|
|
480
|
-
| `
|
|
699
|
+
| `CORE_FEDERATIONS_OIDC_ENABLED` | `false` | Enable the OIDC federation |
|
|
700
|
+
| `CORE_FEDERATIONS_OIDC_ISSUER` | — | Issuer identifier (https), exactly as the IdP writes `iss` |
|
|
701
|
+
| `CORE_FEDERATIONS_OIDC_CLIENT_ID` | — | Client ID registered at the IdP |
|
|
702
|
+
| `CORE_FEDERATIONS_OIDC_CLIENT_SECRET` | — | Client secret (`client_secret_basic`); set `privateKey` in the config file for `private_key_jwt` instead |
|
|
703
|
+
| `CORE_FEDERATIONS_OIDC_CALLBACK_URL` | `http://localhost:3000/session/oauth/federation/oidc/callback` | Where the IdP sends the browser back |
|
|
481
704
|
|
|
482
705
|
A second IdP is another section with `type = "oidc"` and its own callback:
|
|
483
706
|
|
|
484
707
|
```hocon
|
|
485
|
-
federations {
|
|
708
|
+
core.federations {
|
|
486
709
|
okta {
|
|
487
710
|
enabled = true
|
|
488
711
|
type = "oidc"
|
|
@@ -495,9 +718,8 @@ federations {
|
|
|
495
718
|
}
|
|
496
719
|
```
|
|
497
720
|
|
|
498
|
-
The
|
|
499
|
-
`
|
|
500
|
-
OIDC instance named `google`, and the built-in module is not composed.
|
|
721
|
+
The entry's `type` names the implementation, never its name: an entry named
|
|
722
|
+
`google` with `type = "oidc"` is a generic OIDC federation named `google`.
|
|
501
723
|
|
|
502
724
|
Every field the package accepts — `scopes`, `discovery` / `endpoints`,
|
|
503
725
|
`privateKey`, `userInfo`, `idTokenSignedResponseAlg`, `clockToleranceSeconds` —
|
|
@@ -507,21 +729,27 @@ is documented in [its README](../../packages/federation-oidc/README.md).
|
|
|
507
729
|
|
|
508
730
|
| Variable | Default | Description |
|
|
509
731
|
|---|---|---|
|
|
510
|
-
| `
|
|
511
|
-
| `
|
|
732
|
+
| `ADAPTERS_CLIENT_REPOSITORY` | `yaml` | Client store backend: `yaml`, or `static`, core's alias of `yaml`, which reads `repositories.client.static.path` (no default) |
|
|
733
|
+
| `REPOSITORIES_CLIENT_YAML_PATH` | `./config/clients.yaml` | Path to the YAML client registry |
|
|
734
|
+
|
|
735
|
+
The registry is per-deployment configuration: `.gitignore` keeps
|
|
736
|
+
`config/clients.yaml` out of git and `.dockerignore` keeps it out of the image,
|
|
737
|
+
and the production compose file mounts it (see [Docker](#docker)).
|
|
512
738
|
|
|
513
739
|
### User Repository
|
|
514
740
|
|
|
515
741
|
| Variable | Default | Description |
|
|
516
742
|
|---|---|---|
|
|
517
|
-
| `
|
|
518
|
-
| `
|
|
519
|
-
| `
|
|
520
|
-
| `
|
|
521
|
-
| `
|
|
522
|
-
| `
|
|
523
|
-
| `
|
|
524
|
-
| `
|
|
743
|
+
| `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) |
|
|
744
|
+
| `REPOSITORIES_USER_YAML_PATH` | `./config/users.yaml` | The YAML user file the `yaml` backend reads |
|
|
745
|
+
| `REPOSITORIES_USER_HTTP_AUTHENTICATE_URL` | — | URL for password-based user authentication. **https required** (see below) |
|
|
746
|
+
| `REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL` | — | URL for token-based user authentication. **https required** (see below) |
|
|
747
|
+
| `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** |
|
|
748
|
+
| `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** |
|
|
749
|
+
| `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** |
|
|
750
|
+
| `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) |
|
|
751
|
+
| `REPOSITORIES_USER_HTTP_TIMEOUT` | `5000` | HTTP request timeout in milliseconds. Positive integer ≤ `2147483647` |
|
|
752
|
+
| `REPOSITORIES_USER_HTTP_MAX_RESPONSE_BYTES` | `1048576` | Largest upstream response body accepted, in bytes |
|
|
525
753
|
|
|
526
754
|
Both user-authentication URLs carry **plaintext user credentials** to the
|
|
527
755
|
upstream store, so both must be absolute `https://` URLs. `http://` is accepted
|
|
@@ -539,13 +767,13 @@ follows a `3xx`, so a URL that redirects fails every call — see
|
|
|
539
767
|
**Who may call the Store.** What the token-based login and account linking
|
|
540
768
|
send is an identifier, not a secret, so a Store that answers any caller
|
|
541
769
|
resolves a known identity to its user — or links one to any account — for
|
|
542
|
-
anyone who can reach it. Set `
|
|
770
|
+
anyone who can reach it. Set `REPOSITORIES_USER_HTTP_BEARER_TOKEN` and have the Store
|
|
543
771
|
answer every request whose `Authorization` is not exactly `Bearer <that token>`
|
|
544
772
|
with `401` and `WWW-Authenticate: Bearer error="invalid_token"` (or `403` and
|
|
545
773
|
`error="insufficient_scope"`). The one token goes to every Store URL above, so
|
|
546
|
-
those endpoints must be one trust domain. With `
|
|
774
|
+
those endpoints must be one trust domain. With `ADAPTERS_USER_REPOSITORY=http` — this
|
|
547
775
|
template's default — boot fails if the token is weaker than 32 bytes
|
|
548
|
-
(measured like `
|
|
776
|
+
(measured like `SESSION_STORE_SECRET`), malformed, or exported but empty; it appears
|
|
549
777
|
in no error this server throws. With that challenge, a token the Store does not
|
|
550
778
|
accept is an outage on every Store call: logins answer
|
|
551
779
|
`503 temporarily_unavailable` and log an error naming the refused credential,
|
|
@@ -565,29 +793,38 @@ what the Store checks are in the same foundation README section.
|
|
|
565
793
|
|
|
566
794
|
### Code Repository
|
|
567
795
|
|
|
796
|
+
`adapters.codeRepository` (`ADAPTERS_CODE_REPOSITORY`) chooses the
|
|
797
|
+
repository; each reads its own section. The Redis one runs on the shared
|
|
798
|
+
connection `REDIS_CLIENTS_URL` configures, so it takes no URL or password of
|
|
799
|
+
its own.
|
|
800
|
+
|
|
568
801
|
| Variable | Default | Description |
|
|
569
802
|
|---|---|---|
|
|
570
|
-
| `
|
|
571
|
-
| `
|
|
572
|
-
| `
|
|
573
|
-
|
|
803
|
+
| `REDIS_CODE_REPOSITORY_DEFAULT_EXPIRES_IN` | `600` | `redis-code-repository.defaultExpiresIn`: the Redis repository's default authorization-code lifetime, in seconds |
|
|
804
|
+
| `REDIS_CODE_REPOSITORY_KEY_PREFIX` | `oauth:code:` | `redis-code-repository.keyPrefix`: the Redis repository's key namespace (see [Redis Namespacing](#redis-namespacing)) |
|
|
805
|
+
| `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 |
|
|
806
|
+
|
|
807
|
+
`CLIENT_CODE_DEFAULT_EXPIRES_IN` and `CLIENT_CODE_KEY_PREFIX` were renamed
|
|
808
|
+
with them, and `CLIENT_CODE_ENDPOINT_URI` and `CLIENT_CODE_PASSWORD` removed. A
|
|
809
|
+
renamed one set alone, or beside its new name at a different value, and a
|
|
810
|
+
removed one set at all, fails boot naming what to set instead.
|
|
574
811
|
|
|
575
812
|
### Federation Token Store
|
|
576
813
|
|
|
577
814
|
The upstream IdP tokens (a Google refresh token, say) held on behalf of a
|
|
578
815
|
session. Memory by default, which forks per replica and is refused under
|
|
579
|
-
`
|
|
580
|
-
`
|
|
816
|
+
`CORE_DEPLOYMENT_MODE=multi`; the Redis store shares the socket configured by
|
|
817
|
+
`REDIS_CLIENTS_URL` and encrypts records at rest, so it
|
|
581
818
|
needs a key.
|
|
582
819
|
|
|
583
820
|
| Variable | Default | Description |
|
|
584
821
|
|---|---|---|
|
|
585
|
-
| `
|
|
822
|
+
| `ADAPTERS_FEDERATION_TOKEN_STORE` | `memory` | Federation token store backend: `memory` or `redis` |
|
|
586
823
|
| `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` |
|
|
587
|
-
| `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 `
|
|
824
|
+
| `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 |
|
|
588
825
|
|
|
589
826
|
`ttl` (seconds; keep it above the upstream refresh-token lifetime) and the #291
|
|
590
|
-
`scanFallback` migration flag live under `
|
|
827
|
+
`scanFallback` migration flag live under `redis-federation-token-store` in a
|
|
591
828
|
config layer rather than behind an environment variable.
|
|
592
829
|
|
|
593
830
|
### Consent Store
|
|
@@ -596,12 +833,87 @@ Where an end-user's consent to a client that is not first-party is recorded,
|
|
|
596
833
|
together with the `/authorize` request parked while the consent page asks
|
|
597
834
|
(#527, #552). One switch wires both. `none` by default: such clients are
|
|
598
835
|
refused and only first-party clients are served. `memory` forks per replica and
|
|
599
|
-
is refused under `
|
|
600
|
-
socket configured by `
|
|
836
|
+
is refused under `CORE_DEPLOYMENT_MODE=multi`; `redis` (#561) shares both over the
|
|
837
|
+
socket configured by `REDIS_CLIENTS_URL`.
|
|
601
838
|
|
|
602
839
|
| Variable | Default | Description |
|
|
603
840
|
|---|---|---|
|
|
604
|
-
| `
|
|
841
|
+
| `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) |
|
|
842
|
+
|
|
843
|
+
### Multi-factor authentication
|
|
844
|
+
|
|
845
|
+
A second factor after a password login, and a step-up when a relying party
|
|
846
|
+
asks for one at `/authorize` — the [MFA package](../../packages/mfa/README.md).
|
|
847
|
+
`MFA_MODE` is the template's own switch, `mfaMode`
|
|
848
|
+
([`config/reference.conf`](config/reference.conf)): `required`, the default
|
|
849
|
+
(every password login has a second factor), and `optional` (a user with a
|
|
850
|
+
factor is challenged, nobody is forced) install the MFA module, the TOTP, recovery-code and email factors (email off
|
|
851
|
+
unless `MFA_EMAIL_FACTOR_ENABLED`), the operator reset
|
|
852
|
+
(`handle.components.mfaReset`), the session package's login completion and
|
|
853
|
+
the two MFA stores `adapters` selects. The template then adds `mfa` to
|
|
854
|
+
`core.sessionRequirements.expected`, names it
|
|
855
|
+
`core.sessionRequirements.secondFactorAuthority` and writes `mfa.mode` from
|
|
856
|
+
the switch, so
|
|
857
|
+
a composition that asks for a second factor and cannot enforce one is refused
|
|
858
|
+
at boot rather than let a password alone sign in; `off` installs nothing of
|
|
859
|
+
MFA. A value that is none of the three is refused before boot, naming
|
|
860
|
+
`mfaMode` and `MFA_MODE`.
|
|
861
|
+
|
|
862
|
+
**On by default.** In development (`make dev`) MFA needs nothing more: the
|
|
863
|
+
sample key in `config/development.conf`, the sender that logs each code, and
|
|
864
|
+
`docker-compose.yml`'s Redis for the two stores. Outside development a
|
|
865
|
+
deployment either sets what MFA needs — the two stores on `redis` (or the
|
|
866
|
+
factors in the `store`), `MFA_ENCRYPTION_KEY` and the SMTP relay
|
|
867
|
+
(`STANDARD_SMTP_MAIL_SENDER_HOST` and `_FROM`, or a sender of its own through
|
|
868
|
+
`buildModules`' `mailSenderModules`) — or sets `MFA_MODE=off`. Until it
|
|
869
|
+
decides, the boot is refused before the app is created: the stores' refusal
|
|
870
|
+
comes first, naming each of those and `MFA_MODE=off`. The MFA page is never
|
|
871
|
+
refused: `MFA_PAGE_URL` defaults to `/mfa`, which the deployment must serve,
|
|
872
|
+
in development too, or every password login stops there.
|
|
873
|
+
|
|
874
|
+
| Variable | Default | Description |
|
|
875
|
+
|---|---|---|
|
|
876
|
+
| `MFA_MODE` | `required` | `mfaMode`: `required`, `optional` or `off` |
|
|
877
|
+
| `MFA_ENCRYPTION_KEY` | — | The MFA key ring's first key, which seals every factor's data: canonical base64 of 32 bytes (`openssl rand -base64 32`). Required with MFA on. Under `CONFIG_ENV=development`, `config/development.conf` puts the MFA package's published sample key in its place: write your own key there instead, since this variable set beside that ring refuses the boot. The sample key is refused under `CONFIG_ENV` or `NODE_ENV` `production` or `staging` and under `CORE_DEPLOYMENT_MODE=multi` |
|
|
878
|
+
| `MFA_PAGE_URL` | `/mfa` | The deployment's MFA page, where a login's second factor and a step-up start. The template ships no page: the page contract is the [MFA package's](../../packages/mfa/README.md#the-routes) |
|
|
879
|
+
| `MFA_STORE_TIMEOUT_MS` | `5000` | `mfa.storeTimeoutMs`, one Store call's time: at least every store's own per-call timeout. Below `REPOSITORIES_USER_HTTP_TIMEOUT` where the Store is called (`ADAPTERS_USER_REPOSITORY=http`, or the factors in the Store) the boot is refused, so raise the two together; above 37500 ms it is refused too |
|
|
880
|
+
| `STANDARD_SMTP_MAIL_SENDER_HOST` | — | The SMTP relay outside development, where MFA mails the account-email proof and the email factor's codes. With MFA on, the boot needs it and `STANDARD_SMTP_MAIL_SENDER_FROM` |
|
|
881
|
+
| `STANDARD_SMTP_MAIL_SENDER_FROM` | — | The one sender address of that mail |
|
|
882
|
+
| `STANDARD_SMTP_MAIL_SENDER_PORT` | `587` | The relay's port |
|
|
883
|
+
| `STANDARD_SMTP_MAIL_SENDER_SECURE` | `starttls` | `starttls`, `tls`, or `none` to a loopback relay only |
|
|
884
|
+
| `STANDARD_SMTP_MAIL_SENDER_USER` | — | The relay account, with `STANDARD_SMTP_MAIL_SENDER_PASSWORD` |
|
|
885
|
+
| `STANDARD_SMTP_MAIL_SENDER_PASSWORD` | — | That account's password |
|
|
886
|
+
|
|
887
|
+
The rest of the MFA settings — each factor's, the lock, a transaction's life
|
|
888
|
+
— are the MFA package's, with their variables, in its
|
|
889
|
+
[README](../../packages/mfa/README.md#configuration). A few things to know:
|
|
890
|
+
|
|
891
|
+
- **The stores.** `memory`, the shipped selection, is for development and
|
|
892
|
+
test alone: a restart loses every factor, lock and recorded email proof,
|
|
893
|
+
after which whoever holds a password can bind a factor of their own. With
|
|
894
|
+
MFA on it is refused before boot unless the configuration's name, and
|
|
895
|
+
`CONFIG_ENV` and `NODE_ENV` wherever set, each say `development` or
|
|
896
|
+
`test`, and core refuses it under `CORE_DEPLOYMENT_MODE=multi`. Production
|
|
897
|
+
needs `redis` for both, or `store` for the factors: `redis` shares the
|
|
898
|
+
socket `REDIS_CLIENTS_URL` opens; `store` (factors only) keeps them behind the Store's four endpoints
|
|
899
|
+
(`FOUNDATION_MFA_FACTOR_STORE_*`,
|
|
900
|
+
[foundation's README](../../packages/foundation/README.md)), sent the same
|
|
901
|
+
bearer token as the user repository.
|
|
902
|
+
- **The enrollment witness.** With `ADAPTERS_USER_REPOSITORY=http`, set
|
|
903
|
+
`REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL` ([User Repository](#user-repository));
|
|
904
|
+
without it, boot warns once (`mfa_enrollment_witness_unwritable`).
|
|
905
|
+
- **`acr`.** With MFA on, the template adds `"urn:o3co:acr:mfa" = ["mfa"]`
|
|
906
|
+
to `oauth.authorize.acrValues`, unless your configuration writes that
|
|
907
|
+
entry, and discovery advertises it. With MFA off it adds nothing: the acr
|
|
908
|
+
table and discovery are what they are without MFA.
|
|
909
|
+
- **The mail in development.** Under `CONFIG_ENV=development` the template's
|
|
910
|
+
sender logs each code (`mail_code_issued`) and sends nothing. To run the
|
|
911
|
+
SMTP sender and read what it sends, use the
|
|
912
|
+
[Mailpit overlay](#mailpit-for-the-smtp-sender-development-only).
|
|
913
|
+
- **One switch.** A file that writes `mfaMode` while `MFA_MODE` is set to
|
|
914
|
+
something else, and a configuration that writes `mfa.mode` other than
|
|
915
|
+
what the switch says, are refused before boot, naming the keys. While MFA
|
|
916
|
+
is off, nothing the configuration writes under `mfa` is handed to boot.
|
|
605
917
|
|
|
606
918
|
### Federation Grants
|
|
607
919
|
|
|
@@ -617,29 +929,35 @@ needs before it issues a refresh token is in
|
|
|
617
929
|
|
|
618
930
|
| Variable | Default | Description |
|
|
619
931
|
|---|---|---|
|
|
620
|
-
| `FEDERATION_GRANTS_ENABLED` | `false` | Installs the routes, the two stores below and the subject-revocation service |
|
|
932
|
+
| `FEDERATION_GRANTS_ENABLED` | `false` | Installs the routes, the two stores selected below (none until selected) and the subject-revocation service |
|
|
621
933
|
| `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 |
|
|
622
934
|
| `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 |
|
|
623
|
-
| `
|
|
624
|
-
| `
|
|
625
|
-
| `
|
|
935
|
+
| `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) |
|
|
936
|
+
| `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` |
|
|
937
|
+
| `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` |
|
|
626
938
|
| `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 |
|
|
627
939
|
| `REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX` | `fg:` | Key namespace of the Redis grant store |
|
|
940
|
+
| `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 |
|
|
628
941
|
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
942
|
+
The feature's own settings are the `federation-grants {}` section; the Redis
|
|
943
|
+
grant store's — its key ring, its encryption mode, its prefix — are
|
|
944
|
+
`redis-federation-grant-store {}`. **Two things have no environment form**,
|
|
945
|
+
because a list is HOCON's: the connections, and the encryption key ring.
|
|
946
|
+
Write them in a deployment-owned layer — `config/production.conf`, say:
|
|
632
947
|
|
|
633
948
|
```hocon
|
|
634
|
-
|
|
949
|
+
redis-federation-grant-store {
|
|
635
950
|
encryptionKeys = [
|
|
636
951
|
# The first key seals; every listed key opens. GRANT_KEY_2026_09 is a name
|
|
637
952
|
# you choose (openssl rand -base64 32), not a template override.
|
|
638
953
|
{ id = "2026-09", key = ${GRANT_KEY_2026_09} }
|
|
639
954
|
]
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
federation-grants {
|
|
640
958
|
connections {
|
|
641
959
|
files {
|
|
642
|
-
federation = "entra-files" # an enabled federations.<name> of type "oidc", with an app registration of its own
|
|
960
|
+
federation = "entra-files" # an enabled core.federations.<name> of type "oidc", with an app registration of its own
|
|
643
961
|
scopes = ["openid", "profile", "offline_access", "Files.Read"]
|
|
644
962
|
boundary = "production"
|
|
645
963
|
maxAccessTokenLifetime = 3600
|
|
@@ -659,15 +977,15 @@ enabled, or on one whose adapter lacks the delegated capability — only the
|
|
|
659
977
|
generic OIDC adapter has it, so a Google connection is a `type = "oidc"`
|
|
660
978
|
federation; `required` with a user repository that does not cover a
|
|
661
979
|
connection's registration; Redis grants beside memory user-session stores
|
|
662
|
-
(`
|
|
980
|
+
(`ADAPTERS_USER_SESSION_STORES=memory` — the grants would outlive the boundary
|
|
663
981
|
that ends them; both compose files set `redis`); the Redis grant store under
|
|
664
982
|
`encryptionMode = "required"` with no key in the ring (the memory store seals
|
|
665
|
-
nothing and needs none); `memory` for either store under `
|
|
983
|
+
nothing and needs none); `memory` for either store under `CORE_DEPLOYMENT_MODE=multi`.
|
|
666
984
|
|
|
667
985
|
**The user repository.** Under `required`, boot asks the repository whether it
|
|
668
986
|
covers each connection's registration, and the connect callback asks it who
|
|
669
987
|
holds the upstream account. The template's `http` repository asks your Store
|
|
670
|
-
(#613): set `
|
|
988
|
+
(#613): set `REPOSITORIES_USER_HTTP_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` to the
|
|
671
989
|
endpoint, and declare what that endpoint covers — one entry per registration,
|
|
672
990
|
with the claims the Store's strategy needs — in a HOCON layer:
|
|
673
991
|
|
|
@@ -718,9 +1036,21 @@ is `/oauth/consent`'s, so one page can serve both. `GET
|
|
|
718
1036
|
duration after approval, not a date), `continues_after_logout` (which the page
|
|
719
1037
|
must show), and `expires_in` (what is left of the flow). `POST` with
|
|
720
1038
|
`challenge` and `decision` (`accept` | `deny`) answers `303` — to the upstream,
|
|
721
|
-
or back to the client. Every acquisition and renewal goes through it.
|
|
722
|
-
|
|
723
|
-
|
|
1039
|
+
or back to the client. Every acquisition and renewal goes through it. The
|
|
1040
|
+
answer is held to the session module's CSRF policy: post it as a form from the
|
|
1041
|
+
page, and serve the page with `Referrer-Policy: same-origin` — not
|
|
1042
|
+
`no-referrer`, whether by the header, `<meta name="referrer">` or
|
|
1043
|
+
`rel="noreferrer"` on the form, under which the browser sends `Origin: null`
|
|
1044
|
+
and the answer is refused. This app's `helmet()` sends `no-referrer` on every
|
|
1045
|
+
response, so a page it serves sets `Referrer-Policy: same-origin` on its own
|
|
1046
|
+
route. Behind a proxy, `HTTP_TRUST_PROXY` names it, and it forwards
|
|
1047
|
+
`X-Forwarded-Proto` and `X-Forwarded-Host` (or keeps the browser's `Host`). A
|
|
1048
|
+
user agent that sends neither `Origin` nor `Referer` echoes the token
|
|
1049
|
+
`GET /session/csrf` hands out, as for `POST /session/login`; a page may always
|
|
1050
|
+
include it, since it counts only when neither is sent. The package README has
|
|
1051
|
+
the rule.
|
|
1052
|
+
|
|
1053
|
+
**The login page** is the one `SESSION_LOGIN_PAGE_URL` names, reached with
|
|
724
1054
|
`redirect_to=<the connect link>`; it signs the user in and navigates back to
|
|
725
1055
|
that link unchanged, as it does for `/oauth/authorize`. Do not submit the link
|
|
726
1056
|
to `POST /session/login` as its `redirect_to`: that route's exact-match
|
|
@@ -743,11 +1073,11 @@ auth.provider instances cannot collide in the same database:
|
|
|
743
1073
|
|
|
744
1074
|
| Variable | Default | Description |
|
|
745
1075
|
|---|---|---|
|
|
746
|
-
| `REDIS_SESSION_STORES_KEY_PREFIX` | `ss:` | Outer prefix for user sessions,
|
|
747
|
-
| `
|
|
748
|
-
| `
|
|
1076
|
+
| `REDIS_SESSION_STORES_KEY_PREFIX` | `ss:` | Outer prefix for user sessions, the subject session index, the subject-revocation boundaries and the session lifecycle records. |
|
|
1077
|
+
| `REDIS_REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX` | `rtfam:` | Prefix for refresh-token family records. |
|
|
1078
|
+
| `REDIS_CODE_REPOSITORY_KEY_PREFIX` | `oauth:code:` | Prefix for OAuth authorization codes. |
|
|
749
1079
|
| `REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX` | `ft:` | Prefix for federation token records, their per-session index and their lock keys. |
|
|
750
|
-
| `REDIS_CONSENT_STORE_KEY_PREFIX` | `consent:` | Prefix for consent records and for parked consent requests with their per-session index (`
|
|
1080
|
+
| `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). |
|
|
751
1081
|
|
|
752
1082
|
Use values that include the deployment name, for example `tenant-a:ss:`,
|
|
753
1083
|
`tenant-a:rtfam:`, `tenant-a:code:`, `tenant-a:ft:`, and `tenant-a:consent:`.
|
|
@@ -756,24 +1086,26 @@ Use values that include the deployment name, for example `tenant-a:ss:`,
|
|
|
756
1086
|
|
|
757
1087
|
| Variable | Default | Description |
|
|
758
1088
|
|---|---|---|
|
|
759
|
-
| `
|
|
760
|
-
| `
|
|
1089
|
+
| `SESSION_LOGIN_PAGE_URL` | `/login` | URL of the login page (for redirects) |
|
|
1090
|
+
| `OAUTH_CONSENT_PAGE_URL` | `/consent` | URL of the consent page a client that is not first-party is routed through, with `?challenge=<id>` (#527) |
|
|
761
1091
|
|
|
762
1092
|
There is no deployment-wide client or callback URL: federation callback URLs
|
|
763
|
-
are configured per federation (`
|
|
764
|
-
`
|
|
1093
|
+
are configured per federation (`CORE_FEDERATIONS_GOOGLE_CALLBACK_URL`,
|
|
1094
|
+
`CORE_FEDERATIONS_OIDC_CALLBACK_URL`).
|
|
765
1095
|
|
|
766
1096
|
## Module Composition Order
|
|
767
1097
|
|
|
768
1098
|
[`src/buildModules.mts`](src/buildModules.mts) is the single source of truth for which modules are composed and in what order — read it rather than a copy of it. The boot planner resolves `requires` / `provides` topologically, but it mounts routes and middleware in list order unless a module declares `before` / `after` — so position matters for anything that mounts. These are the rules to keep when you edit the list:
|
|
769
1099
|
|
|
770
|
-
1. **`
|
|
771
|
-
2. **Under `/oauth`, the order does not matter.** `federationGrantsModules` (while federation grants are enabled), `
|
|
772
|
-
3. **One module per store slot.** Each adapter switch — `federationTokenStore
|
|
773
|
-
4. **The shared Redis connection comes with the first Redis-backed module.** `standaloneRedisClientsModule` opens the one ioredis connection every Redis adapter here uses, 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`).
|
|
774
|
-
5. **
|
|
1100
|
+
1. **`sessionStoreModule` stays first.** It mounts the `express-session` middleware and declares no `before` / `after`, so its position in the list is what puts it ahead of every route that reads `req.session`. It declares its replica safety from its own section, so `session-store.storage.type = "memory"` is replica-unsafe and refused under `core.deployment.mode = "multi"`.
|
|
1101
|
+
2. **Under `/oauth`, the order does not matter.** `federationGrantsModules` (while federation grants are enabled), `oauthEndpointsModule` and any module of your own may all mount routes under `/oauth`. `oauthEndpointsModule`'s router parses the bodies of its own routes only, so each module's requests reach its own parsers whatever the list order — provided every module under `/oauth` parses its own body and scopes its parsers to exactly its own paths, as the shipped ones do — a route (`router.all(path, parser)` or the route's own handler list), not `router.use(path, parser)`, which matches every path beneath `path` as well. The federation grants browser half orders itself after the session middleware with its own `after`.
|
|
1102
|
+
3. **One module per store slot.** Each adapter switch — `adapters.federationTokenStore`, `adapters.userSessionStores`, `adapters.rateLimiter`, `adapters.codeRepository`, `adapters.accessTokenDenylist`, `adapters.replaySeenSet`, `adapters.consentStore`, the two federation-grant store switches and the two MFA store switches — picks one of a memory / Redis pair (or, for the MFA factors, the Store). Both provide the same slot, so wiring both is a boot-time slot collision. `adapters.consentStore = "none"` wires neither, the federation-grant stores are wired only while the feature is enabled, and the MFA stores only while `MFA_MODE` installs MFA.
|
|
1103
|
+
4. **The shared Redis connection comes with the first Redis-backed module.** `standaloneRedisClientsModule` opens the one ioredis connection every Redis adapter here uses, from its own section (`redis-clients`), and is added whenever a composed module needs one. The refresh-token family store is on Redis in the shipped composition, so a deployment always has it; the in-memory family store is a test override (`overrides.refreshTokenFamilyModules`).
|
|
1104
|
+
5. **The template's own settings modules are always composed.** `loggingModule` and `httpModule` own `logging {}` and `http {}`, the CORS list among `http`'s keys (`http.cors.allowedOrigins`). `httpModule` provides core's `httpSettings`, which is authoritative, so no `overrideComponents` entry replaces it while the module is loaded; it also provides the template's `httpHostSettings`, which `app.mts` reads after boot for the port and the readiness deadline. The logger is built before boot from the `logging` section (`readLogging`), not by a module, and handed to boot as the `logger` component.
|
|
1105
|
+
6. **The federation types are always composed.** `googleFederationTypeModule()` and `oidcFederationTypeModule()` are listed whatever `core.federations` says: each contributes its type, and boot builds one federation per enabled entry of it, under the entry's name, so a federation is added or removed in configuration alone. Nothing in the list reads an entry. A module that contributes a federation under an entry's name directly beside an entry dispatched by its type is a boot-time `duplicate-contribute`: add a federation of another kind as a type module, and give its entries that type.
|
|
1106
|
+
7. **The mail sender follows the environment.** `@o3co/auth-provider-standard`'s development sender, which logs each code, where the configuration was selected as `development`; its module installs only where that name, and `CONFIG_ENV` and `NODE_ENV` wherever they are set, each read `development` or `test`, and refuses the boot otherwise, or where `core.deployment.mode` is `multi`. Under any other name, the SMTP sender's module, whose section is `standard-smtp-mail-sender` ([the package's README](../../packages/standard/README.md)); its sender is built only where something reads the `mailSender` slot, and there the boot needs `STANDARD_SMTP_MAIL_SENDER_HOST` and `STANDARD_SMTP_MAIL_SENDER_FROM`. While `MFA_MODE` is `off` nothing reads the slot, so the template boots without them; with MFA on, the MFA module and the operator reset read it. A sender of your own replaces this choice: pass its module where [`src/app.mts`](src/app.mts) calls `buildModules`, as `buildModules(switches, { environment: env, mailSenderModules: [mySenderModule] })`. Given, `mailSenderModules` is installed under every environment name and the bundled sender is not, so the two never both provide the slot; an empty list installs no sender, and a module that needs one (the MFA email factor, or the MFA module with `requireEmailProof = "always"`) then refuses the boot.
|
|
775
1107
|
|
|
776
|
-
`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.
|
|
1108
|
+
`jwksModule` (from core) is always composed: a provider that signs tokens publishes its verification keys whether or not an issuer is configured. What each route module mounts is in its package's README. The template composes core's `sessionLifecycleModule`: a login opens its session's record, a code exchange joins it, and both `POST /session/logout` and `POST /oauth/logout` close the session through the lifecycle, which revokes the session's refresh-token families and tells its relying parties. See [Which logout endpoint invalidates what](../../docs/operator-runbook.md#which-logout-endpoint-invalidates-what).
|
|
777
1109
|
|
|
778
1110
|
## Built-in Routes
|
|
779
1111
|
|
|
@@ -809,11 +1141,13 @@ make test
|
|
|
809
1141
|
The `docker-compose.yml` starts the auth server together with a Redis container. Configure environment variables in `.env`.
|
|
810
1142
|
|
|
811
1143
|
`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
|
|
812
|
-
[`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 `
|
|
1144
|
+
[`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`.
|
|
813
1145
|
|
|
814
1146
|
`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.
|
|
815
1147
|
|
|
816
|
-
Every store whose records must outlive one process is named in that file's `environment:` block rather than inherited — including `
|
|
1148
|
+
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.
|
|
1149
|
+
|
|
1150
|
+
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.
|
|
817
1151
|
|
|
818
1152
|
```bash
|
|
819
1153
|
# The signing keys are a required input: EdDSA is the default and there is no
|
|
@@ -822,19 +1156,32 @@ openssl genpkey -algorithm ed25519 -out jwt-private.pem
|
|
|
822
1156
|
openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
|
|
823
1157
|
chmod 600 jwt-private.pem
|
|
824
1158
|
|
|
1159
|
+
# The client registry is a required input too: the scaffold's
|
|
1160
|
+
# config/clients.yaml, or one made from config/clients.yaml.example.
|
|
1161
|
+
|
|
825
1162
|
docker compose -f docker-compose.production.yml up -d --build
|
|
826
1163
|
```
|
|
827
1164
|
|
|
828
1165
|
**Keys in containers.** The pem pair reaches the container as a compose
|
|
829
1166
|
**secret**, mounted read-only at `/run/secrets/`, and the compose file points
|
|
830
|
-
`
|
|
831
|
-
`COPY`s that directory
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
The
|
|
1167
|
+
`KEY_STORE_LOCAL_*_KEY_PATH` there. Do not park them in `config/`: the `Dockerfile`
|
|
1168
|
+
`COPY`s that directory. `.dockerignore` keeps every `*.pem` and `*.key` out of
|
|
1169
|
+
the build context, as `.gitignore` keeps them out of git, but a key under any
|
|
1170
|
+
other name placed there is baked into an image layer and travels with every
|
|
1171
|
+
push — a key you then have to rotate rather than one you merely stored badly.
|
|
1172
|
+
The same applies to `.env`; keep both out of git.
|
|
1173
|
+
|
|
1174
|
+
**The client registry in containers.** `config/clients.yaml` arrives the same
|
|
1175
|
+
way, as the compose secret `client_registry`, with `REPOSITORIES_CLIENT_YAML_PATH` pointed at it.
|
|
1176
|
+
It is per-deployment and may hold client secrets, so `.dockerignore` keeps it
|
|
1177
|
+
out of the build context: no image carries a registry, and an image built from
|
|
1178
|
+
a working copy is the same as one built from a clean checkout. Compose refuses
|
|
1179
|
+
to start until the file exists. Run the image some other way and you mount it
|
|
1180
|
+
and set `REPOSITORIES_CLIENT_YAML_PATH` yourself.
|
|
1181
|
+
|
|
1182
|
+
The production file also pins `SESSION_STORE_SECURE=true` and the `__Host-` cookie
|
|
836
1183
|
name in its `environment:` block, which wins over `env_file`. `.env.example`
|
|
837
|
-
ships `
|
|
1184
|
+
ships `SESSION_STORE_SECURE=false` so that a plain-HTTP compose run (`make dev`) does
|
|
838
1185
|
not trip the `__Host-` cookie check, and this file requires that same `.env` — behind the TLS it assumes, a non-Secure session
|
|
839
1186
|
cookie is the session handed to anyone who can read one plaintext hop.
|
|
840
1187
|
|
|
@@ -855,6 +1202,47 @@ docker run -e HTTP_PORT=8080 -p 8080:8080 my-auth-provider
|
|
|
855
1202
|
`EXPOSE` is image metadata and does not publish ports by itself, so the
|
|
856
1203
|
explicit `-p` mapping is still required.
|
|
857
1204
|
|
|
1205
|
+
### Mailpit for the SMTP sender (development only)
|
|
1206
|
+
|
|
1207
|
+
[`docker-compose.mailpit.yml`](docker-compose.mailpit.yml) is an overlay on
|
|
1208
|
+
`docker-compose.yml` that adds [Mailpit](https://mailpit.axllent.org/), a mail
|
|
1209
|
+
catcher, so a development run with MFA on sends its mail — the account-email
|
|
1210
|
+
proof, the email factor's codes — through the SMTP sender instead of the
|
|
1211
|
+
development sender, which only logs each code:
|
|
1212
|
+
|
|
1213
|
+
```bash
|
|
1214
|
+
docker compose -f docker-compose.yml -f docker-compose.mailpit.yml up --build
|
|
1215
|
+
```
|
|
1216
|
+
|
|
1217
|
+
Read the mail at <http://localhost:8025>. Mailpit keeps it and delivers
|
|
1218
|
+
nothing onward. A plain `docker compose up` (`make dev`) starts no Mailpit, and
|
|
1219
|
+
the production file names none. It is for development only: a deployment
|
|
1220
|
+
names a real relay with the `STANDARD_SMTP_MAIL_SENDER_*` variables
|
|
1221
|
+
([Multi-factor authentication](#multi-factor-authentication)).
|
|
1222
|
+
|
|
1223
|
+
What the overlay sets on the app service:
|
|
1224
|
+
|
|
1225
|
+
- `CONFIG_ENV=mailpit`. Under `development` the template installs the sender
|
|
1226
|
+
that logs each code ([Module Composition Order](#module-composition-order),
|
|
1227
|
+
rule 7). [`config/mailpit.conf`](config/mailpit.conf) includes
|
|
1228
|
+
`development.conf`, so the run is otherwise the development one, the MFA
|
|
1229
|
+
sample key included.
|
|
1230
|
+
- Nothing of `MFA_MODE`: MFA is on as the template ships it, or as your
|
|
1231
|
+
`.env` sets it. With MFA off nothing sends mail.
|
|
1232
|
+
- `ADAPTERS_MFA_FACTOR_STORE=redis` and `ADAPTERS_MFA_TRANSACTION_STORE=redis`,
|
|
1233
|
+
on the compose Redis, as `docker-compose.yml` sets them: under a name other
|
|
1234
|
+
than `development` or `test` the MFA stores may not be kept in memory.
|
|
1235
|
+
- `STANDARD_SMTP_MAIL_SENDER_HOST=localhost`,
|
|
1236
|
+
`STANDARD_SMTP_MAIL_SENDER_PORT=1025`, `STANDARD_SMTP_MAIL_SENDER_SECURE=none`
|
|
1237
|
+
and `STANDARD_SMTP_MAIL_SENDER_FROM=auth@example.com`. The SMTP sender sends
|
|
1238
|
+
in plaintext only to a loopback host, so Mailpit runs in the app container's
|
|
1239
|
+
network namespace (`network_mode: service:app`) and listens on its
|
|
1240
|
+
`localhost`; the app service therefore publishes Mailpit's web UI, on
|
|
1241
|
+
loopback.
|
|
1242
|
+
|
|
1243
|
+
It is an overlay rather than a compose profile because a profile only adds
|
|
1244
|
+
services: it cannot change the app service's environment or ports.
|
|
1245
|
+
|
|
858
1246
|
### Health endpoints
|
|
859
1247
|
|
|
860
1248
|
Two routes, answering two different questions. Both are mounted on the host
|
|
@@ -931,21 +1319,23 @@ unready one.
|
|
|
931
1319
|
|
|
932
1320
|
## Adding Custom Modules
|
|
933
1321
|
|
|
934
|
-
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: `
|
|
1322
|
+
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:
|
|
935
1323
|
|
|
936
1324
|
```diff
|
|
937
1325
|
// src/buildModules.mts
|
|
938
1326
|
+import { myCustomModule } from "./my-custom-module.mjs";
|
|
939
1327
|
…
|
|
940
1328
|
return [
|
|
941
|
-
|
|
1329
|
+
sessionStoreModule,
|
|
942
1330
|
…
|
|
943
|
-
...(federationGrantsEnabled ? [subjectRevocationServiceModule] : []),
|
|
1331
|
+
...(federationGrantsEnabled || mfaInstalled ? [subjectRevocationServiceModule] : []),
|
|
944
1332
|
+ myCustomModule,
|
|
945
1333
|
];
|
|
946
1334
|
```
|
|
947
1335
|
|
|
948
|
-
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()`.
|
|
1336
|
+
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.
|
|
1337
|
+
|
|
1338
|
+
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.
|
|
949
1339
|
|
|
950
1340
|
### Shutdown guarantees
|
|
951
1341
|
|
|
@@ -955,9 +1345,22 @@ Keep the other rules there too: the session store module stays first, and a modu
|
|
|
955
1345
|
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.
|
|
956
1346
|
3. **In-flight requests get `drainTimeoutMs`** (default **10s**) to finish.
|
|
957
1347
|
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.
|
|
958
|
-
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
|
|
1348
|
+
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.
|
|
1349
|
+
|
|
1350
|
+
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.
|
|
959
1351
|
|
|
960
|
-
|
|
1352
|
+
Each stage logs one line:
|
|
1353
|
+
|
|
1354
|
+
| Event | Level | Fields |
|
|
1355
|
+
|---|---|---|
|
|
1356
|
+
| `shutdown_draining` | info | `drainTimeoutMs`, `cleanupTimeoutMs` |
|
|
1357
|
+
| `shutdown_drain_deadline_exceeded` | error | `drainTimeoutMs` |
|
|
1358
|
+
| `shutdown_server_close_failed` | error | `err` |
|
|
1359
|
+
| `shutdown_cleanup_timed_out` | error | `cleanupTimeoutMs` |
|
|
1360
|
+
| `shutdown_cleanup_failed` | error | `err` |
|
|
1361
|
+
| `shutdown_complete` | info | `reason`, `drain`, `exitCode` |
|
|
1362
|
+
|
|
1363
|
+
`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.
|
|
961
1364
|
|
|
962
1365
|
**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.
|
|
963
1366
|
|
|
@@ -984,8 +1387,9 @@ Keep the other rules there too: the session store module stays first, and a modu
|
|
|
984
1387
|
### Logging
|
|
985
1388
|
|
|
986
1389
|
The provider logs newline-delimited JSON on stdout via [pino](https://getpino.io),
|
|
987
|
-
which is what a log aggregator ingests without a parser. `
|
|
988
|
-
`logging.level
|
|
1390
|
+
which is what a log aggregator ingests without a parser. `LOGGING_LEVEL` (HOCON
|
|
1391
|
+
`logging.level`, the `logging` module's section, read before boot with that
|
|
1392
|
+
module's schema) sets the threshold; `trace` and `debug` carry request-shaped
|
|
989
1393
|
detail and are off by default, and pino drops sub-threshold calls before
|
|
990
1394
|
formatting, so they cost nothing in production rather than being emitted and
|
|
991
1395
|
filtered downstream.
|
|
@@ -993,7 +1397,7 @@ filtered downstream.
|
|
|
993
1397
|
The logger is wired into the boot planner's `logger` component slot, so every
|
|
994
1398
|
module that declares `optional: ["logger"]` — oauth, session, dpop, mtls,
|
|
995
1399
|
token-exchange — logs through this one instance. Filling that slot is what makes
|
|
996
|
-
`
|
|
1400
|
+
`LOGGING_LEVEL` reach them; a composition root that leaves it empty gets each
|
|
997
1401
|
module's own `consoleLogger` default instead, and configuring a level changes
|
|
998
1402
|
nothing.
|
|
999
1403
|
|
|
@@ -1008,7 +1412,7 @@ shape, so a pino-compatible logger needs no adapter.
|
|
|
1008
1412
|
|
|
1009
1413
|
Security-relevant events — who authenticated, what was issued, what was refused,
|
|
1010
1414
|
and when the rate limiter could not answer — go to the sink named by
|
|
1011
|
-
`
|
|
1415
|
+
`adapters.auditSink` (env `ADAPTERS_AUDIT_SINK`). The template ships `"logger"`, which
|
|
1012
1416
|
writes one event per line through the same pino stream as everything else:
|
|
1013
1417
|
|
|
1014
1418
|
```json
|
|
@@ -1021,40 +1425,46 @@ The event type is also the message, so alert on the name — `authorize.rejected
|
|
|
1021
1425
|
`token.issued.failure`, `rate_limit.unavailable` — the same way you would on
|
|
1022
1426
|
`session_store_redis_error`.
|
|
1023
1427
|
|
|
1024
|
-
`
|
|
1428
|
+
`ADAPTERS_AUDIT_SINK=console` selects core's built-in sink instead: the bare event
|
|
1025
1429
|
as one JSON object per line, with no log envelope, for a pipeline that wants the
|
|
1026
1430
|
event and nothing else.
|
|
1027
1431
|
|
|
1028
|
-
**`
|
|
1432
|
+
**`LOGGING_LEVEL` does not gate the audit trail.** The audit stream's level is fixed
|
|
1029
1433
|
at `info`. `warn` is an ordinary production setting and `silent` a legitimate
|
|
1030
1434
|
one, and either silencing this would delete the record of every authentication
|
|
1031
1435
|
and issuance while looking like a logging preference. Where audit events go is
|
|
1032
|
-
chosen by `
|
|
1033
|
-
|
|
1034
|
-
deployment with no audit trail.
|
|
1436
|
+
chosen by `adapters.auditSink` — and that selector has **no `"none"`**. An unknown
|
|
1437
|
+
sink fails boot naming the sinks that are registered, rather than producing a
|
|
1438
|
+
deployment with no audit trail. A composition that installs no sink on purpose
|
|
1439
|
+
says so in core's own section, `core.declaredAbsent = ["auditSink"]`; this
|
|
1440
|
+
template always installs one.
|
|
1035
1441
|
|
|
1036
1442
|
To point it at a real sink — SIEM, log pipeline, message bus — register a
|
|
1037
|
-
builder in `
|
|
1038
|
-
|
|
1443
|
+
builder in `auditSinkModuleFor` (`src/modules.mts`) and select it; its options
|
|
1444
|
+
are the `audit-sink` module's section, under the sink's name:
|
|
1039
1445
|
|
|
1040
1446
|
```ts
|
|
1041
1447
|
factory.register("splunk-hec", (cfg) => createSplunkSink(cfg));
|
|
1042
1448
|
```
|
|
1043
1449
|
|
|
1044
1450
|
```hocon
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
splunk-hec { endpoint = ${?SPLUNK_HEC_URL}, token = ${?SPLUNK_HEC_TOKEN} }
|
|
1049
|
-
}
|
|
1451
|
+
adapters.auditSink = "splunk-hec"
|
|
1452
|
+
audit-sink {
|
|
1453
|
+
splunk-hec { endpoint = ${?SPLUNK_HEC_URL}, token = ${?SPLUNK_HEC_TOKEN} }
|
|
1050
1454
|
}
|
|
1051
1455
|
```
|
|
1052
1456
|
|
|
1457
|
+
To add an audit destination beside the configured one, contribute
|
|
1458
|
+
`auditHooks` from your own module; core fans out `auditSink` and `auditHooks`
|
|
1459
|
+
together.
|
|
1460
|
+
|
|
1053
1461
|
Sinks are fire-and-forget by contract: core dispatches without awaiting and
|
|
1054
1462
|
swallows rejections, so a slow or failing sink cannot add latency to — or
|
|
1055
1463
|
fail — an auth flow. The flip side is that a sink which cannot deliver drops
|
|
1056
|
-
|
|
1057
|
-
|
|
1464
|
+
the event. On its own a sink's failure is swallowed silently; once a module
|
|
1465
|
+
contributes `auditHooks`, the fan-out logs each failing call at error as
|
|
1466
|
+
`audit_sink_failed`, with the sink's position and the event's `type`. The
|
|
1467
|
+
drops are not counted yet — see **Not published yet** under Metrics below.
|
|
1058
1468
|
|
|
1059
1469
|
### Metrics
|
|
1060
1470
|
|