@o3co/create-auth-provider 0.17.0-rc.1 → 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.
Files changed (58) hide show
  1. package/package.json +1 -1
  2. package/templates/standalone/.env.example +0 -3
  3. package/templates/standalone/README.ja.md +42 -18
  4. package/templates/standalone/README.md +120 -44
  5. package/templates/standalone/config/application.conf +40 -36
  6. package/templates/standalone/config/reference.conf +21 -2
  7. package/templates/standalone/docker-compose.production.yml +5 -2
  8. package/templates/standalone/package.json +1 -1
  9. package/templates/standalone/src/__tests__/adapters.test.mts +80 -19
  10. package/templates/standalone/src/__tests__/admission-lifecycle.test.mts +87 -0
  11. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +112 -98
  12. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +27 -6
  13. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +86 -51
  14. package/templates/standalone/src/__tests__/audit-sink.test.mts +10 -9
  15. package/templates/standalone/src/__tests__/config-notices.test.mts +154 -0
  16. package/templates/standalone/src/__tests__/core-federations.test.mts +48 -25
  17. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +1 -1
  18. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +349 -54
  19. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +15 -21
  20. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +9 -15
  21. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +10 -4
  22. package/templates/standalone/src/__tests__/federation-types.test.mts +239 -0
  23. package/templates/standalone/src/__tests__/google-session.fixture.mts +60 -67
  24. package/templates/standalone/src/__tests__/library-reference-resolution.smoke.test.mts +1 -1
  25. package/templates/standalone/src/__tests__/library-references.fixture.mts +51 -46
  26. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +216 -0
  27. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +0 -2
  28. package/templates/standalone/src/__tests__/mfa-routes-budget.test.mts +122 -0
  29. package/templates/standalone/src/__tests__/mfa-switch.test.mts +70 -25
  30. package/templates/standalone/src/__tests__/own-modules.test.mts +40 -11
  31. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +3 -3
  32. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +8 -4
  33. package/templates/standalone/src/__tests__/replica-safety.test.mts +127 -19
  34. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +15 -20
  35. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +215 -0
  36. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +3 -7
  37. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +130 -0
  38. package/templates/standalone/src/__tests__/session-requirements.test.mts +114 -31
  39. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +270 -0
  40. package/templates/standalone/src/__tests__/smoke.test.mts +19 -28
  41. package/templates/standalone/src/__tests__/store-credential.test.mts +8 -13
  42. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +50 -34
  43. package/templates/standalone/src/__tests__/two-phase-config.test.mts +242 -76
  44. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +23 -31
  45. package/templates/standalone/src/adapters.mts +31 -18
  46. package/templates/standalone/src/app.mts +6 -5
  47. package/templates/standalone/src/bootRefusal.mts +117 -0
  48. package/templates/standalone/src/buildModules.mts +46 -96
  49. package/templates/standalone/src/configPath.mts +180 -153
  50. package/templates/standalone/src/mfaSwitch.mts +43 -20
  51. package/templates/standalone/src/modules.mts +25 -213
  52. package/templates/standalone/src/rootRenames.mts +35 -18
  53. package/templates/standalone/src/sections.mts +1 -0
  54. package/templates/standalone/tests/index.test.js +4 -3
  55. package/templates/versions.json +18 -18
  56. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +0 -134
  57. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +0 -202
  58. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +0 -144
@@ -1,6 +1,6 @@
1
1
  # @o3co/auth-provider-standalone
2
2
 
3
- Last updated: 2026-10-03
3
+ Last updated: 2026-10-06
4
4
 
5
5
  Deployable server template for auth.provider. This is the composition root — it reads config, loads modules, and starts the Express server. Generated by `@o3co/create-auth-provider`.
6
6
 
@@ -26,7 +26,7 @@ the Google and generic OpenID Connect federation adapters, `-federation-grants`,
26
26
  slot), the signing key store, the client and user repositories, the audit
27
27
  sink, the one shared
28
28
  Redis connection, the in-memory user-session stores, code repository and
29
- federation token store, and the federation config bridges —
29
+ federation token store —
30
30
  [`src/modules.mts`](src/modules.mts), with the defaults of the sections they
31
31
  own in [`config/reference.conf`](config/reference.conf). They are the
32
32
  scaffold's rather than a package's because each builds its component from
@@ -104,7 +104,7 @@ Nothing secret lives in `clients.yaml` for such a client.
104
104
 
105
105
  **What this does not do.** It does not make `/authorize` safe against forced navigation for a client that *is* first-party — that remains the accepted model here. What it prevents is a client that should never have been trusted with a silent code being registered into that position by accident.
106
106
 
107
- **Unmarked registrations.** A registration without the field is not first-party: on the defaults it is refused at `/authorize` until you mark it, with no admit-with-warning window. Mark every client you operate before pointing it at `/authorize`. `oauth.authorize.allowUnmarkedClients` is not a setting: a config or environment that sets it (`OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS`, any value) fails at boot with migration instructions rather than being silently ignored.
107
+ **Unmarked registrations.** A registration without the field is not first-party: on the defaults it is refused at `/authorize` until you mark it, with no admit-with-warning window. Mark every client you operate before pointing it at `/authorize`. `oauth.authorize.allowUnmarkedClients` is not a setting: a config or environment that sets it (`OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS`, any value) fails at boot rather than being silently ignored: the oauth module refuses the key as removed (`config-path-relocated`), and the variable as one bound to a removed key (`environment-variable-renamed`). Mark your clients, then delete the key and unset the variable.
108
108
 
109
109
  ## Multi-replica deployments
110
110
 
@@ -127,7 +127,11 @@ It takes four shapes, all of them Express's own:
127
127
 
128
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.
129
129
 
130
- **Point `adapters.rateLimiter` at Redis.** It defaults to `"memory"`, which is per-process: with N replicas every configured limit is effectively N times larger and resets on every deploy. The memory adapter is also **evadable under bucket exhaustion**: it caps itself at 10,000 buckets and, at the cap, admitting a new key evicts the bucket closest to reset — so an attacker who can present many source IPs (and `req.ip` is client-influenced whenever `HTTP_TRUST_PROXY` is broader than your actual hops) can churn the table until a target's counter is evicted and starts over. That is acceptable for one dev process; it is not a production rate limit. The login guard runs on this same shared component, so one setting covers both the OAuth endpoints and `/session/login`. The login window and limit stay configured at `session.rateLimit.login`; the session module contributes them as the `login` budget both adapters read, so there is nothing to restate.
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.
131
135
 
132
136
  **Behind a BFF, raise `limits.token` before you need to.** The OAuth-endpoint
133
137
  rate limits key on `req.ip` (`packages/core/src/ratelimit/guard.mts` builds the
@@ -173,7 +177,7 @@ raising it globally to accommodate one topology would weaken it for the other.
173
177
  Put the throttling that protects the BFF's *own* users in front of the BFF,
174
178
  where per-user identity still exists.
175
179
 
176
- **Set `CORE_DEPLOYMENT_MODE=multi` once you run more than one replica.** Boot then *fails* if any in-memory store that has to be shared is still wired, naming every offender and what it costs — user sessions forking (back-channel logout reaches one replica, a logged-out session stays valid on the others), rate-limit counters multiplying, access-token revocation not propagating, a single-use client assertion or WebAuthn challenge replayable once per replica. The check reads the declaration each installed module carries on its own manifest rather than a list of library module names, so this template's own in-memory modules — the user-session stores (`ADAPTERS_USER_SESSION_STORES=memory`), the authorization-code repository (`ADAPTERS_CODE_REPOSITORY=memory`) and the federation token store (`ADAPTERS_FEDERATION_TOKEN_STORE=memory`, the default) — are refused by name too. So are express-session's own store under `SESSION_STORE_STORAGE_TYPE=memory` (#474) and the default memory rate limiter (`core-rate-limiter-memory`, `ADAPTERS_RATE_LIMITER=memory`). With the mode unset nothing is refused: every one of them is listed in a single `replica_unsafe_adapters` warning at boot. (The login and WebAuthn-options routes carry per-process fallback limiters that warn on their own, but only in a composition that wires no `rateLimiter` at all, which this template never does; see the operator runbook.) With `CORE_DEPLOYMENT_MODE=single` the check is silent, because you have said there is one replica. This template does not install DPoP; a composition that adds it records every accepted proof in the same replay seen-set as `private_key_jwt` (`ADAPTERS_REPLAY_SEEN_SET`), so it gets the same answer — `memory` is refused under `CORE_DEPLOYMENT_MODE=multi` and listed in the unset-mode warning, and the shipped `redis` shares DPoP's records across replicas — see the dpop package's [operator requirements](../../packages/dpop/README.md#operator-requirements).
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).
177
181
 
178
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.
179
183
 
@@ -296,7 +300,10 @@ Configuration is loaded from `config/application.conf` (HOCON format), over the
296
300
  `http.cors`), `key-store`, the shared Redis connection's `redis-clients`,
297
301
  `repositories`, the in-process code repository's
298
302
  `standalone-in-memory-code-repository` and `audit-sink` — and of the
299
- composition root's own `adapters` and `mfaMode` (below). Set a deployment's own value in
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
300
307
  the two files above, not there. `config/reference.conf` binds each of these
301
308
  keys' variables beside its default, so a value either file above sets wins
302
309
  over the variable, except for `HTTP_PORT`, `HTTP_TRUST_PROXY`,
@@ -311,35 +318,61 @@ It reads the two files above once, under one snapshot of the environment
311
318
  [`src/configPath.mts`](src/configPath.mts)) — so a file replaced, or a
312
319
  variable changed, while the process starts cannot make boot parse something
313
320
  other than what the modules were chosen by. First, before it knows its
314
- modules, it reads the switches `buildModules` chooses them by — the
315
- federations, the features — and the `sessionRequirements` it derives the
316
- session requirements it expects from, from the two files above over core's
317
- `reference.conf` alone (`readSwitches`, with core's transitional reader),
318
- parsing those paths (`SWITCHES`) and nothing else. It reads `adapters` — which
319
- adapter fills each slot, the composition root's own section — from the two
320
- files above over the template's `config/reference.conf`, with the template's
321
- own schema (`readAdapters`): a selection still written at the path it moved
322
- from, or a variable renamed with it, is refused there, before any module is
323
- chosen, naming the new path and variable. It reads the MFA switch `mfaMode`
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`
324
328
  (`MFA_MODE`), the composition root's own key, the same way, with the
325
329
  template's own schema (`readMfaSwitch`), and beside them the user
326
330
  repository's HTTP settings, unparsed, which the Store-backed MFA factor store
327
- is built over. Boot is not handed `adapters` or `mfaMode`. It sees nothing
328
- a package's `reference.conf` alone sets — none is layered yet — and a module
329
- you add to `buildModules` that reads its configuration when it is built adds
330
- the paths it reads to `SWITCHES`. The log level is read before boot too, as
331
+ 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
331
342
  the `logging` module's section, with that module's schema, over the template's
332
343
  `reference.conf` (`readLogging`): the logger exists before boot, since the
333
- template logs while it reads its configuration and chooses its modules. Then it hands `createApp` the configuration as resolved over every
334
- loaded module's `reference.conf` (`resolveForBoot`), unparsed, with the session
335
- requirements phase one derived written in, and the `mfa` section as the MFA
336
- switch decides it (see [Multi-factor authentication](#multi-factor-authentication)):
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)):
337
360
  boot parses it once, with every loaded module's schema. What
338
361
  the template reads after boot — the trusted hops, the port, the readiness
339
362
  deadline — it reads from the `http` module: core's `httpSettings` slot and the
340
- template's `httpHostSettings`. A top-level section no
341
- loaded module owns is kept and logged once at boot as
342
- `config_sections_ignored`: that is where a misspelt section name shows.
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.
343
376
 
344
377
  Values in the overlay take precedence over `application.conf`. The scaffold
345
378
  ships with `development.conf`, `production.conf` and `mailpit.conf` (the
@@ -359,6 +392,7 @@ fails before boot, naming it.
359
392
  | Variable | Default | Description |
360
393
  |---|---|---|
361
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) |
362
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)) |
363
397
  | `ADAPTERS_ACCESS_TOKEN_DENYLIST` | `redis` | `adapters.accessTokenDenylist`: `memory` or `redis` |
364
398
  | `ADAPTERS_REPLAY_SEEN_SET` | `redis` | `adapters.replaySeenSet`: the replay seen-set behind `private_key_jwt`, `memory` or `redis` |
@@ -447,7 +481,6 @@ every relying party must be handed the shared secret — which also lets it
447
481
  |---|---|---|
448
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`). |
449
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. |
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
484
  | `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | Refresh token lifetime in seconds. Whole positive number, at most one year (`31536000`). |
452
485
 
453
486
  Each is read only as decimal digits. Exporting one as an empty string is a
@@ -457,6 +490,12 @@ boot failure, not a fallback (HOCON resolves `FOO=` to `""`), and so is a hex
457
490
  Only token exchange (RFC 8693) reads an `expires_in` request parameter; every
458
491
  other grant ignores it and mints the default.
459
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
+
460
499
  ### Grant Types
461
500
 
462
501
  | Variable | Default | Description |
@@ -468,11 +507,11 @@ other grant ignores it and mints the default.
468
507
  | `OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED` | `false` | Enable the jwt-bearer grant type (RFC 7523) |
469
508
 
470
509
  Each switch is its module's key — `oauth-session.enabled`, and
471
- `oauth-authorization.grants.<grant>.enabled` for the others — which the
472
- template reads before boot, from its own files and the environment, to choose
473
- the modules. `OAUTH_GRANTS_<GRANT>_ENABLED`, the old names, refuse boot set
474
- alone or beside the new name at a different value; beside it at the same value,
475
- they boot.
510
+ `oauth-authorization.grants.<grant>.enabled` for the others. Both modules are
511
+ always loaded and read their switches at boot, from the configuration boot
512
+ parses; the template reads none of them before boot. `OAUTH_GRANTS_<GRANT>_ENABLED`,
513
+ the old names, refuse boot set alone or beside the new name at a different
514
+ value; beside it at the same value, they boot.
476
515
 
477
516
  ### Session
478
517
 
@@ -601,6 +640,44 @@ key's path under `core.federations`; a `FEDERATIONS_GOOGLE_*` or
601
640
  `FEDERATIONS_OIDC_*` variable set alone, or beside its new name at a different
602
641
  value, is refused before any module is chosen.
603
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
+
604
681
  | Variable | Default | Description |
605
682
  |---|---|---|
606
683
  | `CORE_FEDERATIONS_GOOGLE_ENABLED` | `false` | Enable Google OAuth federation |
@@ -641,9 +718,8 @@ core.federations {
641
718
  }
642
719
  ```
643
720
 
644
- The entry's `type` names the implementation. `core.federations.google` without a
645
- `type` is the built-in Google federation; with `type = "oidc"` it is a generic
646
- OIDC instance named `google`, and the built-in module is not composed.
721
+ The entry's `type` names the implementation, never its name: an entry named
722
+ `google` with `type = "oidc"` is a generic OIDC federation named `google`.
647
723
 
648
724
  Every field the package accepts — `scopes`, `discovery` / `endpoints`,
649
725
  `privateKey`, `userInfo`, `idTokenSignedResponseAlg`, `clockToleranceSeconds` —
@@ -997,7 +1073,7 @@ auth.provider instances cannot collide in the same database:
997
1073
 
998
1074
  | Variable | Default | Description |
999
1075
  |---|---|---|
1000
- | `REDIS_SESSION_STORES_KEY_PREFIX` | `ss:` | Outer prefix for user sessions, RP registry, session-family index, and session-federation index. |
1076
+ | `REDIS_SESSION_STORES_KEY_PREFIX` | `ss:` | Outer prefix for user sessions, the subject session index, the subject-revocation boundaries and the session lifecycle records. |
1001
1077
  | `REDIS_REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX` | `rtfam:` | Prefix for refresh-token family records. |
1002
1078
  | `REDIS_CODE_REPOSITORY_KEY_PREFIX` | `oauth:code:` | Prefix for OAuth authorization codes. |
1003
1079
  | `REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX` | `ft:` | Prefix for federation token records, their per-session index and their lock keys. |
@@ -1021,15 +1097,15 @@ are configured per federation (`CORE_FEDERATIONS_GOOGLE_CALLBACK_URL`,
1021
1097
 
1022
1098
  [`src/buildModules.mts`](src/buildModules.mts) is the single source of truth for which modules are composed and in what order — read it rather than a copy of it. The boot planner resolves `requires` / `provides` topologically, but it mounts routes and middleware in list order unless a module declares `before` / `after` — so position matters for anything that mounts. These are the rules to keep when you edit the list:
1023
1099
 
1024
- 1. **`sessionStoreModuleFor(config)` stays first.** It mounts the `express-session` middleware and declares no `before` / `after`, so its position in the list is what puts it ahead of every route that reads `req.session`. It is built from `config` so that `session-store.storage.type = "memory"` declares itself replica-unsafe.
1025
- 2. **Under `/oauth`, the order does not matter.** `federationGrantsModules` (while federation grants are enabled), `oauthModule` and any module of your own may all mount routes under `/oauth`. `oauthModule`'s router parses the bodies of its own routes only, so each module's requests reach its own parsers whatever the list order — provided every module under `/oauth` parses its own body and scopes its parsers to exactly its own paths, as the shipped ones do — a route (`router.all(path, parser)` or the route's own handler list), not `router.use(path, parser)`, which matches every path beneath `path` as well. The federation grants browser half orders itself after the session middleware with its own `after`.
1100
+ 1. **`sessionStoreModule` stays first.** It mounts the `express-session` middleware and declares no `before` / `after`, so its position in the list is what puts it ahead of every route that reads `req.session`. It declares its replica safety from its own section, so `session-store.storage.type = "memory"` is replica-unsafe and refused under `core.deployment.mode = "multi"`.
1101
+ 2. **Under `/oauth`, the order does not matter.** `federationGrantsModules` (while federation grants are enabled), `oauthEndpointsModule` and any module of your own may all mount routes under `/oauth`. `oauthEndpointsModule`'s router parses the bodies of its own routes only, so each module's requests reach its own parsers whatever the list order — provided every module under `/oauth` parses its own body and scopes its parsers to exactly its own paths, as the shipped ones do — a route (`router.all(path, parser)` or the route's own handler list), not `router.use(path, parser)`, which matches every path beneath `path` as well. The federation grants browser half orders itself after the session middleware with its own `after`.
1026
1102
  3. **One module per store slot.** Each adapter switch — `adapters.federationTokenStore`, `adapters.userSessionStores`, `adapters.rateLimiter`, `adapters.codeRepository`, `adapters.accessTokenDenylist`, `adapters.replaySeenSet`, `adapters.consentStore`, the two federation-grant store switches and the two MFA store switches — picks one of a memory / Redis pair (or, for the MFA factors, the Store). Both provide the same slot, so wiring both is a boot-time slot collision. `adapters.consentStore = "none"` wires neither, the federation-grant stores are wired only while the feature is enabled, and the MFA stores only while `MFA_MODE` installs MFA.
1027
1103
  4. **The shared Redis connection comes with the first Redis-backed module.** `standaloneRedisClientsModule` opens the one ioredis connection every Redis adapter here uses, from its own section (`redis-clients`), and is added whenever a composed module needs one. The refresh-token family store is on Redis in the shipped composition, so a deployment always has it; the in-memory family store is a test override (`overrides.refreshTokenFamilyModules`).
1028
1104
  5. **The template's own settings modules are always composed.** `loggingModule` and `httpModule` own `logging {}` and `http {}`, the CORS list among `http`'s keys (`http.cors.allowedOrigins`). `httpModule` provides core's `httpSettings`, which is authoritative, so no `overrideComponents` entry replaces it while the module is loaded; it also provides the template's `httpHostSettings`, which `app.mts` reads after boot for the port and the readiness deadline. The logger is built before boot from the `logging` section (`readLogging`), not by a module, and handed to boot as the `logger` component.
1029
- 6. **A federation adapter comes with its config bridge.** `googleFederationModule` with `googleFederationConfigModule` — only for an enabled `core.federations.google` entry whose `type` is `google`, so a `type = "oidc"` section named `google` is not composed twice — and one `oidcFederationModule(name)` per enabled `type = "oidc"` section, with the one `oidcFederationConfigModule` they share. A bridge's provider throws when its section is absent, so the pair is included at composition time rather than gated inside it.
1030
- 7. **The mail sender follows the environment.** `@o3co/auth-provider-standard`'s development sender, which logs each code, where the configuration was selected as `development`; its module installs only where that name, and `CONFIG_ENV` and `NODE_ENV` wherever they are set, each read `development` or `test`, and refuses the boot otherwise, or where `core.deployment.mode` is `multi`. Under any other name, the SMTP sender's module, whose section is `standard-smtp-mail-sender` ([the package's README](../../packages/standard/README.md)); its sender is built only where something reads the `mailSender` slot, and there the boot needs `STANDARD_SMTP_MAIL_SENDER_HOST` and `STANDARD_SMTP_MAIL_SENDER_FROM`. While `MFA_MODE` is `off` nothing reads the slot, so the template boots without them; with MFA on, the MFA module and the operator reset read it. A sender of your own replaces this choice: pass its module where [`src/app.mts`](src/app.mts) calls `buildModules`, as `buildModules(switches, { environment: env, logger, mailSenderModules: [mySenderModule] })`. Given, `mailSenderModules` is installed under every environment name and the bundled sender is not, so the two never both provide the slot; an empty list installs no sender, and a module that needs one (the MFA email factor, or the MFA module with `requireEmailProof = "always"`) then refuses the boot.
1105
+ 6. **The federation types are always composed.** `googleFederationTypeModule()` and `oidcFederationTypeModule()` are listed whatever `core.federations` says: each contributes its type, and boot builds one federation per enabled entry of it, under the entry's name, so a federation is added or removed in configuration alone. Nothing in the list reads an entry. A module that contributes a federation under an entry's name directly beside an entry dispatched by its type is a boot-time `duplicate-contribute`: add a federation of another kind as a type module, and give its entries that type.
1106
+ 7. **The mail sender follows the environment.** `@o3co/auth-provider-standard`'s development sender, which logs each code, where the configuration was selected as `development`; its module installs only where that name, and `CONFIG_ENV` and `NODE_ENV` wherever they are set, each read `development` or `test`, and refuses the boot otherwise, or where `core.deployment.mode` is `multi`. Under any other name, the SMTP sender's module, whose section is `standard-smtp-mail-sender` ([the package's README](../../packages/standard/README.md)); its sender is built only where something reads the `mailSender` slot, and there the boot needs `STANDARD_SMTP_MAIL_SENDER_HOST` and `STANDARD_SMTP_MAIL_SENDER_FROM`. While `MFA_MODE` is `off` nothing reads the slot, so the template boots without them; with MFA on, the MFA module and the operator reset read it. A sender of your own replaces this choice: pass its module where [`src/app.mts`](src/app.mts) calls `buildModules`, as `buildModules(switches, { environment: env, mailSenderModules: [mySenderModule] })`. Given, `mailSenderModules` is installed under every environment name and the bundled sender is not, so the two never both provide the slot; an empty list installs no sender, and a module that needs one (the MFA email factor, or the MFA module with `requireEmailProof = "always"`) then refuses the boot.
1031
1107
 
1032
- `jwksModule` (from core) is always composed: a provider that signs tokens publishes its verification keys whether or not an issuer is configured. What each route module mounts is in its package's README. One behaviour to know when composing: `sessionModule`'s `POST /session/logout` deletes the `UserSession` record (so `/oauth/introspect` and `/oauth/userinfo` stop honouring tokens minted from that session), the subject index and the federation entries — but it does **not** revoke refresh-token families; `POST /oauth/logout` is the endpoint that runs the full cascade. See [Which logout endpoint invalidates what](../../docs/operator-runbook.md#which-logout-endpoint-invalidates-what).
1108
+ `jwksModule` (from core) is always composed: a provider that signs tokens publishes its verification keys whether or not an issuer is configured. What each route module mounts is in its package's README. The template composes core's `sessionLifecycleModule`: a login opens its session's record, a code exchange joins it, and both `POST /session/logout` and `POST /oauth/logout` close the session through the lifecycle, which revokes the session's refresh-token families and tells its relying parties. See [Which logout endpoint invalidates what](../../docs/operator-runbook.md#which-logout-endpoint-invalidates-what).
1033
1109
 
1034
1110
  ## Built-in Routes
1035
1111
 
@@ -1243,14 +1319,14 @@ unready one.
1243
1319
 
1244
1320
  ## Adding Custom Modules
1245
1321
 
1246
- To add a custom module, import it in `src/buildModules.mts` and add it to the array `buildModules` returns. The boot planner resolves dependencies by what modules `require` and `provide`, but it mounts routes and middleware in list order wherever no `before` / `after` says otherwise. So position matters for middleware that other routes depend on — the session store module, rule 1 of [Module Composition Order](#module-composition-order). A route under a prefix another module also uses, such as `/oauth`, needs no particular position (rule 2), provided it parses its own body and scopes its parsers to exactly its own paths: `oauthModule`'s router parses only its own routes', and a parser that ran for every request under the prefix would read other modules' bodies for them. A module that mounts nothing of the first kind can go at the end:
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:
1247
1323
 
1248
1324
  ```diff
1249
1325
  // src/buildModules.mts
1250
1326
  +import { myCustomModule } from "./my-custom-module.mjs";
1251
1327
  …
1252
1328
  return [
1253
- sessionStoreModuleFor(config),
1329
+ sessionStoreModule,
1254
1330
  …
1255
1331
  ...(federationGrantsEnabled || mfaInstalled ? [subjectRevocationServiceModule] : []),
1256
1332
  + myCustomModule,
@@ -28,12 +28,11 @@
28
28
  # `oauth-authorization.grants.clientCredentials.enabled = true` in their own
29
29
  # layer.
30
30
  #
31
- # The template reads these switches before it chooses its modules, from its
32
- # own files, so this file writes every switch and binds its variable: one set
33
- # only in the package's reference.conf would read as unset there, and a
34
- # variable bound only there would install no grant. The env-override line is
35
- # repeated after each value because a layer's own value wins over a variable
36
- # bound below it.
31
+ # The modules read these switches at boot, from the configuration boot parses;
32
+ # the template reads none of them before. This file writes the template's
33
+ # choices and binds each variable again: the env-override line is repeated
34
+ # after each value because a layer's own value wins over a variable bound
35
+ # below it.
37
36
  #
38
37
  # PKCE is mandatory for every authorization-code client and `S256` is the only
39
38
  # method the provider accepts -- fixed policy (OAuth 2.1 §4.1.1 / RFC 9700
@@ -94,8 +93,9 @@ oauth {
94
93
  # /authorize refuses any client not marked `firstParty: true` in
95
94
  # clients.yaml — the template's example clients are marked. There is no
96
95
  # opt-out: the one-time `allowUnmarkedClients` migration flag is removed,
97
- # and a config or environment still setting it fails at boot with
98
- # migration instructions (via the tombstone in core's reference.conf).
96
+ # and a config or environment still setting it fails at boot: the oauth
97
+ # module refuses the key as removed, and an exported
98
+ # OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS as the variable of a removed key.
99
99
  # POST /oauth/revoke revokes access tokens by writing their `jti` to the
100
100
  # denylist wired below, and boot FAILS if that denylist is missing — an
101
101
  # RFC 7009 endpoint must answer 200, so an unwired denylist would tell every
@@ -154,30 +154,20 @@ core.sessionRequirements.expected = []
154
154
  # the recorded decision not to make that check) — each refused at boot when
155
155
  # missing. Connections and the Redis grant store's key ring have no
156
156
  # environment form; write them in a deployment-owned HOCON layer (README:
157
- # Federation Grants). The template reads `federation-grants.enabled` before it
158
- # chooses its modules, from its own files, which is why this file binds
159
- # FEDERATION_GRANTS_ENABLED too.
160
- federation-grants {
161
- enabled = false
162
- enabled = ${?FEDERATION_GRANTS_ENABLED}
163
- identityLookup = "required"
164
- identityLookup = ${?FEDERATION_GRANTS_IDENTITY_LOOKUP}
165
- consent {
166
- url = ${?FEDERATION_GRANTS_CONSENT_URL}
167
- }
168
- }
169
-
170
- # The Redis grant store seals every credential; "required" needs its key ring,
171
- # `redis-federation-grant-store.encryptionKeys`.
172
- redis-federation-grant-store {
173
- encryptionMode = "required"
174
- encryptionMode = ${?REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE}
175
- }
157
+ # Federation Grants). The section's defaults and its other variables
158
+ # (FEDERATION_GRANTS_IDENTITY_LOOKUP, FEDERATION_GRANTS_CONSENT_URL) are the
159
+ # federation-grants package's reference.conf, and the Redis grant store's
160
+ # (REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE, "required" by default) the
161
+ # Redis package's: each is layered only where a module of its package is
162
+ # installed. The
163
+ # template reads `federation-grants.enabled` before it chooses its modules,
164
+ # from its own files, which is why this file binds FEDERATION_GRANTS_ENABLED;
165
+ # unset, it writes nothing, and the feature is off.
166
+ federation-grants.enabled = ${?FEDERATION_GRANTS_ENABLED}
176
167
 
177
168
  # The session store's section: the session cookie and the store
178
- # express-session keeps sessions in. The template reads `storage` before it
179
- # chooses its modules (a memory store declares itself replica-unsafe), from
180
- # its own files, which is why this file binds SESSION_STORE_STORAGE_TYPE too.
169
+ # express-session keeps sessions in. A memory store declares itself
170
+ # replica-unsafe from this section.
181
171
  session-store {
182
172
  secret = ${?SESSION_STORE_SECRET}
183
173
  # A `__Host-` name requires Secure, Path=/ and no Domain attribute. If
@@ -218,10 +208,11 @@ session {
218
208
  # ignored. The federation flow has a separate list per provider (see
219
209
  # `core.federations` below).
220
210
  redirectAllowlist = []
221
- # Rate limiting for POST /session/login (brute-force protection), in
222
- # windowMs (milliseconds); the session module contributes it as the `login`
223
- # budget, kept in this layer so operators can tweak it. It does NOT govern
224
- # OAuth endpoint rate limiting: the `rateLimiter` component
211
+ # POST /session/login's own attempt limit (brute-force protection), in
212
+ # windowMs (milliseconds), counted on the attempt counter
213
+ # (`adapters.attemptCounter`); kept in this layer so operators can tweak
214
+ # it. No rate limiter changes it. It does NOT govern OAuth endpoint rate
215
+ # limiting: the `rateLimiter` component
225
216
  # (memoryRateLimiterModule or redisRateLimiterModule) reads its own section,
226
217
  # `core-rate-limiter-memory` / `redis-rate-limiter` (with `windowSeconds`
227
218
  # instead of `windowMs`). Switch via `adapters.rateLimiter = "memory" |
@@ -233,15 +224,28 @@ session {
233
224
  }
234
225
  }
235
226
 
227
+ # The MFA routes' budget, 60 per 300 s, is config/reference.conf's, given to
228
+ # both limiters so that switching `adapters.rateLimiter` keeps it. Change it
229
+ # for the limiter this deployment wires, in its own section:
230
+ #
231
+ # redis-rate-limiter.limits.mfa { limit = 30, windowSeconds = 300 }
232
+
236
233
  # The federations, in core's own section, keyed by the name each is reached
237
234
  # at (/session/oauth/federation/<name>). A key of one is bound to the
238
- # variable named after its path, CORE_FEDERATIONS_<NAME>_<KEY>.
235
+ # variable named after its path, CORE_FEDERATIONS_<NAME>_<KEY>. Each entry
236
+ # names its `type`, and boot hands an enabled entry to the module that handles
237
+ # that type: the template loads the `google` and `oidc` types, so an entry of
238
+ # either type under any name is a federation of its own. An entry without a
239
+ # `type`, enabled or not, refuses the boot, and so does an enabled entry of a
240
+ # type no loaded module handles.
239
241
  core.federations {
240
242
  # Template ships a Google federation declaration disabled-by-default.
241
- # Operators enable + populate credentials via env vars.
243
+ # Operators enable + populate credentials via env vars. A second Google
244
+ # client is another entry with `type = "google"` and its own callbackURL.
242
245
  google {
243
246
  enabled = false
244
247
  enabled = ${?CORE_FEDERATIONS_GOOGLE_ENABLED}
248
+ type = "google"
245
249
  clientId = ${?CORE_FEDERATIONS_GOOGLE_CLIENT_ID}
246
250
  clientSecret = ${?CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET}
247
251
  callbackURL = "http://localhost:3000/session/oauth/federation/google/callback"
@@ -1,6 +1,7 @@
1
1
  # Defaults of the standalone template's own modules (src/modules.mts), each in
2
2
  # the section named after its module, and of its own `adapters` and
3
- # `mfaMode`, each key with its variable, named after its path. Layered
3
+ # `mfaMode`, each key with its variable, named after its path, and the MFA
4
+ # routes' budget in each rate limiter's section (below `mfaMode`). Layered
4
5
  # beneath application.conf and {env}.conf: set a deployment's own values
5
6
  # there, not here. A value either file sets wins over
6
7
  # the variable bound here, except for HTTP_PORT, HTTP_TRUST_PROXY,
@@ -217,9 +218,16 @@ audit-sink {}
217
218
  # replica, and `core.deployment.mode = "multi"` refuses each memory store by
218
219
  # name. Each Redis store shares the one socket `redis-clients` opens.
219
220
  adapters {
220
- # The OAuth endpoints' and /session/login's rate-limit counters.
221
+ # The rate-limit counters: the OAuth endpoints' and the MFA routes'.
221
222
  rateLimiter = "memory"
222
223
  rateLimiter = ${?ADAPTERS_RATE_LIMITER}
224
+ # The counter behind /session/login's own attempt limit
225
+ # (session.rateLimit.login), and device verification's
226
+ # (device-grant.rateLimit) when the device grant is added. `memory` wires
227
+ # none: each counts per process, which `core.deployment.mode = "multi"`
228
+ # refuses. `redis` needs a server whose maxmemory-policy is `noeviction`.
229
+ attemptCounter = "memory"
230
+ attemptCounter = ${?ADAPTERS_ATTEMPT_COUNTER}
223
231
  # The four user-session stores and the subject-level revocation pair. They
224
232
  # must be on Redis whenever express-session's store is, or a restart leaves
225
233
  # every browser a session whose user session is gone.
@@ -294,6 +302,17 @@ adapters {
294
302
  mfaMode = "required"
295
303
  mfaMode = ${?MFA_MODE}
296
304
 
305
+ # The MFA routes' budget: the one setting this file writes in sections of
306
+ # modules other than the template's own. Every /session/mfa POST is limited
307
+ # per client address under the prefix `mfa` (`mfa:ip:<ip>`) by the wired
308
+ # limiter alone: the MFA package ships no budget of its own. Both limiters
309
+ # carry it, so switching `adapters.rateLimiter` keeps it; written here, beside
310
+ # each package's own defaults, the limiter not wired has it as a default
311
+ # rather than as a setting nothing reads. Without an entry the limiter's
312
+ # `defaultLimit` (60 per 60 s) applies. The MFA lock is separate and unchanged.
313
+ core-rate-limiter-memory.limits.mfa { limit = 60, windowSeconds = 300 }
314
+ redis-rate-limiter.limits.mfa { limit = 60, windowSeconds = 300 }
315
+
297
316
  # The old paths of these sections refuse boot naming the new ones: `cors`,
298
317
  # `oauth.jwt.signingKey`, `refreshTokenFamilyStore.redis`,
299
318
  # `repositories.code` and `audit.sink`; the old adapter switches
@@ -142,8 +142,8 @@ services:
142
142
  SESSION_STORE_STORAGE_TYPE: redis
143
143
  SESSION_STORE_STORAGE_REDIS_URL: redis://redis:6379
144
144
  # The UserSession stores: the provider's own record of who is signed in,
145
- # which RPs a session has touched, and the family / federation indexes
146
- # hanging off it. They MUST be on Redis whenever the express-session
145
+ # the subject index and revocation boundaries beside it, and the session
146
+ # lifecycle records. They MUST be on Redis whenever the express-session
147
147
  # store above is, and this line is the fix for what happens when they
148
148
  # are not.
149
149
  #
@@ -161,6 +161,9 @@ services:
161
161
  # The memory rate limiter is per-process and evadable under bucket
162
162
  # exhaustion (see README) — production points it at Redis.
163
163
  ADAPTERS_RATE_LIMITER: redis
164
+ # The counter behind the login's own attempt limit: on Redis, a restart
165
+ # does not reset it. Redis must keep `maxmemory-policy noeviction`.
166
+ ADAPTERS_ATTEMPT_COUNTER: redis
164
167
  # The federation token store is deliberately left at its `memory`
165
168
  # default, and that is coherent here rather than an oversight: this
166
169
  # template ships every federation disabled, so nothing writes to the
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth-provider-standalone",
3
- "version": "0.17.0-rc.1",
3
+ "version": "0.17.0-rc.2",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "engines": {