@o3co/create-auth-provider 0.17.0-rc.2 → 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.
Files changed (39) hide show
  1. package/README.ja.md +3 -1
  2. package/README.md +5 -2
  3. package/package.json +1 -1
  4. package/templates/standalone/.env.example +7 -2
  5. package/templates/standalone/README.ja.md +14 -10
  6. package/templates/standalone/README.md +26 -20
  7. package/templates/standalone/config/application.conf +1 -0
  8. package/templates/standalone/config/clients.yaml.example +8 -0
  9. package/templates/standalone/config/development.conf +2 -2
  10. package/templates/standalone/config/mailpit.conf +10 -5
  11. package/templates/standalone/config/reference.conf +9 -3
  12. package/templates/standalone/docker-compose.mailpit.yml +3 -0
  13. package/templates/standalone/package.json +1 -1
  14. package/templates/standalone/src/__tests__/adapters.test.mts +12 -6
  15. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +13 -5
  16. package/templates/standalone/src/__tests__/config-notices.test.mts +4 -1
  17. package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +48 -0
  18. package/templates/standalone/src/__tests__/core-federations.test.mts +9 -6
  19. package/templates/standalone/src/__tests__/core-sections.test.mts +7 -5
  20. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +89 -86
  21. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +4 -0
  22. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +81 -14
  23. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +8 -4
  24. package/templates/standalone/src/__tests__/mfa-switch.test.mts +79 -21
  25. package/templates/standalone/src/__tests__/own-modules.test.mts +38 -12
  26. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +214 -0
  27. package/templates/standalone/src/__tests__/replica-safety.test.mts +4 -0
  28. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +10 -3
  29. package/templates/standalone/src/__tests__/sections.test.mts +24 -0
  30. package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +4 -0
  31. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +148 -0
  32. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +8 -4
  33. package/templates/standalone/src/__tests__/two-phase-config.test.mts +2 -1
  34. package/templates/standalone/src/adapters.mts +2 -2
  35. package/templates/standalone/src/mfaSwitch.mts +6 -7
  36. package/templates/standalone/src/modules.mts +12 -6
  37. package/templates/standalone/src/rootRenames.mts +12 -13
  38. package/templates/standalone/src/sections.mts +12 -2
  39. package/templates/versions.json +18 -18
package/README.ja.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @o3co/create-auth-provider
2
2
 
3
- 最終更新: 2026-10-03
3
+ 最終更新: 2026-10-06
4
4
 
5
5
  auth.provider 用の CLI スキャフォルダーです。内包するテンプレートの 1 つから新しいサーバープロジェクトを生成します。
6
6
 
@@ -86,6 +86,8 @@ SMTP リレー)を設定するか、`MFA_MODE=off` で MFA を無効にする
86
86
  `mfaMode = ${?MFA_MODE}` をこの順に追記するので、後のテンプレートがデフォルトを
87
87
  変えてもプロジェクトは off のままで、`MFA_MODE` を設定すれば作り直さずに MFA を
88
88
  有効にできます。MFA パッケージは依存に残り、ほかには何も削除・変更しません。
89
+ プロジェクトに含まれるテスト(`pnpm run test`)はスイッチをプロジェクトの設定から
90
+ 読み、その値が意味する動作を確かめるので、`--no-mfa` の有無にかかわらず通ります。
89
91
 
90
92
  生成されるプロジェクトは pnpm のプロジェクトです。`Dockerfile` は
91
93
  `pnpm install --frozen-lockfile` でインストールし、ビルドの許可リストは
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # @o3co/create-auth-provider
2
2
 
3
- Last updated: 2026-10-03
3
+ Last updated: 2026-10-06
4
4
 
5
5
  CLI scaffolder for auth.provider. Generates a new server project from one of the built-in templates.
6
6
 
@@ -85,7 +85,10 @@ switch installs and needs is in the template's README.
85
85
  it appends `mfaMode = "off"` and then `mfaMode = ${?MFA_MODE}` to
86
86
  `config/application.conf`, so the project stays off whatever default a later
87
87
  template ships, and `MFA_MODE` still turns MFA on without re-scaffolding. The
88
- MFA package stays a dependency; nothing else is removed or changed.
88
+ MFA package stays a dependency; nothing else is removed or changed. The
89
+ project's own suite (`pnpm run test`) reads the switch from the project's
90
+ configuration and asserts what it implies, so it passes with or without
91
+ `--no-mfa`.
89
92
 
90
93
  The generated project is a pnpm project: its `Dockerfile` installs with
91
94
  `pnpm install --frozen-lockfile`, and its build allowlist lives in
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/create-auth-provider",
3
- "version": "0.17.0-rc.2",
3
+ "version": "0.17.0-rc.3",
4
4
  "description": "Scaffold a new o3co/auth.provider project from one of its templates",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -78,6 +78,10 @@ SESSION_STORE_STORAGE_REDIS_URL=redis://redis:6379
78
78
  # defaults to redis://localhost:6379, which inside the container is the
79
79
  # container, so leaving it unset makes the compose boot dial nothing.
80
80
  REDIS_CLIENTS_URL=redis://redis:6379
81
+ # The Redis stores refuse to boot unless the server reports maxmemory-policy
82
+ # noeviction (README: Multi-replica deployments). Only for a server that does
83
+ # run noeviction but will not report it (INFO and CONFIG refused to this user):
84
+ # REDIS_CLIENTS_ASSUME_NO_EVICTION=true
81
85
 
82
86
  # --- Federation grants (#593, optional) --------------------------------------
83
87
  # Offline delegation of an upstream IdP's tokens to a backend or an agent, on a
@@ -126,8 +130,9 @@ REDIS_CLIENTS_URL=redis://redis:6379
126
130
  # Or run without MFA:
127
131
  # MFA_MODE=off
128
132
  # To read the mail in development, leave every STANDARD_SMTP_MAIL_SENDER_*
129
- # unset (USER and PASSWORD included) and run the Mailpit
130
- # overlay, which sets them (README: Mailpit for the SMTP sender):
133
+ # unset (USER and PASSWORD included), set MFA_ENCRYPTION_KEY to a key of your
134
+ # own (the run does not use the development sample key), and run the Mailpit
135
+ # overlay, which sets the sender's variables (README: Mailpit for the SMTP sender):
131
136
  # docker compose -f docker-compose.yml -f docker-compose.mailpit.yml up --build
132
137
 
133
138
  # Development (override for local HTTP).
@@ -58,6 +58,8 @@ my-app:
58
58
 
59
59
  ロードバランサ配下で standalone サーバーを複数インスタンス動かすときは、`REDIS_CLIENTS_URL` を**共有**の Redis 7.2+ インスタンスに向けること。共有の Redis URL が無いと、各レプリカは refresh token family を自分の接続(と、ローカル専用の Redis)に保持するため、トークンを発行していないレプリカに届いた refresh リクエストは `invalid_grant` を返し、ラウンドロビンの LB では 1 回おきにそうなる。
60
60
 
61
+ **その Redis は `maxmemory-policy noeviction`(Redis のデフォルト)で動かすこと。** 期限まで残らなければならないキーを持つ Redis のストア — attempt counter、ユーザーセッションストアのライフサイクルストア、フェデレーショントークンストア、MFA のストア — は起動時にポリシーを読む(`INFO memory`、次に `CONFIG GET maxmemory-policy`)。ほかのポリシーでは起動を拒否し、サーバーがポリシーを答えないときも拒否する。接続のユーザーが `INFO` や `CONFIG` を実行できない(ACL で制限されたユーザー、それらを改名するマネージドサービス)が、サーバーが `noeviction` で動いていると分かっているなら、`REDIS_CLIENTS_ASSUME_NO_EVICTION=true`(`redis-clients.assumeNoEviction`、デフォルト `false`)を設定すること。これはポリシーを読めないときにだけ読まれる: サーバーが答えたポリシーが常に優先するので、`allkeys-*` や `volatile-*` のサーバーを通すことはない。
62
+
61
63
  **ロードバランサ配下では `HTTP_TRUST_PROXY` をプロキシのアドレスに設定すること。** デフォルトは `false` で、この場合 `req.ip` はクライアントではなく*ロードバランサの*アドレスになる。すると IP をキーとするすべての rate limit — OAuth エンドポイントの limiter と `POST /session/login` の総当たり対策ガード — が**全ユーザーで 1 つのバケットを共有する**: 誰からであれ最初の 20 回のログイン試行でウィンドウを使い切り、以降のユーザーは全員 `429` を受け取る。この障害は設定ミスではなく攻撃のように見えるため、深夜 3 時の診断が高くつく。このサービスの前段で何かが TLS を終端したりプロキシしたりしているなら必ず設定し、`X-Forwarded-For` を設定しているのがそのホップであることを確認すること。`ip` のない監査イベントは、リクエストのアドレスがアドレスでなかった — `req.ip` がアドレス以外を入れた `X-Forwarded-For` から来た — ことを意味するので、`HTTP_TRUST_PROXY` がそのヘッダーを設定するホップだけを信頼しているか確認すること。
62
64
 
63
65
  取りうる形は 4 つで、いずれも Express 自身のものである:
@@ -77,7 +79,7 @@ allowlist はネットワーク上の制御であって、暗号学的な制御
77
79
 
78
80
  **MFA ルートの予算はリミッターのものである。** MFA パッケージは自身のルートの予算を持たない: `/session/mfa` への POST はすべて、配線されたリミッターだけによって、クライアントアドレスごとにプレフィックス `mfa` の下で制限される。`config/reference.conf` は両方のリミッターに `limits.mfa { limit = 60, windowSeconds = 300 }`(`core-rate-limiter-memory.limits.mfa`、`redis-rate-limiter.limits.mfa`)を与えるので、`adapters.rateLimiter` を切り替えてもこの予算は保たれる。そこでは既定値なので、構成が配線しないリミッターがこれを持っていても、boot はそのセクションの名前を出さない。変えるなら、配線したリミッターについて `config/application.conf` か自分のレイヤーで変えること。エントリがなければリミッターの `defaultLimit`(60 秒あたり 60)が適用される。MFA のロック — トランザクションごとの試行回数、バックオフ、週ごとの失敗回数 — は MFA パッケージ自身のもので、リミッターの設定はこれを変えない。
79
81
 
80
- **`adapters.attemptCounter` を Redis に向けること。** デフォルトは `"memory"` でカウンターを配線しない: ログイン(device grant を加えたときはデバイス検証も)は試行をプロセスごとに数えるので、N レプリカでは `session.rateLimit.login`(と `device-grant.rateLimit`)が上限の N 倍を許し、デプロイのたびにリセットされる。`CORE_DEPLOYMENT_MODE=multi` は起動を拒否する。`"redis"`(`ADAPTERS_ATTEMPT_COUNTER=redis`)は共有の Redis ソケットの上に `redisAttemptCounterModule` を入れる。その Redis は `maxmemory-policy noeviction`(Redis のデフォルト)で動かすこと: 追い出された窓は数え直しになるからである。モジュールは読み取れたほかのポリシーでは起動を拒否するが、サーバーが答えない(`INFO` と `CONFIG` が拒否・改名されている)ときは `attempt_counter_durability_unchecked` を警告して起動するので、ポリシーはサーバーの設定側で確かめること。どちらも閉じる側に倒れる: カウンターが答えられない間、`/session/login` と `POST /oauth/device/verification` は `redis-rate-limiter.failMode` が何であっても `503` を返す。
82
+ **`adapters.attemptCounter` を Redis に向けること。** デフォルトは `"memory"` でカウンターを配線しない: ログイン(device grant を加えたときはデバイス検証も)は試行をプロセスごとに数えるので、N レプリカでは `session.rateLimit.login`(と `device-grant.rateLimit`)が上限の N 倍を許し、デプロイのたびにリセットされる。`CORE_DEPLOYMENT_MODE=multi` は起動を拒否する。`"redis"`(`ADAPTERS_ATTEMPT_COUNTER=redis`)は共有の Redis ソケットの上に `redisAttemptCounterModule` を入れる。その Redis は `maxmemory-policy noeviction`(Redis のデフォルト)で動かすこと: 追い出された窓は数え直しになるからである。モジュールはほかのポリシーでは起動を拒否する。サーバーがポリシーを答えないときも、`REDIS_CLIENTS_ASSUME_NO_EVICTION=true`(上記)でない限り拒否する。どちらも閉じる側に倒れる: カウンターが答えられない間、`/session/login` と `POST /oauth/device/verification` は `redis-rate-limiter.failMode` が何であっても `503` を返す。
81
83
 
82
84
  **BFF の背後では、必要になる前に `limits.token` を上げておくこと。** OAuth エンドポイントの rate limit は `req.ip` をキーにする(`packages/core/src/ratelimit/guard.mts` はバケットキーを `<endpoint>:ip:<req.ip>` として組み立てる)。クライアントがブラウザやネイティブアプリで、このプロバイダーと直接通信しているなら、これは正しい identity である。しかし backend-for-frontend 構成 — サーバー側アプリがセッションを保持し、ユーザーに代わってコード交換と refresh を行う構成 — では誤った identity になる: すべての `/oauth/token` と `/oauth/introspect` の呼び出しが BFF の単一アドレスから届き、デプロイ全体で 1 つのバケットを共有する。デフォルトの 60 秒あたり 60 リクエストでは、**全ユーザー合計で毎分およそ 60 回の session グラント交換**が上限になる — しかもそれは rate limit として表に出てこない。BFF は想定していない `429` を受け取り、それを自分の呼び出し元への `502` に変え、ユーザーが報告する症状は「サインインがときどき壊れる」になる。それは起き始めるトラフィック量に達した時点で現れ、それより前には現れない。
83
85
 
@@ -100,7 +102,7 @@ redis-rate-limiter {
100
102
 
101
103
  **2 つ以上のレプリカを動かすようになったら `CORE_DEPLOYMENT_MODE=multi` を設定すること。** すると、共有が必要な in-memory ストアがまだ配線されていれば起動が*失敗*し、該当するものすべてと、それぞれの代償が名指しされる — ユーザーセッションの分岐(back-channel logout が 1 つのレプリカにしか届かず、ログアウトしたセッションが他のレプリカでは有効なまま)、rate limit カウンターの倍増、アクセストークン失効の未伝播、一度きりのクライアントアサーションや WebAuthn チャレンジがレプリカごとに 1 回ずつ再利用できてしまうこと。このチェックはライブラリのモジュール名のリストではなく、インストールされた各モジュールが自身の manifest に持つ宣言を読むため、このテンプレート独自の in-memory モジュール — ユーザーセッションストア(`ADAPTERS_USER_SESSION_STORES=memory`)、認可コードリポジトリ(`ADAPTERS_CODE_REPOSITORY=memory`)、フェデレーショントークンストア(`ADAPTERS_FEDERATION_TOKEN_STORE=memory`、デフォルト) — も名指しで拒否される。`SESSION_STORE_STORAGE_TYPE=memory` のときの express-session 自身のストア(#474)と、デフォルトの memory の rate limiter(`core-rate-limiter-memory`、`ADAPTERS_RATE_LIMITER=memory`)も同様である。モードが未設定なら何も拒否されない: これらはすべて、起動時の 1 件の `replica_unsafe_adapters` 警告に列挙される。(WebAuthn-options のルートは個別に警告するプロセス単位のフォールバック limiter を持つが、それが働くのは `rateLimiter` をまったく配線しない構成だけで、このテンプレートは常に配線する。ログインは `ADAPTERS_ATTEMPT_COUNTER=memory` のときプロセスごとに数える: `multi` ではログインを名指しして起動が拒否され、モード未設定なら `attempt_counter_not_shared` を警告する。オペレーター runbook を参照。)`CORE_DEPLOYMENT_MODE=single` ではチェックは何も言わない。レプリカは 1 つだと宣言したからである。このテンプレートは DPoP をインストールしない。DPoP を加えた構成では、受け入れた DPoP proof はすべて `private_key_jwt` と同じ replay seen-set(`ADAPTERS_REPLAY_SEEN_SET`)に記録されるため、同じ扱いを受ける — `memory` は `CORE_DEPLOYMENT_MODE=multi` のもとで拒否され、モード未設定なら警告に列挙される。同梱の `redis` なら DPoP の記録もレプリカ間で共有される。dpop パッケージの [operator requirements](../../packages/dpop/README.md#operator-requirements) を参照。
102
104
 
103
- この変数は `core.deployment.mode` を設定する。旧名の `DEPLOYMENT_MODE` は、単独で、または `CORE_DEPLOYMENT_MODE` と異なる値で設定されているとブートを拒否し、同じ値で並べて設定されていればブートする。
105
+ この変数は `core.deployment.mode` を設定する。旧名の `DEPLOYMENT_MODE` は、設定されていれば、単独でも `CORE_DEPLOYMENT_MODE` と並べても、値が何であれブートを拒否する。外すこと。
104
106
 
105
107
  このチェックに*できない*ことも把握しておくこと: `CORE_DEPLOYMENT_MODE` を一度も設定しないまま N レプリカにスケールしても、何も失敗しない。すべての状態を自分のメモリに持つプロセスには、他のピアに気づくための共有媒体が無い — その状態は、まさにそれが真であるときに内側からは検出できない。この変数はスケールの一環として設定し、何かが壊れてからにしないこと。
106
108
 
@@ -236,7 +238,7 @@ overlay の値は `application.conf` より優先される。scaffold には `de
236
238
  | `ADAPTERS_USER_REPOSITORY` | `http` | `adapters.userRepository`: `http`、`yaml` または `static`(core の `yaml` の別名)。[ユーザーリポジトリ](#ユーザーリポジトリ) を参照 |
237
239
  | `ADAPTERS_AUDIT_SINK` | `logger` | `adapters.auditSink`: セキュリティイベントの送り先、`logger`(stdout への pino エンベロープ付き NDJSON)または `console`(イベントの JSON そのまま)。`none` は存在しない。[監査ログ](#監査ログ) を参照 |
238
240
 
239
- 移動する前の場所 — `rateLimiter.adapter`、`oauth.code.adapter`、`audit.sink.type`、`repositories.user.type` など — にまだ書かれた選択や、それとともに改名された変数(`RATE_LIMITER_ADAPTER`、`CLIENT_TYPE` など)が単独で、または別の値で設定されていると、新しいパスと変数を名指しして boot の前に失敗する。古い変数が新しい変数と同じ値で並んでいるなら受け入れる。
241
+ 移動する前の場所 — `rateLimiter.adapter`、`oauth.code.adapter`、`audit.sink.type`、`repositories.user.type` など — にまだ書かれた選択や、それとともに改名された変数(`RATE_LIMITER_ADAPTER`、`CLIENT_TYPE` など)が設定されていれば、単独でも新しい変数と並べても、値が何であれ、新しいパスと変数を名指しして boot の前に失敗する。古い変数は外すこと。
240
242
 
241
243
  ### HTTP
242
244
 
@@ -301,7 +303,7 @@ openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
301
303
 
302
304
  `expires_in` リクエストパラメータを読むのは token exchange(RFC 8693)だけで、他のグラントはそれを無視してデフォルトを発行する。
303
305
 
304
- デフォルトの旧名 `OAUTH_ACCESS_TOKEN_EXPIRES_IN`(config キーでは `oauth.accessToken.expiresIn`)は、単独で、または `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` と異なる値で設定されているとブートを拒否し、同じ値で並べて設定されていればブートする。旧キーは値が何であれブートを拒否する。値は消さずに新しい名前へ移すこと: 消すとデフォルトは `3600` になる。
306
+ デフォルトの旧名 `OAUTH_ACCESS_TOKEN_EXPIRES_IN`(config キーでは `oauth.accessToken.expiresIn`)は、設定されていれば、単独でも `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` と並べても、値が何であれブートを拒否する。旧キーは値が何であれブートを拒否する。値は消さずに新しい名前へ移すこと: 消すとデフォルトは `3600` になる。
305
307
 
306
308
  ### グラントタイプ
307
309
 
@@ -313,7 +315,7 @@ openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
313
315
  | `OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED` | `false` | client credentials グラントタイプを有効化 |
314
316
  | `OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED` | `false` | jwt-bearer グラントタイプ(RFC 7523)を有効化 |
315
317
 
316
- スイッチはそれぞれモジュールのキー — `oauth-session.enabled`、ほかは `oauth-authorization.grants.<grant>.enabled` — である。どちらのモジュールも常に読み込まれ、自分のスイッチを boot がパースする設定から boot で読む。テンプレートはそのどれも boot の前には読まない。旧名の `OAUTH_GRANTS_<GRANT>_ENABLED` は、単独で、または新名と違う値で設定されていると boot を拒否し、同じ値なら boot する。
318
+ スイッチはそれぞれモジュールのキー — `oauth-session.enabled`、ほかは `oauth-authorization.grants.<grant>.enabled` — である。どちらのモジュールも常に読み込まれ、自分のスイッチを boot がパースする設定から boot で読む。テンプレートはそのどれも boot の前には読まない。旧名の `OAUTH_GRANTS_<GRANT>_ENABLED` は、設定されていれば、単独でも新名と並べても、値が何であれ boot を拒否する。
317
319
 
318
320
  ### Session
319
321
 
@@ -330,7 +332,7 @@ openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
330
332
  | `SESSION_STORE_STORAGE_REDIS_URL` | `redis://localhost:6379` | セッションストア用 Redis 接続 URL |
331
333
  | `SESSION_STORE_STORAGE_REDIS_PASSWORD` | — | セッションストア用 Redis パスワード |
332
334
 
333
- cookie とそのストアはセッションストアのセクション `session-store` のもので、CSRF トークンの寿命、ログインページ、ログインの予算は session モジュールのセクション `session` のもの。`SESSION_STORE_*` の旧名 `SESSION_<KEY>` は、単独で、または新名と違う値で設定されていると boot を拒否し、同じ値なら boot する。
335
+ cookie とそのストアはセッションストアのセクション `session-store` のもので、CSRF トークンの寿命、ログインページ、ログインの予算は session モジュールのセクション `session` のもの。`SESSION_STORE_*` の旧名 `SESSION_<KEY>` は、設定されていれば、単独でも新名と並べても、値が何であれ boot を拒否する。
334
336
 
335
337
  ローカルの HTTP 開発のために `SESSION_STORE_SECURE=false` を設定する場合や、ドメインを共有する Cookie のために `SESSION_STORE_DOMAIN` を設定する場合は、`SESSION_STORE_NAME` も `auth.sid` のような `__Host-` でない値にすること — `SESSION_STORE_SECURE=false` なら接頭辞の無い値にする: `__Secure-` の名前も `SESSION_STORE_SECURE=true` を要する。`__Host-` や `__Secure-`(大文字小文字は問わない)の Cookie 名が、ブラウザがその prefix に対して拒否する属性と組み合わされると、`SESSION_STORE_NAME` が Cookie の名前(RFC 6265 のトークン: 空白、`;` などの区切り文字を含まない)でないと、また `SESSION_STORE_DOMAIN` がホスト名でない(スキーム、ポート、パスを含む)と、サーバーは fail-fast する。
336
338
 
@@ -379,7 +381,7 @@ http.cors {
379
381
 
380
382
  ### Google フェデレーション
381
383
 
382
- フェデレーションは core のものである: `core.federations`、各フェデレーションに到達する名前(`/session/oauth/federation/<name>`)をキーとする 1 つのマップ。各キーは、そのパスから名付けた変数 `CORE_FEDERATIONS_<NAME>_<KEY>` に束縛される。トップレベルに書いたマップ(`federations { ... }`)は、各キーの `core.federations` の下のパスを名指しして起動を拒否する。`FEDERATIONS_GOOGLE_*` や `FEDERATIONS_OIDC_*` の変数が単独で、または新しい名前と異なる値で設定されていると、どのモジュールを選ぶよりも前に拒否される。
384
+ フェデレーションは core のものである: `core.federations`、各フェデレーションに到達する名前(`/session/oauth/federation/<name>`)をキーとする 1 つのマップ。各キーは、そのパスから名付けた変数 `CORE_FEDERATIONS_<NAME>_<KEY>` に束縛される。トップレベルに書いたマップ(`federations { ... }`)は、各キーの `core.federations` の下のパスを名指しして起動を拒否する。`FEDERATIONS_GOOGLE_*` や `FEDERATIONS_OIDC_*` の変数が設定されていれば、単独でも新しい名前と並べても、値が何であれ、どのモジュールを選ぶよりも前に拒否される。
383
385
 
384
386
  すべてのエントリは自分の `type` を書き、boot は有効化された各エントリを、その type を扱うモジュールにエントリの名前で渡す。テンプレートは同梱する 2 つの type — `google`(`@o3co/auth-provider-federation-google`)と `oidc`(`@o3co/auth-provider-federation-oidc`)— を、マップの内容にかかわらず常に読み込み、エントリを自分では読まない: エントリが持つキーは core と type のスキーマが読み、不正なものはそのパス(`core.federations.<name>.<key>`)を名指しして拒否される。`type` の無いエントリは、有効かどうかにかかわらず `core.federations.<name>.type` で拒否される(`config-validation-failed`)。読み込んだどのモジュールも扱わない type の有効なエントリは、そのエントリを名指しして起動を拒否する(`federation-type-unhandled`)。GitHub と Apple は同梱しない: 使いたいデプロイは、そのパッケージとモジュールを `buildModules` に加える — GitHub は type モジュール `githubFederationTypeModule()` と `type = "github"` のエントリで、Apple はその [README](../../packages/federation-apple/README.md) に従う。
385
387
 
@@ -479,7 +481,7 @@ core.federations {
479
481
  | `REDIS_CODE_REPOSITORY_KEY_PREFIX` | `oauth:code:` | `redis-code-repository.keyPrefix`: Redis のリポジトリのキー名前空間([Redis の名前空間](#redis-の名前空間) を参照) |
480
482
  | `STANDALONE_IN_MEMORY_CODE_REPOSITORY_DEFAULT_EXPIRES_IN` | `600` | `standalone-in-memory-code-repository.defaultExpiresIn`: in-process のリポジトリのもの。正の整数(秒) |
481
483
 
482
- `CLIENT_CODE_DEFAULT_EXPIRES_IN` と `CLIENT_CODE_KEY_PREFIX` はこれらとともに改名され、`CLIENT_CODE_ENDPOINT_URI` と `CLIENT_CODE_PASSWORD` は削除された。改名されたものが単独で、または新しい名前と異なる値で設定されていると、そして削除されたものが設定されていると、代わりに設定すべきものを名指しして起動に失敗する。
484
+ `CLIENT_CODE_DEFAULT_EXPIRES_IN` と `CLIENT_CODE_KEY_PREFIX` はこれらとともに改名され、`CLIENT_CODE_ENDPOINT_URI` と `CLIENT_CODE_PASSWORD` は削除された。改名されたものが、単独でも新しい名前と並べても、値が何であれ設定されていると、そして削除されたものが設定されていると、代わりに設定すべきものを名指しして起動に失敗する。
483
485
 
484
486
  ### フェデレーショントークンストア
485
487
 
@@ -510,7 +512,7 @@ core.federations {
510
512
  | 変数 | デフォルト | 説明 |
511
513
  |---|---|---|
512
514
  | `MFA_MODE` | `required` | `mfaMode`: `required`、`optional` または `off` |
513
- | `MFA_ENCRYPTION_KEY` | — | MFA の鍵リングの最初の鍵で、すべての要素のデータを封じる: 32 バイトの canonical な base64(`openssl rand -base64 32`)。MFA が有効なら必須。`CONFIG_ENV=development` では `config/development.conf` が代わりに MFA パッケージの公開サンプル鍵を置く: 自分の鍵はそこに書く。そのリングの横でこの変数を設定すると boot は拒否される。サンプル鍵は `CONFIG_ENV` か `NODE_ENV` が `production` か `staging` のとき、また `CORE_DEPLOYMENT_MODE=multi` のもとでは拒否される |
515
+ | `MFA_ENCRYPTION_KEY` | — | MFA の鍵リングの最初の鍵で、すべての要素のデータを封じる: 32 バイトの canonical な base64(`openssl rand -base64 32`)。MFA が有効なら必須。`CONFIG_ENV=development` では `config/development.conf` が代わりに MFA パッケージの公開サンプル鍵を置く: 自分の鍵はそこに書く。そのリングの横でこの変数を設定すると boot は拒否される。サンプル鍵が受け入れられるのは、`CONFIG_ENV` と `NODE_ENV` のうち設定されているものがすべて `development` か `test` を示すときだけで、`CORE_DEPLOYMENT_MODE=multi` のもとでは拒否される |
514
516
  | `MFA_PAGE_URL` | `/mfa` | デプロイの MFA ページ。ログインの第二要素とステップアップはここから始まる。テンプレートはページを同梱しない: ページの契約は [MFA パッケージのもの](../../packages/mfa/README.md#the-routes) |
515
517
  | `MFA_STORE_TIMEOUT_MS` | `5000` | `mfa.storeTimeoutMs`、Store の呼び出し 1 回の時間: 各ストアの呼び出しごとのタイムアウト以上でなければならない。Store を呼ぶ構成(`ADAPTERS_USER_REPOSITORY=http`、または要素を Store に置く)で `REPOSITORIES_USER_HTTP_TIMEOUT` を下回ると boot を拒否するので、二つは一緒に上げる。37500 ms を超えても拒否する |
516
518
  | `STANDARD_SMTP_MAIL_SENDER_HOST` | — | development 以外での SMTP リレー。MFA はここにアカウントのメールの証明とメール要素のコードを送る。MFA が有効なら、boot にはこれと `STANDARD_SMTP_MAIL_SENDER_FROM` が要る |
@@ -718,11 +720,13 @@ docker run -e HTTP_PORT=8080 -p 8080:8080 my-auth-provider
718
720
  docker compose -f docker-compose.yml -f docker-compose.mailpit.yml up --build
719
721
  ```
720
722
 
723
+ 最初に実行する前に、自分の鍵を `.env` に `MFA_ENCRYPTION_KEY` として書く(`openssl rand -base64 32`)。この実行は development のサンプル鍵を使わず、鍵が無ければ boot は拒否される。
724
+
721
725
  メールは <http://localhost:8025> で読む。Mailpit はメールを保持し、先へは何も配送しない。素の `docker compose up`(`make dev`)は Mailpit を起動せず、本番用のファイルにも Mailpit は無い。開発専用である: デプロイでは `STANDARD_SMTP_MAIL_SENDER_*` の変数で実際のリレーを指定する([多要素認証](#多要素認証))。
722
726
 
723
727
  オーバーレイが app サービスに設定するもの:
724
728
 
725
- - `CONFIG_ENV=mailpit`。`development` ではテンプレートは各コードをログに出す送信者を入れる([モジュール合成順序](#モジュール合成順序) のルール 7)。[`config/mailpit.conf`](config/mailpit.conf) は `development.conf` を include するので、それ以外は MFA のサンプル鍵も含めて development の実行と同じである。
729
+ - `CONFIG_ENV=mailpit`。`development` ではテンプレートは各コードをログに出す送信者を入れる([モジュール合成順序](#モジュール合成順序) のルール 7)。[`config/mailpit.conf`](config/mailpit.conf) は `development.conf` を include しない。`development.conf` の唯一の設定は MFA のサンプル鍵で、MFA パッケージがそれを受け入れるのは、設定されている環境名がすべて `development` か `test` を示すときだけだからである。鍵リングの最初の鍵は、MFA パッケージの `reference.conf` が束ねるとおり `MFA_ENCRYPTION_KEY` である。
726
730
  - `MFA_MODE` は設定しない: MFA はテンプレートの出荷どおり、または `.env` が設定するとおりに有効である。MFA が off ならメールを送るものが無い。
727
731
  - `ADAPTERS_MFA_FACTOR_STORE=redis` と `ADAPTERS_MFA_TRANSACTION_STORE=redis`(compose の Redis 上、`docker-compose.yml` と同じ): `development` と `test` 以外の名前では、MFA のストアをメモリに置けない。
728
732
  - `STANDARD_SMTP_MAIL_SENDER_HOST=localhost`、`STANDARD_SMTP_MAIL_SENDER_PORT=1025`、`STANDARD_SMTP_MAIL_SENDER_SECURE=none`、`STANDARD_SMTP_MAIL_SENDER_FROM=auth@example.com`。SMTP 送信者が平文で送るのはループバックのホストにだけなので、Mailpit は app コンテナのネットワーク名前空間で動き(`network_mode: service:app`)、その `localhost` で待ち受ける。そのため Mailpit の Web UI のポートは、Mailpit ではなく app サービスがループバックに公開する。
@@ -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:
@@ -131,7 +133,7 @@ An allowlist is a network control, not a cryptographic one. The edge must also *
131
133
 
132
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.
133
135
 
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.
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.
135
137
 
136
138
  **Behind a BFF, raise `limits.token` before you need to.** The OAuth-endpoint
137
139
  rate limits key on `req.ip` (`packages/core/src/ratelimit/guard.mts` builds the
@@ -179,7 +181,7 @@ where per-user identity still exists.
179
181
 
180
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).
181
183
 
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.
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.
183
185
 
184
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.
185
187
 
@@ -410,9 +412,8 @@ fails before boot, naming it.
410
412
  A selection still written where it was — `rateLimiter.adapter`,
411
413
  `oauth.code.adapter`, `audit.sink.type`, `repositories.user.type` and the
412
414
  like — or a variable renamed with it (`RATE_LIMITER_ADAPTER`, `CLIENT_TYPE`,
413
- …) set alone or to a different value, fails before boot, naming the new path
414
- and variable; the old variable beside the new one at the same value is
415
- accepted.
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.
416
417
 
417
418
  ### HTTP
418
419
 
@@ -491,9 +492,8 @@ Only token exchange (RFC 8693) reads an `expires_in` request parameter; every
491
492
  other grant ignores it and mints the default.
492
493
 
493
494
  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
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
497
  the new name rather than deleting it: without it, the default is `3600`.
498
498
 
499
499
  ### Grant Types
@@ -510,8 +510,8 @@ Each switch is its module's key — `oauth-session.enabled`, and
510
510
  `oauth-authorization.grants.<grant>.enabled` for the others. Both modules are
511
511
  always loaded and read their switches at boot, from the configuration boot
512
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.
513
+ the old names, refuse boot while they are set, alone or beside the new name at
514
+ any value.
515
515
 
516
516
  ### Session
517
517
 
@@ -531,8 +531,8 @@ value; beside it at the same value, they boot.
531
531
  The cookie and its store are the session store's section, `session-store`; the
532
532
  CSRF token's lifetime, the login page and the login's budget are the session
533
533
  module's, `session`. `SESSION_<KEY>`, the old names of the `SESSION_STORE_*`
534
- variables, refuse boot set alone or beside the new name at a different value;
535
- beside it at the same value, they boot.
534
+ variables, refuse boot while they are set, alone or beside the new name at any
535
+ value.
536
536
 
537
537
  If you set `SESSION_STORE_SECURE=false` for local HTTP development or set `SESSION_STORE_DOMAIN`
538
538
  for shared-domain cookies, also set `SESSION_STORE_NAME` to a non-`__Host-` value such as
@@ -637,8 +637,8 @@ each is reached at (`/session/oauth/federation/<name>`). A key of one is bound
637
637
  to the variable named after its path, `CORE_FEDERATIONS_<NAME>_<KEY>`. The map
638
638
  written at the top level (`federations { ... }`) refuses boot, naming each
639
639
  key's path under `core.federations`; a `FEDERATIONS_GOOGLE_*` or
640
- `FEDERATIONS_OIDC_*` variable set alone, or beside its new name at a different
641
- value, is refused before any module is chosen.
640
+ `FEDERATIONS_OIDC_*` variable set, alone or beside its new name at any value,
641
+ is refused before any module is chosen.
642
642
 
643
643
  Every entry names its `type`, and boot hands each enabled entry to the module
644
644
  that handles that type, under the entry's name. The template always loads the
@@ -806,8 +806,8 @@ its own.
806
806
 
807
807
  `CLIENT_CODE_DEFAULT_EXPIRES_IN` and `CLIENT_CODE_KEY_PREFIX` were renamed
808
808
  with them, and `CLIENT_CODE_ENDPOINT_URI` and `CLIENT_CODE_PASSWORD` removed. A
809
- renamed one set alone, or beside its new name at a different value, and a
810
- removed one set at all, fails boot naming what to set instead.
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.
811
811
 
812
812
  ### Federation Token Store
813
813
 
@@ -874,7 +874,7 @@ in development too, or every password login stops there.
874
874
  | Variable | Default | Description |
875
875
  |---|---|---|
876
876
  | `MFA_MODE` | `required` | `mfaMode`: `required`, `optional` or `off` |
877
- | `MFA_ENCRYPTION_KEY` | — | The MFA key ring's first key, which seals every factor's data: canonical base64 of 32 bytes (`openssl rand -base64 32`). Required with MFA on. Under `CONFIG_ENV=development`, `config/development.conf` puts the MFA package's published sample key in its place: write your own key there instead, since this variable set beside that ring refuses the boot. The sample key is refused under `CONFIG_ENV` or `NODE_ENV` `production` or `staging` and under `CORE_DEPLOYMENT_MODE=multi` |
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` |
878
878
  | `MFA_PAGE_URL` | `/mfa` | The deployment's MFA page, where a login's second factor and a step-up start. The template ships no page: the page contract is the [MFA package's](../../packages/mfa/README.md#the-routes) |
879
879
  | `MFA_STORE_TIMEOUT_MS` | `5000` | `mfa.storeTimeoutMs`, one Store call's time: at least every store's own per-call timeout. Below `REPOSITORIES_USER_HTTP_TIMEOUT` where the Store is called (`ADAPTERS_USER_REPOSITORY=http`, or the factors in the Store) the boot is refused, so raise the two together; above 37500 ms it is refused too |
880
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` |
@@ -1214,6 +1214,10 @@ development sender, which only logs each code:
1214
1214
  docker compose -f docker-compose.yml -f docker-compose.mailpit.yml up --build
1215
1215
  ```
1216
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
+
1217
1221
  Read the mail at <http://localhost:8025>. Mailpit keeps it and delivers
1218
1222
  nothing onward. A plain `docker compose up` (`make dev`) starts no Mailpit, and
1219
1223
  the production file names none. It is for development only: a deployment
@@ -1224,9 +1228,11 @@ What the overlay sets on the app service:
1224
1228
 
1225
1229
  - `CONFIG_ENV=mailpit`. Under `development` the template installs the sender
1226
1230
  that logs each code ([Module Composition Order](#module-composition-order),
1227
- rule 7). [`config/mailpit.conf`](config/mailpit.conf) includes
1228
- `development.conf`, so the run is otherwise the development one, the MFA
1229
- sample key included.
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.
1230
1236
  - Nothing of `MFA_MODE`: MFA is on as the template ships it, or as your
1231
1237
  `.env` sets it. With MFA off nothing sends mail.
1232
1238
  - `ADAPTERS_MFA_FACTOR_STORE=redis` and `ADAPTERS_MFA_TRANSACTION_STORE=redis`,
@@ -347,4 +347,5 @@ http {
347
347
  redis-clients {
348
348
  url = ${?REDIS_CLIENTS_URL}
349
349
  password = ${?REDIS_CLIENTS_PASSWORD}
350
+ assumeNoEviction = ${?REDIS_CLIENTS_ASSUME_NO_EVICTION}
350
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 refused where the configuration is selected as
14
- # production or staging, where NODE_ENV is either, and under
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 development overlay under another name: the template installs the
5
- # sender that logs each code only where the configuration is selected as
6
- # "development", and the SMTP sender under any other name. For development
7
- # only; a deployment names its own relay.
8
- include "development.conf"
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.
@@ -130,11 +130,17 @@ key-store {
130
130
 
131
131
  # `redis-clients`: the one ioredis socket per replica every Redis-backed store
132
132
  # shares. More than one replica needs a shared Redis 7.2+ here; localhost
133
- # inside a container is the container itself.
133
+ # inside a container is the container itself. The Redis stores that keep
134
+ # durable keys refuse to boot unless the server reports maxmemory-policy
135
+ # `noeviction`. `assumeNoEviction = true` asserts it for a server that will not
136
+ # report it (INFO and CONFIG refused to the connection's user); a policy the
137
+ # server does report always decides.
134
138
  redis-clients {
135
139
  url = "redis://localhost:6379"
136
140
  url = ${?REDIS_CLIENTS_URL}
137
141
  password = ${?REDIS_CLIENTS_PASSWORD}
142
+ assumeNoEviction = false
143
+ assumeNoEviction = ${?REDIS_CLIENTS_ASSUME_NO_EVICTION}
138
144
  }
139
145
 
140
146
  # `repositories`: the client registry, and the user repository's settings for
@@ -319,8 +325,8 @@ redis-rate-limiter.limits.mfa { limit = 60, windowSeconds = 300 }
319
325
  # (`rateLimiter.adapter`, `oauth.code.adapter`, ...) are refused before the
320
326
  # modules are chosen, and so are the variables renamed with them. What the
321
327
  # resolution saw of each variable they renamed: each name null, then its value
322
- # when set. Boot refuses an old name set unless its new name is set to the
323
- # same value, and removes this section before it parses the configuration.
328
+ # when set. Boot refuses an old name set, whether or not its new name is set,
329
+ # and removes this section before it parses the configuration.
324
330
  # Never write it in another layer.
325
331
  renamed-variables {
326
332
  LOG_LEVEL = null
@@ -4,6 +4,9 @@
4
4
  #
5
5
  # docker compose -f docker-compose.yml -f docker-compose.mailpit.yml up --build
6
6
  #
7
+ # Write MFA_ENCRYPTION_KEY into .env first (openssl rand -base64 32): this run
8
+ # reads its MFA key from it, not the development sample key.
9
+ #
7
10
  # Mailpit keeps every mail the provider sends and delivers none; read them at
8
11
  # http://localhost:8025. Never use it in production, where
9
12
  # STANDARD_SMTP_MAIL_SENDER_* name a real relay. Leave every
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth-provider-standalone",
3
- "version": "0.17.0-rc.2",
3
+ "version": "0.17.0-rc.3",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -211,7 +211,9 @@ describe("a path a selection moved from, written in the operator's own layer", (
211
211
  "adapters.%s: refused where it was, %s's old path, naming the new path and ADAPTERS_%s",
212
212
  (key, name, oldPath, _oldName, value) => {
213
213
  const err = refusal({}, `${oldPath} = "${value}"\n`);
214
- expect(err.message).toContain(`${oldPath} has moved to adapters.${key}`);
214
+ expect(err.message).toContain(
215
+ `${oldPath} has moved to adapters.${key}; see the upgrade guide (docs/upgrading-from-v0.16.0.md).`,
216
+ );
215
217
  expect(err.message).toContain(`ADAPTERS_${name}`);
216
218
  expect(err.reason).toBe("config-path-relocated");
217
219
  expect(err.details).toEqual({
@@ -251,8 +253,9 @@ describe("a variable renamed with a selection", () => {
251
253
  "adapters.%s: %s's old variable set alone is refused, naming ADAPTERS_%s",
252
254
  (key, name, _oldPath, oldName, value) => {
253
255
  const err = refusal({ [oldName]: value });
254
- expect(err.message).toContain(`${oldName} was renamed ADAPTERS_${name}`);
255
- expect(err.message).toContain(`adapters.${key}`);
256
+ expect(err.message).toContain(
257
+ `${oldName} was renamed ADAPTERS_${name}, the variable adapters.${key} is bound to; see the upgrade guide (docs/upgrading-from-v0.16.0.md).`,
258
+ );
256
259
  expect(err.reason).toBe("environment-variable-renamed");
257
260
  expect(err.details).toEqual({
258
261
  reason: "environment-variable-renamed",
@@ -282,9 +285,12 @@ describe("a variable renamed with a selection", () => {
282
285
  );
283
286
 
284
287
  it.each(SELECTIONS)(
285
- "adapters.%s: the old variable beside ADAPTERS_%s at the same value is read",
286
- (key, name, _oldPath, oldName, value) => {
287
- expect(adaptersFrom({ [oldName]: value, [`ADAPTERS_${name}`]: value })[key]).toBe(value);
288
+ "adapters.%s: the old variable beside ADAPTERS_%s at the same value is refused all the same",
289
+ (_key, name, _oldPath, oldName, value) => {
290
+ const err = refusal({ [oldName]: value, [`ADAPTERS_${name}`]: value });
291
+ expect(err.reason).toBe("environment-variable-renamed");
292
+ expect(err.message).toContain(`unset ${oldName}`);
293
+ expect(err.details).toMatchObject({ renamed: [{ from: oldName, state: "different" }] });
288
294
  },
289
295
  );
290
296
  });
@@ -78,6 +78,10 @@ vi.mock("ioredis", () => {
78
78
  quit: async () => "OK",
79
79
  disconnect: () => undefined,
80
80
  ping: async () => "PONG",
81
+ // The Redis stores' eviction gate reads the policy from INFO memory: a
82
+ // default server's.
83
+ info: async (section: string) =>
84
+ section === "memory" ? "# Memory\r\nmaxmemory_policy:noeviction\r\n" : null,
81
85
  };
82
86
  const makeMockRedis = (): object =>
83
87
  new Proxy(
@@ -182,12 +186,16 @@ describe('every module on, every shared store on Redis, core.deployment.mode = "
182
186
  expect(current.handle.components.deploymentMode).toBe("multi");
183
187
  });
184
188
 
185
- it("boots as multi on DEPLOYMENT_MODE=multi beside CORE_DEPLOYMENT_MODE=multi, as the umbrella sets them", async () => {
186
- current = await compose({
187
- ...MULTI,
188
- env: { ...MULTI_ENV, DEPLOYMENT_MODE: "multi", CORE_DEPLOYMENT_MODE: "multi" },
189
+ it("refuses DEPLOYMENT_MODE=multi beside CORE_DEPLOYMENT_MODE=multi: the old name must be unset", async () => {
190
+ await expect(
191
+ compose({
192
+ ...MULTI,
193
+ env: { ...MULTI_ENV, DEPLOYMENT_MODE: "multi", CORE_DEPLOYMENT_MODE: "multi" },
194
+ }),
195
+ ).rejects.toMatchObject({
196
+ reason: "environment-variable-renamed",
197
+ details: { renamed: [{ from: "DEPLOYMENT_MODE", state: "different" }] },
189
198
  });
190
- expect(current.handle.components.deploymentMode).toBe("multi");
191
199
  });
192
200
 
193
201
  /**
@@ -149,6 +149,9 @@ describe("configDefaultsFor", () => {
149
149
 
150
150
  it("takes nothing from the process's environment", () => {
151
151
  vi.stubEnv("REDIS_CLIENTS_URL", "redis://operator.test:6379");
152
- expect(configDefaultsFor(modules)[NOT_LOADED]).toEqual({ url: "redis://localhost:6379" });
152
+ expect(configDefaultsFor(modules)[NOT_LOADED]).toEqual({
153
+ url: "redis://localhost:6379",
154
+ assumeNoEviction: false,
155
+ });
153
156
  });
154
157
  });
@@ -0,0 +1,48 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * The MFA switch, `mfaMode`, as this project's own configuration files write
19
+ * it with `MFA_MODE` unset: `{environment}.conf` over `application.conf` over
20
+ * the template's `config/reference.conf`. The template ships it `required`;
21
+ * a project `create-auth-provider --no-mfa` scaffolds writes it `off` at the
22
+ * end of its `application.conf`. A suite whose subject depends on the switch
23
+ * asserts what MFA on (`required`, as the template ships it) or off (as
24
+ * `--no-mfa` writes it) implies, so the suite that ships with a scaffold holds
25
+ * in either project. A project whose files write another mode, such as
26
+ * `optional`, is not what these suites claim to cover.
27
+ *
28
+ * Read with the HOCON parser alone, not through phase one (`readSwitches`),
29
+ * so that a suite about phase one can state what phase one should answer.
30
+ */
31
+
32
+ import { join } from "node:path";
33
+ import { fileURLToPath } from "node:url";
34
+ import { type Config, empty, parseFile } from "@o3co/ts.hocon";
35
+ import { type MfaSwitch, mfaSwitchSchema } from "#/sections.mjs";
36
+
37
+ const configDir = fileURLToPath(new URL("../../config", import.meta.url));
38
+
39
+ /** `mfaMode` as the files selected by `environment` write it, with no variable set. */
40
+ export function configuredMfaMode(environment: string): MfaSwitch {
41
+ const resolved = [`${environment}.conf`, "application.conf", "reference.conf"]
42
+ .reduce<Config>(
43
+ (layered, file) => layered.withFallback(parseFile(join(configDir, file), { env: {} })),
44
+ empty(),
45
+ )
46
+ .toObject() as Record<string, unknown>;
47
+ return mfaSwitchSchema.parse(resolved.mfaMode);
48
+ }