@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.
- package/README.ja.md +3 -1
- package/README.md +5 -2
- package/package.json +1 -1
- package/templates/standalone/.env.example +7 -2
- package/templates/standalone/README.ja.md +14 -10
- package/templates/standalone/README.md +26 -20
- package/templates/standalone/config/application.conf +1 -0
- package/templates/standalone/config/clients.yaml.example +8 -0
- package/templates/standalone/config/development.conf +2 -2
- package/templates/standalone/config/mailpit.conf +10 -5
- package/templates/standalone/config/reference.conf +9 -3
- package/templates/standalone/docker-compose.mailpit.yml +3 -0
- package/templates/standalone/package.json +1 -1
- package/templates/standalone/src/__tests__/adapters.test.mts +12 -6
- package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +13 -5
- package/templates/standalone/src/__tests__/config-notices.test.mts +4 -1
- package/templates/standalone/src/__tests__/configured-mfa-mode.fixture.mts +48 -0
- package/templates/standalone/src/__tests__/core-federations.test.mts +9 -6
- package/templates/standalone/src/__tests__/core-sections.test.mts +7 -5
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +89 -86
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +4 -0
- package/templates/standalone/src/__tests__/mailpit-compose.test.mts +81 -14
- package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +8 -4
- package/templates/standalone/src/__tests__/mfa-switch.test.mts +79 -21
- package/templates/standalone/src/__tests__/own-modules.test.mts +38 -12
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +214 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +4 -0
- package/templates/standalone/src/__tests__/scaffold-assets.test.mts +10 -3
- package/templates/standalone/src/__tests__/sections.test.mts +24 -0
- package/templates/standalone/src/__tests__/shipped-config-notices.test.mts +4 -0
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +148 -0
- package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +8 -4
- package/templates/standalone/src/__tests__/two-phase-config.test.mts +2 -1
- package/templates/standalone/src/adapters.mts +2 -2
- package/templates/standalone/src/mfaSwitch.mts +6 -7
- package/templates/standalone/src/modules.mts +12 -6
- package/templates/standalone/src/rootRenames.mts +12 -13
- package/templates/standalone/src/sections.mts +12 -2
- 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-
|
|
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-
|
|
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
|
@@ -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)
|
|
130
|
-
#
|
|
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 のデフォルト)で動かすこと:
|
|
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`
|
|
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`
|
|
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
|
|
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`
|
|
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>`
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
414
|
-
|
|
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
|
|
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
|
|
514
|
-
|
|
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
|
|
535
|
-
|
|
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
|
|
641
|
-
|
|
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
|
|
810
|
-
|
|
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
|
|
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)
|
|
1228
|
-
`development.conf`,
|
|
1229
|
-
|
|
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`,
|
|
@@ -50,6 +50,14 @@
|
|
|
50
50
|
# verifier itself as the challenge and so proves nothing against anyone who
|
|
51
51
|
# can read the authorization request. Set it only for a legacy client that
|
|
52
52
|
# genuinely cannot compute SHA-256, and treat it as a migration deadline.
|
|
53
|
+
#
|
|
54
|
+
# `allowExchangeOfTokensIssuedToOthers`: read only where the token-exchange
|
|
55
|
+
# module (@o3co/auth-provider-oauth-token-exchange) is installed, which this
|
|
56
|
+
# template does not do by default. Token exchange requires the caller to be
|
|
57
|
+
# an audience of the subject token by default: the token's `azp` is the
|
|
58
|
+
# client, or its `aud` contains it. `true` lets this one client exchange
|
|
59
|
+
# tokens issued to other clients (a gateway, an on-behalf-of service). Only
|
|
60
|
+
# a strict `true` counts.
|
|
53
61
|
|
|
54
62
|
# A machine client that authenticates with a key pair (#484). Nothing here is
|
|
55
63
|
# secret: the private half stays with the client, which signs an assertion
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
# The MFA key ring's development key: the MFA package's published sample key
|
|
11
11
|
# (MFA_DEVELOPMENT_SAMPLE_KEY), which everyone holds. It stands in for
|
|
12
12
|
# MFA_ENCRYPTION_KEY here: to use a key of your own in development, write it
|
|
13
|
-
# in place of this one. It is
|
|
14
|
-
#
|
|
13
|
+
# in place of this one. It is accepted only where the configuration's name and
|
|
14
|
+
# NODE_ENV, each where set, say development or test, and is refused under
|
|
15
15
|
# `core.deployment.mode = "multi"`; boot warns while it is in use
|
|
16
16
|
# (mfa_development_sample_key_in_use). Read only while MFA_MODE installs MFA.
|
|
17
17
|
mfa.encryptionKeys = [{ key = "bzNjbzptZmE6ZGV2ZWxvcG1lbnQtc2FtcGxlLWtleSE=" }]
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
# Environment overlay for a development run whose mail goes to Mailpit
|
|
2
2
|
# (docker-compose.mailpit.yml). Loaded when CONFIG_ENV is "mailpit".
|
|
3
3
|
#
|
|
4
|
-
# The
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
include
|
|
4
|
+
# The template installs the sender that logs each code only where the
|
|
5
|
+
# configuration is selected as "development", and the SMTP sender under any
|
|
6
|
+
# other name. For development only; a deployment names its own relay.
|
|
7
|
+
#
|
|
8
|
+
# It does not include development.conf, whose one setting is the MFA
|
|
9
|
+
# development sample key: the MFA package accepts that key only where every
|
|
10
|
+
# environment name set says development or test, and this run is selected as
|
|
11
|
+
# "mailpit". The key ring's first key is MFA_ENCRYPTION_KEY, as the MFA
|
|
12
|
+
# package's reference.conf binds it: write a key of your own
|
|
13
|
+
# (openssl rand -base64 32) into .env; without one the boot is refused.
|
|
@@ -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
|
|
323
|
-
#
|
|
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
|
|
@@ -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(
|
|
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(
|
|
255
|
-
|
|
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
|
|
286
|
-
(
|
|
287
|
-
|
|
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("
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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({
|
|
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
|
+
}
|