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