@o3co/create-auth-provider 0.15.0 → 0.17.0-rc.1

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 (92) hide show
  1. package/README.ja.md +139 -48
  2. package/README.md +138 -47
  3. package/dist/index.d.mts +33 -10
  4. package/dist/index.d.mts.map +1 -1
  5. package/dist/index.mjs +118 -59
  6. package/dist/internal/template-filter.d.mts +7 -10
  7. package/dist/internal/template-filter.d.mts.map +1 -1
  8. package/dist/internal/template-filter.mjs +7 -10
  9. package/package.json +6 -4
  10. package/templates/standalone/.dockerignore +15 -2
  11. package/templates/standalone/.env.example +69 -24
  12. package/templates/standalone/Dockerfile +7 -3
  13. package/templates/standalone/README.ja.md +677 -184
  14. package/templates/standalone/README.md +699 -238
  15. package/templates/standalone/config/application.conf +203 -325
  16. package/templates/standalone/config/clients.yaml +2 -0
  17. package/templates/standalone/config/clients.yaml.example +4 -0
  18. package/templates/standalone/config/development.conf +13 -4
  19. package/templates/standalone/config/mailpit.conf +8 -0
  20. package/templates/standalone/config/production.conf +5 -6
  21. package/templates/standalone/config/reference.conf +399 -0
  22. package/templates/standalone/docker-compose.mailpit.yml +36 -0
  23. package/templates/standalone/docker-compose.production.yml +58 -30
  24. package/templates/standalone/docker-compose.yml +13 -3
  25. package/templates/standalone/gitignore +4 -2
  26. package/templates/standalone/package.json +9 -6
  27. package/templates/standalone/src/__tests__/access-token-lifetime-alias.test.mts +65 -32
  28. package/templates/standalone/src/__tests__/adapters.test.mts +358 -0
  29. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +1178 -0
  30. package/templates/standalone/src/__tests__/all-modules-composition.multi.test.mts +256 -0
  31. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +1119 -0
  32. package/templates/standalone/src/__tests__/audit-sink.test.mts +78 -76
  33. package/templates/standalone/src/__tests__/configPath.test.mts +13 -11
  34. package/templates/standalone/src/__tests__/core-federations.test.mts +226 -0
  35. package/templates/standalone/src/__tests__/core-sections.test.mts +190 -0
  36. package/templates/standalone/src/__tests__/device-code-store-client-module.test.mts +9 -14
  37. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +672 -271
  38. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +480 -78
  39. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +102 -66
  40. package/templates/standalone/src/__tests__/federation-token-refresh-outage.test.mts +135 -0
  41. package/templates/standalone/src/__tests__/google-federation-config-module.test.mts +49 -18
  42. package/templates/standalone/src/__tests__/google-session.fixture.mts +315 -0
  43. package/templates/standalone/src/__tests__/host-routes.test.mts +134 -0
  44. package/templates/standalone/src/__tests__/library-references.fixture.mts +183 -0
  45. package/templates/standalone/src/__tests__/listen.test.mts +121 -0
  46. package/templates/standalone/src/__tests__/logger-projection.test.mts +77 -0
  47. package/templates/standalone/src/__tests__/mail-sender.test.mts +134 -0
  48. package/templates/standalone/src/__tests__/mailpit-compose.test.mts +328 -0
  49. package/templates/standalone/src/__tests__/metrics.test.mts +17 -7
  50. package/templates/standalone/src/__tests__/mfa-switch.redis.test.mts +123 -0
  51. package/templates/standalone/src/__tests__/mfa-switch.test.mts +780 -0
  52. package/templates/standalone/src/__tests__/oidc-federation-config-module.test.mts +52 -46
  53. package/templates/standalone/src/__tests__/own-modules.test.mts +1457 -0
  54. package/templates/standalone/src/__tests__/post-logout-redirect.test.mts +183 -0
  55. package/templates/standalone/src/__tests__/redis-connection-error-log.test.mts +135 -0
  56. package/templates/standalone/src/__tests__/redis-rate-limiter-config-survives.test.mts +112 -81
  57. package/templates/standalone/src/__tests__/refresh-token-family-client-module.test.mts +25 -31
  58. package/templates/standalone/src/__tests__/replica-safety.test.mts +166 -145
  59. package/templates/standalone/src/__tests__/router-error-answers.test.mts +146 -0
  60. package/templates/standalone/src/__tests__/scaffold-assets.test.mts +275 -95
  61. package/templates/standalone/src/__tests__/sections.test.mts +480 -0
  62. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +67 -55
  63. package/templates/standalone/src/__tests__/session-requirements.test.mts +299 -0
  64. package/templates/standalone/src/__tests__/shutdown.test.mts +319 -115
  65. package/templates/standalone/src/__tests__/smoke.test.mts +179 -233
  66. package/templates/standalone/src/__tests__/store-credential.test.mts +317 -0
  67. package/templates/standalone/src/__tests__/supertest-loopback.test.mts +182 -11
  68. package/templates/standalone/src/__tests__/three-tier-resolution.test.mts +108 -95
  69. package/templates/standalone/src/__tests__/two-phase-config.test.mts +438 -0
  70. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +390 -0
  71. package/templates/standalone/src/adapters.mts +149 -0
  72. package/templates/standalone/src/app.mts +71 -115
  73. package/templates/standalone/src/buildModules.mts +229 -251
  74. package/templates/standalone/src/configPath.mts +422 -29
  75. package/templates/standalone/src/listen.mts +60 -0
  76. package/templates/standalone/src/logger.mts +31 -57
  77. package/templates/standalone/src/metrics.mts +24 -47
  78. package/templates/standalone/src/mfaSwitch.mts +262 -0
  79. package/templates/standalone/src/modules.mts +480 -464
  80. package/templates/standalone/src/rootRenames.mts +79 -0
  81. package/templates/standalone/src/routes.mts +89 -0
  82. package/templates/standalone/src/sections.mts +366 -0
  83. package/templates/standalone/src/shutdown.mts +54 -126
  84. package/templates/standalone/tests/index.test.js +2 -2
  85. package/templates/standalone/tests/package-lock.json +1615 -2223
  86. package/templates/standalone/tests/package.json +2 -2
  87. package/templates/standalone/tests/vitest.config.mjs +26 -0
  88. package/templates/standalone/vitest.config.mts +8 -7
  89. package/templates/standalone/vitest.supertest-loopback.mts +97 -69
  90. package/templates/versions.json +18 -15
  91. package/templates/standalone/src/__tests__/terminal-error.test.mts +0 -96
  92. package/templates/standalone/src/terminalError.mts +0 -60
@@ -1,204 +1,545 @@
1
1
  # @o3co/auth-provider-standalone
2
2
 
3
- auth.provider のデプロイ可能なサーバーテンプレートです。これは composition root であり、設定を読み込み、モジュールをロードし、Express サーバーを起動します。`@o3co/create-auth-provider` で生成されます。
3
+ 最終更新: 2026-10-03
4
+
5
+ auth.provider のデプロイ可能なサーバーテンプレート。これは composition root であり、設定を読み込み、モジュールをロードし、Express サーバーを起動する。`@o3co/create-auth-provider` で生成される。
6
+
7
+ ## 責務と役割
8
+
9
+ **役割。** composition root であり、デプロイの出発点である。ライブラリではない: これを import するものは何も無い。`@o3co/create-auth-provider` がこれを新しいプロジェクトにコピーし、以後はオペレーターがそのコピーを所有・編集する。パッケージ群の上に位置し、`@o3co/auth-provider-core` の上に `@o3co/auth-provider-oauth`、`-session`、Google と汎用 OpenID Connect のフェデレーションアダプター、`-federation-grants`、`-mfa`、`-redis`、`-foundation`、`-standard` のメール送信者を合成する。
10
+
11
+ **所有する**のは、1 つのデプロイに固有の選択である:
12
+
13
+ - どのモジュールをどの順序で合成し、各ストアスロットをどのアダプターで埋めるか — [`src/buildModules.mts`](src/buildModules.mts)([モジュール合成順序](#モジュール合成順序) を参照)。その選択は自身のキー、`adapters` セクション([`src/adapters.mts`](src/adapters.mts))と MFA のスイッチ `mfaMode`([`src/mfaSwitch.mts`](src/mfaSwitch.mts))から読む
14
+ - この scaffold だけが持つモジュール — `logging` と `http`(ホストプロセス自身の設定とその CORS のリスト、および core の `httpSettings` スロット)、署名鍵ストア、クライアントリポジトリとユーザーリポジトリ、監査 sink、共有される唯一の Redis 接続、in-memory のユーザーセッションストア・コードリポジトリ・フェデレーショントークンストア、そしてフェデレーションの config bridge — [`src/modules.mts`](src/modules.mts)。それらが所有するセクションのデフォルトは [`config/reference.conf`](config/reference.conf) にある。これらがパッケージではなく scaffold 側にあるのは、どれもこのテンプレートの設定セクションから自分のコンポーネントを組み立てるためである。別のソースを使いたいデプロイは、同じ形のモジュールを自前で配線する
15
+ - 設定をどこから読み、そのレイヤーをどう重ねるか — [`src/configPath.mts`](src/configPath.mts) と [`config/`](config/)
16
+ - ホストプロセス: Express アプリ、そのセキュリティヘッダー、起動処理 — [`src/app.mts`](src/app.mts)。health / readiness / metrics の各ルート、合成されたルーター、その後ろの core の終端のエラーハンドラーを、マウントする順に — [`src/routes.mts`](src/routes.mts)。リスナーはソケットが bind されたときに `server_listening`(info、`port`)を 1 行ログに出し、bind できなければその bind のエラーで起動を失敗させ、その後のサーバーのエラーは `server_error`(error)としてログに出す — [`src/listen.mts`](src/listen.mts)。このコードがログに出すエラー — 処理されなかったリクエストのエラー、共有 Redis 接続の `error` イベント、失敗したシャットダウン — はすべて core の [`loggableError`](../../packages/core/README.ja.md#logger) による射影としてログに出し、エラーそのものは出さない。エラーは上流や Redis が言ったことを運びうるためである(サーバーが接続を拒否したとき、接続のエラーは `AUTH` のハンドシェイクをパスワードごと運ぶ)
17
+ - 具体的な logger と監査ストリーム(pino) — [`src/logger.mts`](src/logger.mts) — およびメトリクス([`src/metrics.mts`](src/metrics.mts))
18
+ - プロセスのライフサイクル: drain の deadline 付きのシグナル処理 — [`src/shutdown.mts`](src/shutdown.mts)
19
+ - パッケージング: `Dockerfile`、compose ファイル群、`Makefile`
20
+ - 自身のテスト: `pnpm run test` と `make test` がこの合成に対して実行する `src/__tests__/` と、独自の `package.json` を持つ別個のブラックボックス API スイートで、既に起動しているサーバー(`API_BASE_URL`)に対して実行する `tests/`。`src/__tests__/` が保つものの 1 つは、このテンプレートが有効にできるすべてのモジュールが一緒に起動し、モジュールが出会って初めて成り立つ契約 — 1 つのディスカバリー文書、どちらのマウント順でも保たれる各モジュールのボディ規則、503 で答えて 1 行だけログに出るストアまたはリポジトリの障害 — を守ることであり、`all-modules-composition.test.mts` がそれを固定する。
21
+
22
+ **所有しない**のは HTTP API、グラント、トークン形式、各ストアの振る舞い、config スキーマで、これらはパッケージ側が所有する。変更は上流で行い、ここでは行わない。
23
+
24
+ **分離している理由。** 上記はすべてデプロイごとの判断であるため、公開ライブラリの外に置き、オペレーターが変更する前提のツリーに入れている。これ自体は `"private": true` で、公開されることはなく、ユーザーに届くのは scaffolder 経由だけである。scaffold されたコピーにはこの README も含まれるが、その中の `../../docs` と `../../packages` への相対リンクはこのリポジトリ内でしか解決しない。
25
+
26
+ ## 動作要件
27
+
28
+ - **Node.js** `>=22.0.0`
29
+ - **`bcrypt` のネイティブバイナリ**: このテンプレートは推移的に `bcrypt@6.x` に依存しており、これは `darwin-arm64`、`darwin-x64`、`linux-x64`(glibc と musl)、`linux-arm64`(glibc と musl)、`linux-arm`、`win32-x64`、`win32-arm64` 向けの **prebuild 済み N-API バイナリ**を同梱している。これらのプラットフォームでは `pnpm install` はソースからコンパイルせずに成功し、追加のツールチェーンは不要である(`darwin-*`、`node:*-alpine`、`node:*-bookworm` の各イメージはいずれも同梱の prebuild に一致する)。一致する prebuild の無いプラットフォームや libc/arch の組み合わせでは `pnpm install` がコンパイルにフォールバックし、その場合は C++ コンパイラと Python(Node.js ネイティブアドオンの標準ツールチェーン)が必要になる: Debian/Ubuntu は `apt-get install build-essential python3`、Alpine は `apk add make g++ python3`、macOS は `xcode-select --install`。`bcrypt` 向けの pnpm 10 の `onlyBuiltDependencies` allowlist は `pnpm-workspace.yaml` にある — pnpm ≥10.29 は単一パッケージのプロジェクトでもそこからしか読まない。`create-auth-provider` は scaffold するプロジェクトにこのファイルを書き出す(このモノレポ内では、allowlist はワークスペースルートの `pnpm-workspace.yaml` にある)。これが無いと、新規の `pnpm install` はそれらのプラットフォームで install hook を黙ってスキップする。
30
+ - **Redis 7.2 LTS 以降** — Redis をバックエンドとするアダプター(refresh token family ストア、コードリポジトリ、フェデレーショントークンストア、セッションストア)すべてに必要。複数のアダプターが依存する `pExpireGT` フラグの組は Redis 7.0+ で導入された。7.2 LTS がテスト済みの下限である。AWS ElastiCache for Redis 7.2、Upstash Redis、Redis Cloud 7.2、セルフマネージドの `redis:7.2-alpine` でテストしている。
31
+ - **ioredis** `^6.0.0`(ランタイムの直接依存)。このテンプレートが配線する `@o3co/auth-provider-redis` のアダプター — refresh token family、認可コード、rate limit カウンター、アクセストークン denylist、replay seen-set、ユーザーセッションストア、フェデレーショントークンストア、同意ストア、フェデレーショングラントのストア — はすべて、`standaloneRedisClientsModule` がレプリカごとに開く 1 本の ioredis 接続の上で動く。同じモジュールは Redis パッケージが提供するほかのストア — デバイスコードストアと WebAuthn の challenge ストア — のクライアントも提供するため、デバイスグラントや WebAuthn を Redis のストアとともに `buildModules.mts` に加えるデプロイは、そのクライアントをここで得る。`redis` npm パッケージ(`^6.2.1`)が依存に残っている理由は 1 つだけである: `connect-redis` は node-redis クライアントを受け取るため、`SESSION_STORE_STORAGE_TYPE=redis` の背後にある `express-session` ストアは 2 本目の別接続になる(`/readyz` の `session-store` probe)。
32
+
33
+ ## ファーストパーティクライアント
34
+
35
+ `firstParty: true` が付いたクライアントに対しては、`GET /authorize` はユーザーのセッションが認証済みになった時点で認可コードを発行する。**同意ステップは無い**: このクライアントに自分の identity を渡してよいかを、ユーザーが問われることは一切ない。
36
+
37
+ これは**ファーストパーティ**の OP — 登録されたクライアントがすべて自分で運用するものである OP — にとっては筋の通ったモデルだが、それ以外では成り立たない。自分が制御していないページが `/authorize?client_id=X&redirect_uri=...&code_challenge=<theirs>` へのトップレベル遷移を強制できる。ログイン中のユーザーのブラウザは*そのユーザーの*セッションに紐づいたコードを発行し、それは X の登録済み `redirect_uri` に届けられ、`code_challenge` を選んだ者がそれを、そのユーザーの identity を載せたトークンと引き換える。
38
+
39
+ そのため、この前提は仮定されるのではなく強制される。それ以外のクライアント — `firstParty` フィールドを持たないものも、`false` を持つものも同様に — は同意ステップを通る: コードが発行される前に、デプロイ側の同意ページでユーザーに確認する。このステップには同意ストアが必要だが、テンプレートは何も同梱していない(`ADAPTERS_CONSENT_STORE=none`)ため、デフォルトのままではそのようなクライアントは `/authorize` で `unauthorized_client` として拒否される。それを受け付けるには [同意ストア](#同意ストア) を参照。クライアントをファーストパーティとしてマークするのは、そのクライアントが**ユーザーに確認することなく**ユーザーの identity を受け取っても構わないと言える場合だけにすること。
40
+
41
+ ```yaml
42
+ # config/clients.yaml
43
+ my-app:
44
+ tokenEndpointAuthMethod: "client_secret_basic"
45
+ firstParty: true
46
+ allowedRedirectUris: ["https://app.example/callback"]
47
+ ```
48
+
49
+ マシンクライアントは共有シークレットの代わりに鍵で認証できる(#484): `tokenEndpointAuthMethod: "private_key_jwt"` に `jwks`(インラインの公開鍵)または `jwksUri` を加え、`/oauth/token` で署名済みアサーションを提示する — [oauth パッケージの README](../../packages/oauth/README.md#client-authentication-private_key_jwt-rfc-7523-22) を参照。そのようなクライアントについては、`clients.yaml` に秘密情報は何も置かれない。
50
+
51
+ **これがしないこと。** ファーストパーティで*ある*クライアントについて、`/authorize` を強制遷移に対して安全にするわけではない — それはここで受け入れているモデルのままである。これが防ぐのは、確認なしのコード発行を任せるべきではなかったクライアントが、誤ってその立場に登録されることである。
52
+
53
+ **マークの無い登録。** このフィールドの無い登録はファーストパーティではない: デフォルトのままでは、マークするまで `/authorize` で拒否され、警告付きで受け入れる猶予期間も無い。自分で運用するクライアントは、`/authorize` に向ける前にすべてマークすること。`oauth.authorize.allowUnmarkedClients` は設定項目ではない: これを設定した config や環境(`OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS`、値は問わない)は、黙って無視されるのではなく、移行手順を示して起動時に失敗する。
54
+
55
+ ## マルチレプリカ構成
56
+
57
+ 運用面は [オペレーター runbook](../../docs/operator-runbook.md) が端から端まで扱っている — 起動拒否、probe、依存先の障害、アラート、Redis のサイジング、鍵ローテーション。このセクションはマルチレプリカのチェックリストである。
58
+
59
+ ロードバランサ配下で standalone サーバーを複数インスタンス動かすときは、`REDIS_CLIENTS_URL` を**共有**の Redis 7.2+ インスタンスに向けること。共有の Redis URL が無いと、各レプリカは refresh token family を自分の接続(と、ローカル専用の Redis)に保持するため、トークンを発行していないレプリカに届いた refresh リクエストは `invalid_grant` を返し、ラウンドロビンの LB では 1 回おきにそうなる。
60
+
61
+ **ロードバランサ配下では `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
+
63
+ 取りうる形は 4 つで、いずれも Express 自身のものである:
64
+
65
+ | 値 | 意味 |
66
+ |---|---|
67
+ | `false`(デフォルト) | 何も信頼しない。`req.ip` はソケットのピアになる |
68
+ | `10.0.0.0/8,192.168.0.0/16` | 正確にこれらのホップを信頼する。IP リテラル、CIDR レンジ、または名前付きレンジ `loopback` / `linklocal` / `uniquelocal`。環境変数ではカンマ区切りにする |
69
+ | `1` | ソケットのピアから数えてその数だけのホップを信頼する |
70
+ | `true` | すべてのホップを信頼する — 接続を開いた相手が誰であれ、その `X-Forwarded-For` の左端のエントリ |
71
+
72
+ **アドレスリストを優先すること。** `true` は、このプロセスに到達できる相手なら誰からの転送クライアントアドレスでも信じる。直接、あるいは想定より 1 ホップ多く経由して到達したクライアントは、自分の `req.ip` を、ひいてはそれをキーとする rate limit 上の identity を自分で選べてしまう。エントリは起動時に検証されるため、ホスト名や打ち間違えたレンジは、決して一致しないルールになるのではなく、はっきりと失敗する。
73
+
74
+ allowlist はネットワーク上の制御であって、暗号学的な制御ではない。エッジは受信した `X-Forwarded-*` ヘッダーに追記するのではなく**除去**しなければならず、エッジとこのプロセスの間のホップは、送信元アドレスを偽装できる者から到達できてはならない。
75
+
76
+ **`adapters.rateLimiter` を Redis に向けること。** デフォルトは `"memory"` でプロセスごとである: N レプリカでは設定したすべての limit が実質 N 倍になり、デプロイのたびにリセットされる。memory アダプターは**バケット枯渇によって回避可能**でもある: バケット数の上限を 10,000 とし、上限に達すると、新しいキーを受け入れる際にリセットが最も近いバケットを追い出す — そのため多数の送信元 IP を提示できる攻撃者(`HTTP_TRUST_PROXY` が実際のホップより広ければ、`req.ip` はクライアントの影響を受ける)は、標的のカウンターが追い出されてやり直しになるまでテーブルをかき回せる。これは開発用の 1 プロセスなら許容できるが、本番の rate limit ではない。ログインガードも同じ共有コンポーネントの上で動くため、1 つの設定で OAuth エンドポイントと `/session/login` の両方がカバーされる。ログインのウィンドウと上限は引き続き `session.rateLimit.login` で設定する。session モジュールがそれを両アダプターが読む `login` の予算として寄与するため、改めて書き直すものは無い。
77
+
78
+ **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` に変え、ユーザーが報告する症状は「サインインがときどき壊れる」になる。それは起き始めるトラフィック量に達した時点で現れ、それより前には現れない。
79
+
80
+ `HTTP_TRUST_PROXY` はこれを解決しないし、そのためのものでもない。これは `req.ip` に*プロキシ* — 他者のリクエストを転送し、そのことを `X-Forwarded-For` で申告するホップ — の向こうを見通させるものである。BFF は誰のリクエストも転送していない。BFF 自身がクライアントであり、そのアドレスは「誰が呼んだか」への正直な答えである。これらのリクエストには、キーにできるユーザーごとの identity が存在しない。
81
+
82
+ 調整すべきはエンドポイントごとの予算である。どちらの limiter アダプターも、自身のセクションで、エンドポイントの prefix をキーとする `limits { <prefix> { limit, windowSeconds } }` を受け取る:
83
+
84
+ ```hocon
85
+ redis-rate-limiter {
86
+ limits {
87
+ token { limit = 600, windowSeconds = 60 }
88
+ introspect { limit = 600, windowSeconds = 60 }
89
+ }
90
+ }
91
+ ```
92
+
93
+ (memory アダプターでは `core-rate-limiter-memory.limits` で、形は同じ。)BFF の毎分のサインインと refresh のピークから、余裕を持たせてサイズを決めること。また `adapters.rateLimiter = "redis"` のもとでは予算がレプリカ間で共有されることを覚えておくこと — ここではそれが望ましい挙動であり、それによってこの数値が意味を持つ。
94
+
95
+ デフォルトは意図的に変えていない: 送信元 IP ごとに 60 秒あたり 60 回というのは、クライアントが本当に別々の IP であるデプロイにとって妥当な総当たり対策の上限であり、一方の構成に合わせて全体的に引き上げれば、もう一方の構成での防御が弱まる。BFF *自身の*ユーザーを守るスロットリングは、ユーザーごとの identity がまだ存在する BFF の前段に置くこと。
96
+
97
+ **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` 警告に列挙される。(login と WebAuthn-options のルートは、それぞれ個別に警告するプロセス単位のフォールバック limiter を持つが、それが働くのは `rateLimiter` をまったく配線しない構成だけで、このテンプレートは常に配線する。オペレーター 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) を参照。
98
+
99
+ この変数は `core.deployment.mode` を設定する。旧名の `DEPLOYMENT_MODE` は、単独で、または `CORE_DEPLOYMENT_MODE` と異なる値で設定されているとブートを拒否し、同じ値で並べて設定されていればブートする。
100
+
101
+ このチェックに*できない*ことも把握しておくこと: `CORE_DEPLOYMENT_MODE` を一度も設定しないまま N レプリカにスケールしても、何も失敗しない。すべての状態を自分のメモリに持つプロセスには、他のピアに気づくための共有媒体が無い — その状態は、まさにそれが真であるときに内側からは検出できない。この変数はスケールの一環として設定し、何かが壊れてからにしないこと。
102
+
103
+ **アクセストークンの失効には denylist が必要で、テンプレートはそれを同梱している。** `POST /oauth/revoke` はアクセストークンの `jti` を `accessTokenDenylist` に書き込むことで失効させ、トークン検証と introspection はそれを参照する。背後に denylist の無い状態でエンドポイントがマウントされていると、起動が*失敗*する: RFC 7009 はエンドポイントに `200` を返すことを義務づけているため、denylist が配線されていないと、トークンは失効したと告げながら、そのトークンは期限切れまで使え続けてしまう。ここでは `adapters.accessTokenDenylist` のデフォルトは `"redis"` で(単一インスタンスのローカル作業では `ADAPTERS_ACCESS_TOKEN_DENYLIST=memory`)、`REDIS_CLIENTS_URL` で設定した ioredis ソケットを共有するため、追加の接続は発生しない。本当にアクセストークンを失効させないデプロイは、代わりに `OAUTH_REVOCATION_ACCESS_TOKEN=unsupported` を設定する。するとエンドポイントは `token_type_hint=access_token` に対して `unsupported_token_type` を返す。**リフレッシュトークンの失効はどちらの場合でも機能し、denylist を必要としたことは一度もない。**
104
+
105
+ デフォルトのモジュールがカバーする、その他のマルチレプリカ上の考慮点:
106
+
107
+ - `express-session` のストア(`sessionStoreModule`)は独自の接続である: `SESSION_STORE_STORAGE_TYPE=redis` とし、`SESSION_STORE_STORAGE_REDIS_URL`(`session-store.storage.redis.url`)を共有インスタンスに向ける。
108
+ - ユーザーセッションストアは `adapters.userSessionStores = "redis"`(`ADAPTERS_USER_SESSION_STORES`)で切り替わり、共有の ioredis 接続 — `REDIS_CLIENTS_URL` で設定するもの — の上に `redisSessionStoresModule` を配線する。
109
+ - 認可コードリポジトリは `adapters.codeRepository`(`ADAPTERS_CODE_REPOSITORY`)で切り替わる。テンプレートは同じ接続上の `"redis"` を同梱しているので、すべてのアダプターで Redis URL は 1 つである。Redis のリポジトリ自身の設定はそのセクション `redis-code-repository`、in-process のものは `standalone-in-memory-code-repository` にある([コードリポジトリ](#コードリポジトリ) を参照)。
110
+ - replay seen-set — `private_key_jwt` クライアント認証(#484)の背後にある、`jti` の一回限り使用の記録 — は `adapters.replaySeenSet`(`ADAPTERS_REPLAY_SEEN_SET`)で切り替わる。テンプレートは共有接続上の `"redis"` を同梱しており、`memory` は `CORE_DEPLOYMENT_MODE=multi` のもとでは拒否される。捕獲されたクライアントアサーションが、レプリカごとに 1 回ずつリプレイできてしまうためである。
111
+ - ファーストパーティでないクライアントのための同意ステップ(#527)は `adapters.consentStore`(`ADAPTERS_CONSENT_STORE`)で切り替わる。デフォルトはオフ(`none`)である。`memory` は `CORE_DEPLOYMENT_MODE=multi` のもとでは拒否される。あるレプリカで与えた同意が他のすべてのレプリカで再度求められ、同意ページが保留したリクエストを、回答を受け取ったレプリカが知らないという事態になるためである。`redis`(#561)は両方を共有接続上に保持する。[同意ストア](#同意ストア) を参照。
112
+ - フェデレーショントークンストアのデフォルトは memory である。`ADAPTERS_FEDERATION_TOKEN_STORE=redis`(`adapters.federationTokenStore = "redis"`)を設定し、`REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY` — 32 バイト、base64 エンコード(`openssl rand -base64 32`) — を与えること。ストアは保持する上流のリフレッシュトークンを暗号化する。`REDIS_CLIENTS_URL` で設定した ioredis ソケットを共有する。[フェデレーショントークンストア](#フェデレーショントークンストア) を参照。
113
+ - フェデレーショングラント(#593)は、有効にするとさらに 2 つのストアを持つ: グラントそのもの(`ADAPTERS_FEDERATION_GRANT_STORE`)と、取得フローの記録(`ADAPTERS_FEDERATION_GRANT_INTENT_STORE`)。どちらも `none` を同梱しており、機能を有効にするとそのストアを名指しして起動が拒否される。それぞれ `redis`(共有ソケット)か `memory` を選ぶ。いずれかを `memory` にすると `CORE_DEPLOYMENT_MODE=multi` のもとでは拒否され、Redis のグラントと memory のユーザーセッションストアの組み合わせはレプリカ数にかかわらず拒否される。グラントが、それを終わらせる境界より長生きしてしまうためである。[フェデレーショングラント](#フェデレーショングラント) を参照。
4
114
 
5
115
  ## 使い方
6
116
 
117
+ `pnpm run debug` はこのマシン上で `src/app.mts` を `tsx watch` で実行する。
118
+ `.env` ファイルは読まない — 設定は `config/` と、起動したシェルの環境変数だけで
119
+ ある — そして、その環境が次のものを与えるまで起動しない:
120
+
121
+ - `OAUTH_JWT_ISSUER` — このサーバーのオリジン(loopback ホストなら `http` も可);
122
+ - 署名鍵のペア、`KEY_STORE_LOCAL_PRIVATE_KEY_PATH` / `KEY_STORE_LOCAL_PUBLIC_KEY_PATH`
123
+ ([OAuth JWT](#oauth-jwt) を参照);
124
+ - `SESSION_STORE_SECRET`、32 バイト以上;
125
+ - `REPOSITORIES_USER_HTTP_AUTHENTICATE_URL` と `REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL` —
126
+ ユーザーサービス(「the Store」)。`https`、または loopback ホストなら `http`。
127
+ 起動時に検査されるのは形式だけで、応答があるかどうかではない。何かが応答する
128
+ までログインは失敗する([ユーザーリポジトリ](#ユーザーリポジトリ) を参照);
129
+ - `redis://localhost:6379` で到達できる Redis。同梱の設定は、ブラウザセッションの
130
+ ストアをそこに置き(`SESSION_STORE_STORAGE_REDIS_URL`)、共有の接続を 1 本
131
+ (`REDIS_CLIENTS_URL`)開いて、refresh token family、
132
+ 認可コード、アクセストークン denylist、replay seen-set に使う — 下の設定を
133
+ 加えれば、ユーザーセッションストアにも。
134
+
135
+ 平文の HTTP では、さらに `SESSION_STORE_SECURE=false` と、`__Host-` プレフィックスを
136
+ 持たないセッション Cookie 名が必要になる([Session](#session) を参照)。また
137
+ `ADAPTERS_USER_SESSION_STORES=redis` を設定すること: ブラウザセッションのストアは
138
+ Redis にあり、ユーザーセッションストアのデフォルトは memory で、`tsx watch` は
139
+ 保存のたびにプロセスを再起動する — 両者が分かれていると、再起動のあとブラウザの
140
+ セッションは既に存在しない `UserSession` を指し、`/authorize` がループする
141
+ ([Docker](#docker) を参照)。MFA はデフォルトで有効で、その 2 つのストアの
142
+ デフォルトも memory である: 同じ理由で `ADAPTERS_MFA_FACTOR_STORE=redis` と
143
+ `ADAPTERS_MFA_TRANSACTION_STORE=redis` を設定する。さもないと保存のたびに、
144
+ セッションが残ったままストアだけが空になる。MFA なしで動かすなら `MFA_MODE=off`
145
+ ([多要素認証](#多要素認証) を参照)。
146
+
147
+ クライアントレジストリ `config/clients.yaml`(`REPOSITORIES_CLIENT_YAML_PATH`)も読む。このファイルは
148
+ デプロイごとのもの: scaffold は空のものを作るが、`.gitignore` がそれをプロジェクトの
149
+ リポジトリから外すため、新しい clone には無く、作るまで起動はそのパスを挙げて失敗する
150
+ — `config/clients.yaml.example` から作るか、クライアントの無いレジストリなら空で作る。
151
+
152
+ 下の鍵ペアは Node で生成する。これを動かすマシンには必ず Node がある。
153
+ `openssl genpkey -algorithm ed25519` でもよいが、OpenSSL 1.1.1 以降が必要で、
154
+ macOS が `/usr/bin/openssl` として同梱する LibreSSL では動かない。
155
+
7
156
  ```bash
8
- # 依存関係をインストール
9
157
  pnpm install
10
158
 
11
- # 環境変数ファイルをコピーして必須値を記入
12
- cp .env.example .env
159
+ # 署名鍵のペア(Ed25519)とローカルの Redis
160
+ node -e '
161
+ const { generateKeyPairSync } = require("node:crypto");
162
+ const fs = require("node:fs");
163
+ const { privateKey, publicKey } = generateKeyPairSync("ed25519");
164
+ fs.writeFileSync("jwt-private.pem", privateKey.export({ type: "pkcs8", format: "pem" }), { mode: 0o600 });
165
+ fs.writeFileSync("jwt-public.pem", publicKey.export({ type: "spki", format: "pem" }));
166
+ '
167
+ docker run -d --name auth-redis -p 127.0.0.1:6379:6379 redis:7.2-alpine
168
+
169
+ # 起動に必要なもの(ユーザーサービスの URL は自分の Store のものに置き換える)
170
+ export OAUTH_JWT_ISSUER=http://localhost:3000 \
171
+ KEY_STORE_LOCAL_PRIVATE_KEY_PATH=./jwt-private.pem \
172
+ KEY_STORE_LOCAL_PUBLIC_KEY_PATH=./jwt-public.pem \
173
+ SESSION_STORE_SECRET="$(openssl rand -hex 32)" \
174
+ SESSION_STORE_SECURE=false SESSION_STORE_NAME=auth.sid \
175
+ ADAPTERS_USER_SESSION_STORES=redis \
176
+ ADAPTERS_MFA_FACTOR_STORE=redis ADAPTERS_MFA_TRANSACTION_STORE=redis \
177
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_URL=http://localhost:8080/authenticate \
178
+ REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL=http://localhost:8080/authenticate-by-token
13
179
 
14
180
  # 開発モードで起動(ホットリロード)
15
181
  pnpm run debug
16
182
 
17
- # ビルドして本番モードで起動
183
+ # ビルドしてから、コンパイル済みサーバーを実行(CONFIG_ENV または NODE_ENV が config/<env>.conf を選ぶ)
18
184
  pnpm run build
19
- pnpm start
185
+ pnpm run start
20
186
  ```
21
187
 
188
+ `.env` を読むのは compose ファイル(`env_file`)だけである。`make dev`
189
+ (`docker compose up --build`)は同じ debug サーバーを Redis サービスと並べて
190
+ コンテナで実行し、環境変数を `.env` から取る — `.env` は `.env.example` から
191
+ コピーするもので、その Redis の URL はそのサービスを指している。そこでの値は
192
+ コンテナの中で解決され、コンテナには鍵ファイルが無い: あるのは `src/`、
193
+ `config/`、パッケージのマニフェストと、そのインストールとビルドの成果物だけで
194
+ ある。ホストで有効な鍵のパスや `localhost` の URL は、そこでは有効ではない。
195
+ デプロイ用のイメージとその compose ファイルは [Docker](#docker) にある。
196
+
22
197
  ## 設定
23
198
 
24
- 設定は `config/application.conf`(HOCON 形式)から読み込まれます。各値は対応する環境変数で上書きできます。
199
+ 設定は `config/application.conf`(HOCON 形式)から、パッケージ群の `reference.conf` とテンプレート自身の `config/reference.conf` のデフォルトの上に読み込まれる。各値は対応する環境変数で上書きできる。
25
200
 
26
201
  ### 環境別コンフィグ overlay
27
202
 
28
- `src/app.mts` は 2 層構成でコンフィグを読み込みます:
203
+ `src/app.mts` は複数の層でコンフィグを読み込む(優先度の高い順):
204
+
205
+ 1. **`config/{ENV}.conf`** — 現在の環境の overlay。`ENV = CONFIG_ENV || NODE_ENV || "development"` で決まる。
206
+ 2. **`config/application.conf`** — このデプロイの設定。
207
+ 3. **読み込むモジュールの各パッケージの `reference.conf`**、その次に **`@o3co/auth-provider-core` のもの** — デフォルト。インストール済みパッケージから解決される: 各モジュールが自分のパッケージのファイルを宣言し、core の `moduleReferences(modules)` がそれを core のものを最後にして列挙する。テンプレート自身のモジュールは `config/reference.conf` を宣言し、そこには自身が所有するセクション — `logging`、`http`(その CORS のリスト `http.cors` を含む)、`key-store`、共有 Redis 接続の `redis-clients`、`repositories`、in-process のコードリポジトリの `standalone-in-memory-code-repository`、`audit-sink` — と、composition root 自身の `adapters` と `mfaMode`(下記)のデフォルトがある。デプロイ固有の値はそこではなく上の 2 ファイルに書く。`config/reference.conf` はこれらのキーの変数をデフォルトの横で束縛するので、上の 2 ファイルのどちらかが設定する値は変数に勝つ。ただし `HTTP_PORT`、`HTTP_TRUST_PROXY`、`HTTP_CORS_ALLOWED_ORIGINS`、`REDIS_CLIENTS_URL` / `REDIS_CLIENTS_PASSWORD` は `application.conf` が末尾の行でもう一度束縛するので、それぞれ、そのファイルがそれより上で設定する値に勝つ。上の 2 ファイルのどちらも設定していないキーは、ここから値を得る。
208
+
209
+ 上の 2 ファイルは、環境変数の一つのスナップショットのもとで一度だけ読み(`readOwnLayers`)、その一度の読み込みから二つの段階を組み立てる — 起動中にファイルが置き換えられても、変数が変わっても、boot がモジュールを選んだものと違うものをパースすることはない。読み込みは二段階で行う([#728](https://github.com/o3co/auth.provider/issues/728)、[`src/configPath.mts`](src/configPath.mts))。まず、モジュールを知る前に、`buildModules` がモジュールを選ぶスイッチ — フェデレーション、機能 — と、期待するセッション要件を導く元の `sessionRequirements` を、上の 2 ファイルを core の `reference.conf` だけの上に重ねて読む(`readSwitches`、core の transitional reader で読む)。パースするのはそれらのパス(`SWITCHES`)だけである。`adapters` — 各スロットをどのアダプターで埋めるか、composition root 自身のセクション — は、上の 2 ファイルをテンプレートの `config/reference.conf` の上に重ね、テンプレート自身のスキーマで読む(`readAdapters`): 移動する前のパスにまだ書かれた選択や、それとともに改名された変数は、どのモジュールを選ぶよりも前にここで拒否され、新しいパスと変数を名指しする。composition root 自身のキーである MFA のスイッチ `mfaMode`(`MFA_MODE`)も同じように、テンプレート自身のスキーマで読み(`readMfaSwitch`)、それと並んで、Store を使う MFA 要素ストアの元になるユーザーリポジトリの HTTP 設定をパースせずに読む。boot には `adapters` も `mfaMode` も渡さない。パッケージの `reference.conf` だけが設定するものはこの段階では見えない — まだどれも重ねていない — ので、組み立て時に設定を読むモジュールを `buildModules` に加えるなら、そのモジュールが読むパスを `SWITCHES` に加える。ログレベルも boot の前に読むが、それは `logging` モジュールのセクションとして、そのモジュールのスキーマで、テンプレートの `reference.conf` の上に読む(`readLogging`): テンプレートは設定を読みモジュールを選ぶ間もログを出すので、logger は boot の前に存在する。次に、読み込むすべてのモジュールの `reference.conf` の上に解決した設定を、第一段階で導いたセッション要件を書き込み、`mfa` セクションは MFA のスイッチが決めるとおりにして([多要素認証](#多要素認証) を参照)、パースせずに `createApp` に渡す(`resolveForBoot`): boot はそれを、読み込まれたすべてのモジュールのスキーマで一度だけパースする。boot の後にテンプレートが読むもの — 信頼するホップ、ポート、readiness の期限 — は、`http` モジュールから読む: core の `httpSettings` スロットと、テンプレートの `httpHostSettings` である。読み込まれたどのモジュールも所有しないトップレベルのセクションは残され、boot 時に `config_sections_ignored` として一度だけログに出る: セクション名の綴り間違いはここに現れる。
29
210
 
30
- 1. **`config/application.conf`** — アプリの基本デフォルト。
31
- 2. **`config/{ENV}.conf`** — 現在の環境の overlay。
32
- `ENV = CONFIG_ENV || NODE_ENV || "development"` で決まります。
211
+ overlay の値は `application.conf` より優先される。scaffold には `development.conf` と `production.conf`、そして `mailpit.conf` が同梱されている。`mailpit.conf` は、[Docker](#docker) の Mailpit オーバーレイが選ぶ名前のもとでの development の overlay である。別の環境(例: `staging`)を追加するときは `config/staging.conf` を作成し、`CONFIG_ENV=staging` を設定する。`{ENV}.conf` が存在しない場合は起動時エラーになる — タイポは黙ってデフォルトにフォールバックせず、fail-fast する。
33
212
 
34
- overlay の値は `application.conf` を上書きします。scaffold には
35
- `development.conf` と `production.conf` が同梱されています。別の環境
36
- (例: `staging`)を追加するときは `config/staging.conf` を作成し、
37
- `CONFIG_ENV=staging` を設定してください。`{ENV}.conf` が存在しない場合は
38
- 起動時にエラーになります(タイポを silent に素通りさせず fail-fast します)。
213
+ ### アダプター
214
+
215
+ 各スロットをどのアダプターで埋めるかは composition root 自身の選択で、自身のセクション `adapters` にあり、どのモジュールを選ぶよりも前に読む(上記)。各キーに変数がある。テンプレートが知らない値や、宣言していないキーは、それを名指しして boot の前に失敗する。
216
+
217
+ | 変数 | デフォルト | 説明 |
218
+ |---|---|---|
219
+ | `ADAPTERS_RATE_LIMITER` | `memory` | `adapters.rateLimiter`: `memory` または `redis`。[マルチレプリカ構成](#マルチレプリカ構成) を参照 |
220
+ | `ADAPTERS_USER_SESSION_STORES` | `memory` | `adapters.userSessionStores`: ユーザーセッションストア、`memory` または `redis`。`SESSION_STORE_STORAGE_TYPE` と揃える([Docker](#docker) を参照) |
221
+ | `ADAPTERS_ACCESS_TOKEN_DENYLIST` | `redis` | `adapters.accessTokenDenylist`: `memory` または `redis` |
222
+ | `ADAPTERS_REPLAY_SEEN_SET` | `redis` | `adapters.replaySeenSet`: `private_key_jwt` の背後の replay seen-set、`memory` または `redis` |
223
+ | `ADAPTERS_CONSENT_STORE` | `none` | `adapters.consentStore`: `none`、`memory` または `redis`。[同意ストア](#同意ストア) を参照 |
224
+ | `ADAPTERS_FEDERATION_TOKEN_STORE` | `memory` | `adapters.federationTokenStore`: `memory` または `redis`。[フェデレーショントークンストア](#フェデレーショントークンストア) を参照 |
225
+ | `ADAPTERS_FEDERATION_GRANT_STORE` | `none` | `adapters.federationGrantStore`: `none`、`memory` または `redis`。[フェデレーショングラント](#フェデレーショングラント) を参照 |
226
+ | `ADAPTERS_FEDERATION_GRANT_INTENT_STORE` | `none` | `adapters.federationGrantIntentStore`: `none`、`memory` または `redis`。[フェデレーショングラント](#フェデレーショングラント) を参照 |
227
+ | `ADAPTERS_MFA_FACTOR_STORE` | `memory` | `adapters.mfaFactorStore`: `memory`(development と test のみ)、`redis` または `store`(Store のエンドポイント)。`MFA_MODE` が MFA を組み込む間に読む。[多要素認証](#多要素認証) を参照 |
228
+ | `ADAPTERS_MFA_TRANSACTION_STORE` | `memory` | `adapters.mfaTransactionStore`: `memory`(development と test のみ)または `redis`。`MFA_MODE` が MFA を組み込む間に読む |
229
+ | `ADAPTERS_CODE_REPOSITORY` | `redis` | `adapters.codeRepository`: 認可コードリポジトリ、`memory` または `redis`。[コードリポジトリ](#コードリポジトリ) を参照 |
230
+ | `ADAPTERS_CLIENT_REPOSITORY` | `yaml` | `adapters.clientRepository`: `yaml`、または `static`(core の `yaml` の別名)。[クライアントリポジトリ](#クライアントリポジトリ) を参照 |
231
+ | `ADAPTERS_USER_REPOSITORY` | `http` | `adapters.userRepository`: `http`、`yaml` または `static`(core の `yaml` の別名)。[ユーザーリポジトリ](#ユーザーリポジトリ) を参照 |
232
+ | `ADAPTERS_AUDIT_SINK` | `logger` | `adapters.auditSink`: セキュリティイベントの送り先、`logger`(stdout への pino エンベロープ付き NDJSON)または `console`(イベントの JSON そのまま)。`none` は存在しない。[監査ログ](#監査ログ) を参照 |
233
+
234
+ 移動する前の場所 — `rateLimiter.adapter`、`oauth.code.adapter`、`audit.sink.type`、`repositories.user.type` など — にまだ書かれた選択や、それとともに改名された変数(`RATE_LIMITER_ADAPTER`、`CLIENT_TYPE` など)が単独で、または別の値で設定されていると、新しいパスと変数を名指しして boot の前に失敗する。古い変数が新しい変数と同じ値で並んでいるなら受け入れる。
39
235
 
40
236
  ### HTTP
41
237
 
42
238
  | 変数 | デフォルト | 説明 |
43
239
  |---|---|---|
44
- | `HTTP_PORT` | `3000` | サーバーがリッスンするポート |
45
- | `HTTP_TRUST_PROXY` | `false` | Express の `trust proxy`。`false` / アドレス・CIDR のリスト (`10.0.0.0/8,loopback`) / ホップ数 (`1`) / `true` のいずれか。**ロードバランサ配下では必須** — 未設定だと `req.ip` が LB のアドレスになり、IP をキーとする全ての rate limit が全ユーザーで 1 バケットを共有する。`true` ではなく**実際のプロキシを名指しする**こと (#292): `true` は「接続してきた相手が誰であれ、その X-Forwarded-For の先頭を信じる」という意味で、プロセスに直接到達できる相手が自分で `req.ip` を選べてしまう。詳細は [Multi-replica deployments](#multi-replica-deployments) |
240
+ | `HTTP_PORT` | `3000` | サーバーがリッスンするポート。`0` から `65535` の 10 進数字で書く。`0` は空いているポートを OS に選ばせる。空のまま設定すると起動に失敗する |
241
+ | `HTTP_TRUST_PROXY` | `false` | Express の `trust proxy`: `false`、アドレス/CIDR のリスト(`10.0.0.0/8,loopback`)、ホップ数(`1`)、または `true`。**ロードバランサ配下では必須** — 設定しないと、IP をキーとするすべての rate limit が全ユーザーで 1 つのバケットを共有する。`true` よりもプロキシを名指しすることを優先する。[マルチレプリカ構成](#マルチレプリカ構成) を参照 |
242
+ | `HTTP_READINESS_TIMEOUT_MS` | `1000` | `/readyz` の probe ごとの deadline([ヘルスエンドポイント](#ヘルスエンドポイント) を参照) |
243
+ | `LOGGING_LEVEL` | `info` | 出力する最小レベル: `trace`\|`debug`\|`info`\|`warn`\|`error`\|`fatal`\|`silent`。監査ログの出力は左右**しない** — [監査ログ](#監査ログ) を参照 |
46
244
 
47
245
  ### OAuth JWT
48
246
 
247
+ 署名鍵は `key-store` モジュールのセクションである: `key-store.provider`(`KEY_STORE_PROVIDER`、`local`)と、ローカル鍵ストアの `key-store.local`(`KEY_STORE_LOCAL_*`)。issuer とトークンの形式は `oauth.jwt` に残る。
248
+
49
249
  | 変数 | デフォルト | 説明 |
50
250
  |---|---|---|
51
- | `OAUTH_JWT_ALGORITHM` | `EdDSA` | JWT 署名アルゴリズム: `EdDSA` / `ES256` / `RS256` / `HS256`。デフォルトが非対称なので `/.well-known/jwks.json` が実際の検証鍵を公開する。 |
52
- | `OAUTH_JWT_SECRET` | — | 署名シークレット(**HMAC (`HS256`) 専用**)。32 バイト(256 bit)以上のランダム値が必須 — `openssl rand -hex 32`。hex / base64 値は**デコード後**の長さで測るため、32 文字の hex 文字列は 16 バイト扱いで拒否される。 |
53
- | `OAUTH_JWT_ISSUER` | **(必須)** | すべてのトークンの `iss` に刻まれる canonical issuer URL。絶対 `https` URL(`http` は loopback ホストのみ)で、query / fragment を含まないこと。未設定なら起動に失敗する — `Host` ヘッダから導出されることはない。 |
54
- | `OAUTH_JWT_KID` | `v0` | JWT ヘッダーに含まれる key ID |
55
- | `OAUTH_JWT_PRIVATE_KEY` | — | PEM エンコードされた秘密鍵(非対称アルゴリズム用) |
56
- | `OAUTH_JWT_PRIVATE_KEY_PATH` | — | PEM 秘密鍵ファイルのパス |
57
- | `OAUTH_JWT_PUBLIC_KEY` | — | PEM エンコードされた公開鍵 |
58
- | `OAUTH_JWT_PUBLIC_KEY_PATH` | — | PEM 公開鍵ファイルのパス |
59
-
60
- **署名鍵は必須。** デフォルトアルゴリズムは `EdDSA` で、鍵素材のデフォルト値は存在しない。
61
- 何も設定しないデプロイは「推測可能なシークレットで黙って署名する」のではなく、設定すべき
62
- キー名を明示して起動に失敗する。Ed25519 鍵ペアの生成:
251
+ | `KEY_STORE_LOCAL_ALGORITHM` | `EdDSA` | JWT 署名アルゴリズム: `EdDSA` / `ES256` / `RS256` / `HS256`。デフォルトが非対称なので `/.well-known/jwks.json` が実際の検証鍵を公開する。 |
252
+ | `KEY_STORE_LOCAL_SECRET` | — | 署名シークレット(**HMAC (`HS256`) 専用**)。32 バイト(256 bit)以上のランダム値 — `openssl rand -hex 32`。hex / base64 値は**デコード後**の長さで測るため、32 文字の hex 文字列は 16 バイト扱いで拒否される。 |
253
+ | `OAUTH_JWT_ISSUER` | **(必須)** | すべてのトークンの `iss` に刻まれる canonical issuer URL。絶対 `https` URL(`http` は loopback ホストのみ)で、query / fragment を含まないこと。未設定なら起動に失敗する — `Host` ヘッダーから導出されることはない。 |
254
+ | `OAUTH_REQUIRE_EMAIL_VERIFIED` | `false` | Store が `emailVerified: true` を公開するまで、そのユーザーへのトークン発行を拒否する。`/authorize` と `session` グラントで強制される。検証そのものは Store の仕事で、これは結果を読むだけである。 |
255
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ENABLED` | `false` | Client ID Metadata Document を受け付ける(#529): `client_id` が自身の登録情報の `https` URL であるクライアントで、MCP ホストが使うモデルである。このようなクライアントは public で、決してファーストパーティにはならない — 同意ステップを通るため、`ADAPTERS_CONSENT_STORE` を配線すること。 |
256
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_SCOPES` | — | カンマ区切り: そのようなクライアントが取得してよいもの(ドキュメントの `scope` はこれとの積集合を取る)。空なら何も許可しない。 |
257
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_AUDIENCES` | — | カンマ区切り: そのクライアント向けにトークンを発行してよいリソースサーバー(RFC 8707 の `resource`)。空なら自身の `client_id` だけを許可する。 |
258
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_ALLOWED_HOSTS` | — | ドキュメントを置いてよいホスト(カンマ区切り): 完全一致、またはドメインとそのサブドメインを表す `.suffix`。空なら任意の公開ホストを許可する。 |
259
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_DENIED_HOSTS` | — | 上で許可されていても拒否するホスト(カンマ区切り)。 |
260
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_BYTES` | `5120` | ドキュメントのバイト数上限(draft は 5 KB を推奨)。 |
261
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_TIMEOUT_MS` | `5000` | 取得のタイムアウト。 |
262
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_CACHE_MAX_AGE_MS` | `600000` | 有効なドキュメントをキャッシュから返す期間の上限。`Cache-Control: max-age` によって短くなることがある。 |
263
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CACHE_ENTRIES` | `256` | 同時に記憶しておくドキュメントの数。キーは未認証の呼び出し元が選ぶため、マップには上限を設けている。 |
264
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_STALE_IF_ERROR_MS` | `300000` | 検証済みの登録情報を、ドキュメント側に原因の無い理由(DNS の一時的な不調、5xx、タイムアウト)で再検証が失敗した後も返し続ける期間 — 障害で、動いているクライアントを壊すべきではないため。*拒否*されたドキュメントは即座に破棄される。`0` で無効化する。 |
265
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_NEGATIVE_CACHE_MS` | `60000` | 拒否を記憶しておく期間。同じ `client_id` をリクエストのたびに解決・取得し直さないためである。ドキュメントを修正したクライアントが締め出されないよう、短くしてある。 |
266
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CONCURRENT_FETCHES` | `8` | すべての `client_id` を通じて、同時に取得中にできるドキュメントの数。未認証の呼び出し元がこのサーバーに接続させられる量を制限する。 |
267
+ | `KEY_STORE_LOCAL_KID` | `v0` | JWT ヘッダーに含まれる key ID。制御文字を含まない 1〜256 文字。それ以外は起動時に拒否される — export されているが空の変数も含む(以前は kid `""` で署名されていた。そうして発行されたトークンは修正後に拒否されるため、そのユーザーは再ログインになる) |
268
+ | `KEY_STORE_LOCAL_PRIVATE_KEY` | — | PEM エンコードされた秘密鍵(非対称アルゴリズム用) |
269
+ | `KEY_STORE_LOCAL_PRIVATE_KEY_PATH` | — | PEM 秘密鍵ファイルのパス |
270
+ | `KEY_STORE_LOCAL_PUBLIC_KEY` | — | PEM エンコードされた公開鍵 |
271
+ | `KEY_STORE_LOCAL_PUBLIC_KEY_PATH` | — | PEM 公開鍵ファイルのパス |
272
+ | `OAUTH_JWT_LEGACY_TYP_ACCEPT` | `false` | `typ` ヘッダーの無いトークンを受け付ける。`false` ではそれらを拒否し、typ の無いトークンを、たいていそうであるように設定ミスかダウングレードの試みとして扱う。`true` にするのは、v0.4.x のトークンがまだ流通している間の、期限を区切った移行期間に限ること。 |
273
+ | `JWKS_PATH` | `/.well-known/jwks.json` | issuer の下で検証鍵を公開するパスで、discovery が `jwks_uri` として広告するもの(`jwks.path`)。`//`、ドットセグメント、クエリ、フラグメント、バックスラッシュ、パーセントエンコーディング、制御文字を含まない絶対パス。 |
274
+ | `JWKS_CACHE_MAX_AGE` | `300` | JWKS レスポンスの `Cache-Control: public, max-age`(秒、`jwks.cacheMaxAge`)。ローテーションした鍵がキャッシュする検証者に間に合って届くよう、鍵の重複期間より十分短く保つこと。 |
275
+
276
+ **署名鍵は必須。** デフォルトアルゴリズムは `EdDSA` で、鍵素材のデフォルト値は存在しない。何も設定しないデプロイは、推測可能なもので黙って署名するのではなく、設定すべき鍵を名指しして起動に失敗する。Ed25519 鍵ペアの生成:
63
277
 
64
278
  ```bash
65
279
  openssl genpkey -algorithm ed25519 -out jwt-private.pem
66
280
  openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
67
281
  ```
68
282
 
69
- 生成したら `OAUTH_JWT_PRIVATE_KEY_PATH` / `OAUTH_JWT_PUBLIC_KEY_PATH` を指すか、
70
- `OAUTH_JWT_PRIVATE_KEY` / `OAUTH_JWT_PUBLIC_KEY` に PEM をインラインで渡す。
283
+ 生成したら `KEY_STORE_LOCAL_PRIVATE_KEY_PATH` / `KEY_STORE_LOCAL_PUBLIC_KEY_PATH` をそれらに向ける(または `KEY_STORE_LOCAL_PRIVATE_KEY` / `KEY_STORE_LOCAL_PUBLIC_KEY` で PEM をインラインで渡す)。
71
284
 
72
- `HS256` も引き続き選択可能(`OAUTH_JWT_ALGORITHM=HS256` + 32 バイト以上の
73
- `OAUTH_JWT_SECRET`)だが、代償を理解した上で選ぶこと: 対称鍵には公開鍵の片割れが
74
- 存在しないため `/.well-known/jwks.json` は `404 jwks_not_published` を返し、
75
- すべての RP に共有シークレットを渡す必要がある。それは検証だけでなく
76
- **トークンの発行(偽造)** も可能にする鍵である。
285
+ `HS256` も引き続き選択可能(`KEY_STORE_LOCAL_ALGORITHM=HS256` と 32 バイト以上の `KEY_STORE_LOCAL_SECRET`)だが、代償を理解した上で選ぶこと: 対称鍵には公開鍵の片割れが存在しないため `/.well-known/jwks.json` は `404 jwks_not_published` を返し、すべての RP に共有シークレットを渡す必要がある — そしてその共有シークレットは、RP にトークンの検証だけでなく**発行**も可能にしてしまう。
77
286
 
78
287
  ### トークン有効期限
79
288
 
80
289
  | 変数 | デフォルト | 説明 |
81
290
  |---|---|---|
82
291
  | `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` | `3600` | リクエストが有効期間を指定しないときに全グラントが発行するアクセストークンの有効期間(秒)。正の整数、上限は 1 年(`31536000`)。 |
83
- | `OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN` | デフォルトと同じ | token exchange リクエストの `expires_in` パラメータで得られる上限。超えるリクエストはこの値に切り詰められる。未設定ならデフォルトと同じで、設定しない限り延長されない。デフォルトがこれを超えると両キーを名指しして起動失敗。オフラインで検証するリソースサーバーに対し、交換で発行されたトークンが失効後も通用し得る期間の上限でもある。 |
84
- | `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | — | `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` の**非推奨(deprecated)**エイリアス(config キーでは `oauth.accessToken.expiresIn` が `oauth.accessToken.defaultExpiresIn` のエイリアス)。新しい変数が未設定の間だけ読まれる。値は新しい変数へ移すこと。 |
292
+ | `OAUTH_ACCESS_TOKEN_MAX_EXPIRES_IN` | デフォルトと同じ | token exchange リクエストの `expires_in` パラメータで得られる上限。超えるリクエストはこの値に切り詰められる。未設定ならデフォルトと同じで、これを設定しない限りどのトークンも延長されない。デフォルトがこれを超えると、両キーを名指しして起動に失敗する。オフラインで検証するリソースサーバーに対し、交換で発行されたトークンが失効後も通用し得る期間の上限でもある。 |
293
+ | `OAUTH_ACCESS_TOKEN_EXPIRES_IN` | — | `OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN` の**非推奨(deprecated)**エイリアス(config キーでは `oauth.accessToken.expiresIn` が `oauth.accessToken.defaultExpiresIn` のエイリアス)。新しい変数が未設定の間だけ読まれる。これがまだデフォルトを決めている間は、起動時に `config_key_deprecated`(warn)がログに出る。値は新しい変数へ移すこと。 |
85
294
  | `OAUTH_REFRESH_TOKEN_EXPIRES_IN` | `86400` | リフレッシュトークンの有効期間(秒)。正の整数、上限は 1 年(`31536000`)。 |
86
295
 
87
- これらを空文字で export すると fallback ではなく起動失敗になる: HOCON は `FOO=` を
88
- `""` に解決し、それが `0` に coerce され、有効期間 0 は「発行時点で期限切れ」の
89
- トークンを作るため。
296
+ どれも 10 進数字だけを読む。空文字で export すると、フォールバックではなく起動失敗になる(HOCON は `FOO=` を `""` に解決する)。16 進(`0x10`)、指数(`1e3`)、符号(`+5`)、小数(`5.0`)も同じく起動失敗になる。
90
297
 
91
- `expires_in` リクエストパラメータを読むのは token exchange(RFC 8693)だけで、
92
- 他のグラントはそれを無視してデフォルトを発行する。
298
+ `expires_in` リクエストパラメータを読むのは token exchange(RFC 8693)だけで、他のグラントはそれを無視してデフォルトを発行する。
93
299
 
94
300
  ### グラントタイプ
95
301
 
96
302
  | 変数 | デフォルト | 説明 |
97
303
  |---|---|---|
98
- | `OAUTH_GRANTS_SESSION_ENABLED` | `true` | session グラントタイプを有効化 |
99
- | `OAUTH_GRANTS_AUTHORIZATION_CODE_ENABLED` | `true` | authorization code グラントタイプを有効化 |
100
- | `OAUTH_GRANTS_REFRESH_TOKEN_ENABLED` | `true` | refresh token グラントタイプを有効化 |
304
+ | `OAUTH_SESSION_ENABLED` | `true` | session グラントタイプを有効化 |
305
+ | `OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED` | `true` | authorization code グラントタイプを有効化 |
306
+ | `OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED` | `true` | refresh token グラントタイプを有効化 |
307
+ | `OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED` | `false` | client credentials グラントタイプを有効化 |
308
+ | `OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED` | `false` | jwt-bearer グラントタイプ(RFC 7523)を有効化 |
309
+
310
+ スイッチはそれぞれモジュールのキー — `oauth-session.enabled`、ほかは `oauth-authorization.grants.<grant>.enabled` — で、テンプレートはモジュールを選ぶために boot の前に自分のファイルと環境変数からこれを読む。旧名の `OAUTH_GRANTS_<GRANT>_ENABLED` は、単独で、または新名と違う値で設定されていると boot を拒否し、同じ値なら boot する。
101
311
 
102
312
  ### Session
103
313
 
104
314
  | 変数 | デフォルト | 説明 |
105
315
  |---|---|---|
106
- | `SESSION_SECRET` | — | **必須。** 認証済みセッションそのものである Cookie に署名する鍵。推測されればログインを偽造できる。32 バイト(256 bit)以上 — `openssl rand -hex 32`。`OAUTH_JWT_SECRET` と同じく**デコード後**の長さで測る。 |
107
- | `SESSION_MAX_AGE` | `3600000` | セッション Cookie の最大有効期間(ミリ秒)。正の整数、上限は 1 年(`31536000000`)。 |
108
- | `SESSION_SECURE` | `true` | セッション Cookie に `Secure` フラグを設定 |
109
- | `SESSION_SAME_SITE` | `lax` | `SameSite` 属性(`lax`、`strict`、`none`)。`none` は `SESSION_SECURE=true` が**必須** — ブラウザは `Secure` でない `SameSite=None` Cookie を破棄するため、クライアント側で全ログインが無言で失敗するのを避けて起動時に拒否する。 |
110
- | `SESSION_DOMAIN` | — | Cookie ドメイン(デフォルト未設定) |
111
- | `SESSION_CSRF_TTL_SECONDS` | `7200` | 発行する CSRF トークンの有効期間(秒)。1〜86400 の整数で、外れると boot が失敗する(*空文字* は `0` に coerce され、トークン側の判定を無言で無効化してしまうため) |
112
- | `SESSION_STORAGE_TYPE` | `redis` | セッションストアのバックエンド: `redis` または `memory` |
113
- | `SESSION_STORAGE_REDIS_URL` | `redis://localhost:6379` | セッションストア用 Redis 接続 URL |
114
- | `SESSION_STORAGE_REDIS_PASSWORD` | — | セッションストア用 Redis パスワード |
316
+ | `SESSION_STORE_SECRET` | — | **必須。** 認証済みセッション*そのもの*である Cookie に署名する鍵で、推測されればログインを偽造できる。32 バイト(256 bit)以上 — `openssl rand -hex 32`。`KEY_STORE_LOCAL_SECRET` と同じく**デコード後**の長さで測る。 |
317
+ | `SESSION_STORE_NAME` | `__Host-auth.session` | セッション Cookie 名。デフォルトは `__Host-` prefix を使うため、`SESSION_STORE_SECURE=true` と、`SESSION_STORE_DOMAIN` の未設定が必要になる。 |
318
+ | `SESSION_STORE_MAX_AGE` | `3600000` | セッション Cookie の最大有効期間(ミリ秒)。正の整数、上限は 1 年(`31536000000`)。 |
319
+ | `SESSION_STORE_SECURE` | `true` | セッション Cookie に `Secure` フラグを設定 |
320
+ | `SESSION_STORE_SAME_SITE` | `lax` | `SameSite` 属性(`lax`、`strict`、`none`)。`none` は `SESSION_STORE_SECURE=true` が**必須** — ブラウザは `Secure` でない `SameSite=None` Cookie を破棄するため、クライアント側で全ログインが黙って失敗するのを放置せず、起動時にこの組み合わせを拒否する。 |
321
+ | `SESSION_STORE_DOMAIN` | — | Cookie ドメイン(デフォルト未設定) |
322
+ | `SESSION_CSRF_TTL_SECONDS` | `7200` | 発行する CSRF トークンの有効期間(秒)。10 進数字で書いた 1〜86400 の整数で、それ以外なら起動に失敗する(*空文字*や空白だけの値も含む)。 |
323
+ | `SESSION_STORE_STORAGE_TYPE` | `redis` | セッションストアのバックエンド: `redis` または `memory`。`memory` はプロセスごとで、他の in-memory ストアと同様に `CORE_DEPLOYMENT_MODE=multi` のもとでは拒否される(#474) |
324
+ | `SESSION_STORE_STORAGE_REDIS_URL` | `redis://localhost:6379` | セッションストア用 Redis 接続 URL |
325
+ | `SESSION_STORE_STORAGE_REDIS_PASSWORD` | — | セッションストア用 Redis パスワード |
326
+
327
+ cookie とそのストアはセッションストアのセクション `session-store` のもので、CSRF トークンの寿命、ログインページ、ログインの予算は session モジュールのセクション `session` のもの。`SESSION_STORE_*` の旧名 `SESSION_<KEY>` は、単独で、または新名と違う値で設定されていると boot を拒否し、同じ値なら boot する。
328
+
329
+ ローカルの 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 する。
115
330
 
116
331
  #### `/session/login` と `/session/logout` の CSRF 対策
117
332
 
118
- どちらのルートも、**same-origin(もしくは信頼した)`Origin` / `Referer`**、**または** 有効な double-submit CSRF トークンのいずれかを伴うリクエストのみ受理し、どちらも無いものは拒否する(#272)。ブラウザは自動的に条件を満たす。スクリプトのクライアントはまず `GET /session/csrf` を呼び、返ってきた `csrf_token` を `<SESSION_NAME>.csrf` cookie と `x-csrf-token` ヘッダー(または `csrf_token` フォームフィールド)の両方で送り返す。
333
+ どちらのルートも、same-origin(もしくは信頼した)`Origin` / `Referer`、**または**有効な double-submit CSRF トークンの**いずれか**を伴うリクエストを受理し、どちらも伴わないものは拒否する(#272)。ブラウザは自動的にこの条件を満たす。スクリプトのクライアントはまず `GET /session/csrf` を呼び、返ってきた `csrf_token` を `<SESSION_STORE_NAME>.csrf` Cookie と `x-csrf-token` ヘッダー(または `csrf_token` フォームフィールド)の両方で送り返す。
334
+
335
+ 設定上の注意が 2 点ある:
119
336
 
120
- 設定上の注意が 2 点:
337
+ - TLS 終端プロキシの背後では `HTTP_TRUST_PROXY` を設定する(`true` ではなく、プロキシのアドレスまたは CIDR レンジに)。設定しないと `req.protocol` は `http` と読まれる一方でブラウザは `Origin: https://…` を送るため、origin 側の判定がすべてのリクエストを拒否する。
338
+ - ログイン UI をプロバイダーと**別 origin** で配信している場合は、その origin を HOCON 設定の `session.csrf.trustedOrigins` に列挙する。列挙した origin はフェデレーショングラントの同意とデバイス検証にも回答できるので、クライアントの origin は決して載せない(federation-grants ADR の D7)。`http.cors.allowedOrigins` は CSRF 上の信頼を与えない — それが何を与えるかは [CORS](#cors) を参照。
121
339
 
122
- - TLS 終端プロキシの背後では `HTTP_TRUST_PROXY` にそのプロキシのアドレス/CIDR を設定する (例: `HTTP_TRUST_PROXY=10.0.0.0/8`)。設定しないと `req.protocol` は `http` のままでブラウザは `Origin: https://…` を送るため、origin 側の判定が全リクエストを拒否する。`true` は「プロキシ以外が絶対にプロセスへ到達できない」と言い切れる場合にのみ正しい (#292) — 英語版 README も同じ指針。
123
- - ログイン UI をプロバイダーと**別 origin** で配信している場合は、その origin を HOCON の `session.csrf.trustedOrigins` に列挙する。`cors.allowedOrigins` は CSRF 信頼を与えない。
340
+ ### CORS
124
341
 
125
- ### CORS(#500)
342
+ | 変数 | デフォルト | 説明 |
343
+ |---|---|---|
344
+ | `HTTP_CORS_ALLOWED_ORIGINS` | *(空 — CORS 無効)* | token、userinfo、revocation、discovery/JWKS の各レスポンスの読み取りを許可するブラウザ origin のカンマ区切りリスト |
126
345
 
127
- ブラウザーアプリをプロバイダーと別 origin で配信している場合、その origin を `cors.allowedOrigins`(環境変数は `CORS_ALLOWED_ORIGINS`、カンマ区切り)に列挙しないと、SPA は token エンドポイントを呼べない。有効になるのは `/oauth/token`、`/oauth/userinfo`、`/oauth/revoke`、discovery、JWKS の 5 つだけで、`/oauth/introspect`(サーバー間通信)と `/oauth/authorize`(トップレベル遷移)は意図的に対象外。
346
+ このプロバイダーと別の origin から配信されるブラウザアプリ — `https://auth.example.com` のプロバイダーに対する `https://app.example.com` の SPA — は、その origin が `http.cors.allowedOrigins` に無い限り、token エンドポイントをまったく呼べない。設定すると、プロバイダーは、ブラウザがクロスオリジンで呼ぶ正当な理由のある 5 つのエンドポイントで preflight に応答し、`Access-Control-Allow-Origin` を付与する:
128
347
 
129
- `Origin` ヘッダーとの**完全一致**なので、末尾スラッシュ・明示的な `:443`・パス・大文字ホスト・ワイルドカードはいずれも起動時に失敗する(一致しない許可リストが黙って居座るのを防ぐため)。ループバックホストを除き https が必須。
348
+ | エンドポイント | メソッド | 理由 |
349
+ |---|---|---|
350
+ | `/oauth/token` | `POST` | PKCE のコード交換と refresh — SPA が避けられない呼び出し |
351
+ | `/oauth/userinfo` | `GET`, `POST` | OIDC Core §5.3 が両方を定義している |
352
+ | `/oauth/revoke` | `POST` | RFC 7009 §2.1 — public client がサインアウト時に自分のトークンを失効させる |
353
+ | `/.well-known/openid-configuration` | `GET` | Discovery。ブラウザのクライアントライブラリが取得する |
354
+ | `/.well-known/jwks.json` | `GET` | 同上。`jwks.path` を上書きした場合はそれに従う |
130
355
 
131
- `Access-Control-Allow-Credentials` は**決して送らない** — ここでのクロスオリジン SPA は PKCE を使う public client であり cookie は不要で、cookie を渡せば `session` grant に届いてしまう。空リスト(既定)なら CORS は無効で、middleware 自体がマウントされない。詳細は英語版 README の [CORS](README.md#cors) を参照。
356
+ `/oauth/introspect` と `/oauth/authorize` は意図的に**含めていない**。introspection はサーバー間通信で、既に public client を拒否しているため、ブラウザが使うことはあり得ない。`/authorize` は `fetch` ではなくトップレベル遷移であり、ブラウザがどこへ遷移してよいかについて CORS は関与しない。
357
+
358
+ ```hocon
359
+ http.cors {
360
+ allowedOrigins = ["https://app.example.com", "http://localhost:5173"]
361
+ }
362
+ ```
363
+
364
+ または `HTTP_CORS_ALLOWED_ORIGINS=https://app.example.com,http://localhost:5173`。書き方はこの 2 通りで、どちらでもない値 — 数値、オブジェクト、真偽値。設定ファイルでしか書けない形 — は、origin なしとして読まれるのではなく、`http.cors.allowedOrigins` を示して起動時に失敗する。
365
+
366
+ このリストは `http` モジュールのセクションの一部で、デフォルトは `config/reference.conf` にある。`http` モジュールがそれを、信頼するホップとともに `httpSettings` スロットで core の CORS middleware に渡し、core は設定ではなくスロットを読む: `httpSettings` の provider が無い合成は CORS を組み込まない。
367
+
368
+ **照合は `Origin` ヘッダーとの文字列の完全一致である**。一致し得ない形はすべて、誰も許可しないまま設定に居座るのではなく、そのインデックスを名指しして起動時に失敗する。つまり: 末尾スラッシュ(`https://app.example.com/`)、明示的なデフォルトポート(`:443`)、パス、大文字のホストはいずれも不可で、**ワイルドカードも不可** — サブドメインの照合は無く、今後も提供しない。loopback ホスト(`localhost`、`127.0.0.0/8`、`[::1]`)を除き `https` が必須で、この例外は、フロントエンドの開発サーバーを証明書なしで動かせるようにするためのものである。
369
+
370
+ **credentials は決して許可しない。** プロバイダーは `Access-Control-Allow-Credentials` を送らないため、allowlist にある origin はこれらのレスポンスを読めるが、ブラウザのセッション Cookie は使えない。これは意図的である: ここでのクロスオリジン SPA は PKCE を使う public client で Cookie を必要とせず、一方でそうでなければ得られてしまう Cookie は `session` グラント — 認証済みのブラウザセッションをトークンと交換するもの — に届く。アプリがそのグラントを必要とするなら、same-origin で配信するか、前段に BFF を置くこと。
371
+
372
+ これら 5 つのルートのレスポンスは、CORS ヘッダーを持たないものも含めてすべて `Vary: Origin` を持つため、共有キャッシュがある origin のレスポンスを別の origin に渡すことはない。空リスト(デフォルト)では middleware 自体がマウントされない — ヘッダーも `Vary` も付かず、何も変わらない。
132
373
 
133
374
  ### Google フェデレーション
134
375
 
376
+ フェデレーションは core のものである: `core.federations`、各フェデレーションに到達する名前(`/session/oauth/federation/<name>`)をキーとする 1 つのマップ。各キーは、そのパスから名付けた変数 `CORE_FEDERATIONS_<NAME>_<KEY>` に束縛される。トップレベルに書いたマップ(`federations { ... }`)は、各キーの `core.federations` の下のパスを名指しして起動を拒否する。`FEDERATIONS_GOOGLE_*` や `FEDERATIONS_OIDC_*` の変数が単独で、または新しい名前と異なる値で設定されていると、どのモジュールを選ぶよりも前に拒否される。
377
+
378
+ | 変数 | デフォルト | 説明 |
379
+ |---|---|---|
380
+ | `CORE_FEDERATIONS_GOOGLE_ENABLED` | `false` | Google OAuth フェデレーションを有効化 |
381
+ | `CORE_FEDERATIONS_GOOGLE_CLIENT_ID` | — | Google OAuth クライアント ID |
382
+ | `CORE_FEDERATIONS_GOOGLE_CLIENT_SECRET` | — | Google OAuth クライアントシークレット |
383
+ | `CORE_FEDERATIONS_GOOGLE_CALLBACK_URL` | `http://localhost:3000/session/oauth/federation/google/callback` | Google OAuth コールバック URL |
384
+ | `CORE_FEDERATIONS_GOOGLE_ACCESS_TYPE` | 未設定(`offline`) | `offline`: サインインのたびに Google の同意画面を表示し、すべてのセッションがリフレッシュトークンを得る。`online`: 同意画面は初回のサインインだけで、リフレッシュトークンはまったく得ない。[federation-google](../../packages/federation-google/README.md#refresh-tokens-and-the-consent-screen) を参照 |
385
+
386
+ ### OIDC フェデレーション(任意の OpenID Connect プロバイダー)
387
+
388
+ `config/application.conf` の `core.federations.oidc` に 1 つのインスタンスが同梱されている(`@o3co/auth-provider-federation-oidc`、#524): Okta、Entra ID、Auth0、Keycloak、または自前のテナントを issuer で選ぶ。Discovery は起動時に実行され、失敗すれば起動を拒否する。Store に渡される identity は `oidc:<sub>` で、Store が知らない identity は 401 で拒否される — 何もプロビジョニングされない。
389
+
135
390
  | 変数 | デフォルト | 説明 |
136
391
  |---|---|---|
137
- | `FEDERATIONS_GOOGLE_ENABLED` | `false` | Google OAuth フェデレーションを有効化 |
138
- | `FEDERATIONS_GOOGLE_CLIENT_ID` | — | Google OAuth クライアント ID |
139
- | `FEDERATIONS_GOOGLE_CLIENT_SECRET` | — | Google OAuth クライアントシークレット |
140
- | `FEDERATIONS_GOOGLE_CALLBACK_URL` | `http://localhost:3000/session/oauth/federation/google/callback` | Google OAuth コールバック URL |
392
+ | `CORE_FEDERATIONS_OIDC_ENABLED` | `false` | OIDC フェデレーションを有効化 |
393
+ | `CORE_FEDERATIONS_OIDC_ISSUER` | — | issuer 識別子(https)。IdP が `iss` に書くものと完全に同じにする |
394
+ | `CORE_FEDERATIONS_OIDC_CLIENT_ID` | — | IdP に登録したクライアント ID |
395
+ | `CORE_FEDERATIONS_OIDC_CLIENT_SECRET` | — | クライアントシークレット(`client_secret_basic`)。`private_key_jwt` を使う場合は、代わりに設定ファイルで `privateKey` を設定する |
396
+ | `CORE_FEDERATIONS_OIDC_CALLBACK_URL` | `http://localhost:3000/session/oauth/federation/oidc/callback` | IdP がブラウザを戻す先 |
397
+
398
+ 2 つ目の IdP は、`type = "oidc"` と独自のコールバックを持つ別のセクションにする:
399
+
400
+ ```hocon
401
+ core.federations {
402
+ okta {
403
+ enabled = true
404
+ type = "oidc"
405
+ issuer = "https://dev-123.okta.com"
406
+ clientId = ${OKTA_CLIENT_ID}
407
+ clientSecret = ${OKTA_CLIENT_SECRET}
408
+ callbackURL = "https://auth.example.com/session/oauth/federation/okta/callback"
409
+ redirectAllowlist = ["https://app.example.com/welcome"]
410
+ }
411
+ }
412
+ ```
413
+
414
+ エントリの `type` が実装を指定する。`type` の無い `core.federations.google` は組み込みの Google フェデレーションである。`type = "oidc"` を付けると `google` という名前の汎用 OIDC インスタンスになり、組み込みモジュールは合成されない。
415
+
416
+ パッケージが受け付けるすべてのフィールド — `scopes`、`discovery` / `endpoints`、`privateKey`、`userInfo`、`idTokenSignedResponseAlg`、`clockToleranceSeconds` — は [パッケージの README](../../packages/federation-oidc/README.md) に記載されている。
141
417
 
142
418
  ### クライアントリポジトリ
143
419
 
144
420
  | 変数 | デフォルト | 説明 |
145
421
  |---|---|---|
146
- | `CLIENT_TYPE` | `yaml` | クライアントストアのバックエンド: `yaml` |
147
- | `CLIENT_PATH` | `./config/clients.yaml` | YAML クライアントレジストリのパス |
422
+ | `ADAPTERS_CLIENT_REPOSITORY` | `yaml` | クライアントストアのバックエンド: `yaml`、または core の `yaml` の別名で `repositories.client.static.path`(デフォルトなし)を読む `static` |
423
+ | `REPOSITORIES_CLIENT_YAML_PATH` | `./config/clients.yaml` | YAML クライアントレジストリのパス |
424
+
425
+ レジストリはデプロイごとの設定である: `.gitignore` は `config/clients.yaml` を git から、`.dockerignore` はイメージから外し、本番の compose ファイルがそれをマウントする([Docker](#docker) を参照)。
148
426
 
149
427
  ### ユーザーリポジトリ
150
428
 
151
429
  | 変数 | デフォルト | 説明 |
152
430
  |---|---|---|
153
- | `CLIENT_USER_TYPE` | `http` | ユーザーリポジトリのバックエンド: `http` |
154
- | `CLIENT_USER_AUTHENTICATE_URL` | — | パスワード認証用のユーザー認証 URL。**https 必須**(下記参照) |
155
- | `CLIENT_USER_AUTHENTICATE_BY_TOKEN_URL` | — | トークン認証用のユーザー認証 URL。**https 必須**(下記参照) |
156
- | `CLIENT_USER_TIMEOUT` | `5000` | HTTP リクエストタイムアウト(ミリ秒)。`2147483647` 以下の正の整数 |
157
- | `CLIENT_USER_MAX_RESPONSE_BYTES` | `1048576` | 上流レスポンスボディの受け入れ上限(バイト) |
158
- | `CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` | — | 任意。上流 identity の持ち主を Store に問う URL(#613、フェデレーショングラントの check 5)。設定したら Store が cover する registration を `repositories.user.http.federatedIdentityLookupCoverage`(HOCON)に宣言する。**https 必須** |
431
+ | `ADAPTERS_USER_REPOSITORY` | `http` | ユーザーリポジトリのバックエンド: `http`、またはローカル作業用の `yaml`(core の `yaml` の別名 `static` は `repositories.user.static.path` を読み、デフォルトはない) |
432
+ | `REPOSITORIES_USER_YAML_PATH` | `./config/users.yaml` | `yaml` バックエンドが読む YAML のユーザーファイル |
433
+ | `REPOSITORIES_USER_HTTP_AUTHENTICATE_URL` | — | パスワード認証用のユーザー認証 URL。**https 必須**(下記参照) |
434
+ | `REPOSITORIES_USER_HTTP_AUTHENTICATE_BY_TOKEN_URL` | — | トークン認証用のユーザー認証 URL。**https 必須**(下記参照) |
435
+ | `REPOSITORIES_USER_HTTP_LINK_FEDERATED_IDENTITY_URL` | — | 任意。Store がフェデレーション identity を紐づける URL(#482)。フェデレーション開始ルートで `?link=1` を有効にする。**https 必須** |
436
+ | `REPOSITORIES_USER_HTTP_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` | — | 任意。上流 identity の持ち主を Store が答える URL(#613、フェデレーショングラントの check 5)。設定した場合は、Store が cover する範囲を `repositories.user.http.federatedIdentityLookupCoverage`(HOCON)に宣言する。**https 必須** |
437
+ | `REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL` | — | 任意。Store が MFA の登録の証人を保持する URL(MFA ADR の D12)。設定すれば、失われた要素ストアが最初の紐付けを開けない。そのとき Store は二つの認証 URL の両方で `mfaEnrolled` を返す。未設定なら、MFA が有効な起動で警告が出る(`mfa_enrollment_witness_unwritable`)。**https 必須** |
438
+ | `REPOSITORIES_USER_HTTP_BEARER_TOKEN` | — | 任意。このサーバーが Store に提示する資格情報: Store へのすべてのリクエストが `Authorization: Bearer <token>` を持つ。32 バイト以上の素のトークン(`Bearer ` の接頭辞なし)— `openssl rand -hex 32`。未設定なら `Authorization` ヘッダーは送らない(下記参照) |
439
+ | `REPOSITORIES_USER_HTTP_TIMEOUT` | `5000` | HTTP リクエストタイムアウト(ミリ秒)。`2147483647` 以下の正の整数 |
440
+ | `REPOSITORIES_USER_HTTP_MAX_RESPONSE_BYTES` | `1048576` | 上流レスポンスボディの受け入れ上限(バイト) |
441
+
442
+ ユーザー認証 URL は 2 つとも上流ストアへ**平文のユーザー資格情報**を運ぶため、いずれも絶対 `https://` URL でなければならない。`http://` は loopback ホスト(`localhost`、`127.0.0.0/8` 内のアドレス、`[::1]`)に限って許可され、ローカル開発で証明書を用意せずに済むようにしている。プライベートレンジのアドレス(`http://10.0.0.5/…`)やコンテナネットワークのサービス名(`http://user-service/…`)には依然として `https://` が必要 — これらはデプロイが端から端まで制御していないネットワークを越えるため。URL のどちらか、タイムアウト、レスポンス上限のいずれかが使えない値なら、最初のログイン時ではなく起動時に失敗する。
443
+
444
+ 各 URL には、リダイレクトするエンドポイントではなく応答するエンドポイントを設定する: どのリクエストも `3xx` を追わないので、リダイレクトする URL ではすべての呼び出しが失敗する — [Store が自分で守るべきこと](../../packages/foundation/README.ja.md#store-が自分で守るべきこと) を参照。
159
445
 
160
- ユーザー認証 URL は 2 つとも上流ストアへ**平文のユーザー資格情報**を運ぶため、いずれも絶対 `https://` URL でなければならない。`http://` は loopback ホスト(`localhost`、`127.0.0.0/8` 内のアドレス、`[::1]`)に限って許可され、ローカル開発で証明書を用意せずに済むようにしている。プライベートレンジのアドレス(`http://10.0.0.5/…`)やコンテナネットワークのサービス名(`http://user-service/…`)には `https://` が必要 — これらはデプロイが端から端まで制御していないネットワークを越えるため。URL・タイムアウト・レスポンス上限のいずれかが不正なら、最初のログイン時ではなく起動時に失敗する。
446
+ **Store を誰が呼べるか。** トークンによるログインとアカウントリンクが送るものは秘密ではなく識別子なので、誰にでも応答する Store は、そこに届く者なら誰にでも、既知の ID をそのユーザーに解決し — あるいは任意のアカウントにリンクし — てしまう。`REPOSITORIES_USER_HTTP_BEARER_TOKEN` を設定し、Store は `Authorization` が `Bearer <そのトークン>` と正確に一致しないすべてのリクエストに、`401` と `WWW-Authenticate: Bearer error="invalid_token"`(または `403` と `error="insufficient_scope"`)を返す。一つのトークンが上のすべての Store の URL に送られるので、それらのエンドポイントは一つの信頼境界でなければならない。`ADAPTERS_USER_REPOSITORY=http` — このテンプレートのデフォルト — のもとでは、トークンが 32 バイトより弱い(`SESSION_STORE_SECRET` と同じ測り方)、形が誤っている、または空で export されている場合は起動に失敗する。トークンはこのサーバーが投げるどのエラーにも現れない。そのチャレンジがあれば、Store が受け付けないトークンはすべての Store 呼び出しで障害になる: ログインは `503 temporarily_unavailable` を返し、拒否された資格情報を示すエラーをログに出す(トークンは示さない)。federation-grants の接続も同じエラーを `federation_grant_callback_unavailable` としてログに出す。チャレンジの無い `401` や `403` は従来の意味を保つ — ログインでは「ユーザーが居ない」、`?link=1` のリンクでは拒否、federation-grants の ID の照会では他の `2xx` 以外と同じく障害 — ので、不一致はすべてのログインの失敗とすべてのリンクの拒否としてしか現れない。そして、ユーザーのパスワード誤りや拒否するリンクに `Bearer` チャレンジを付けてはならない。Store に届かない、完全な応答の前に接続を閉じる、または壊れた HTTP の応答を返す場合はそのようにログに出され、運ぶのはせいぜい通信のコード(`ECONNREFUSED`、`UND_ERR_SOCKET`、`ERR_SSL_WRONG_VERSION_NUMBER` など)だけで、通信が引用したものは決して含まない。トークンを使わない場合は、ネットワークポリシーかプラットフォームの相互 TLS で、このサーバーだけが Store に届くようにする。ローテーション、各呼び出し元がログに出すもの、Store が検査することは、同じ foundation README の節にある。
161
447
 
162
448
  ### コードリポジトリ
163
449
 
450
+ `adapters.codeRepository`(`ADAPTERS_CODE_REPOSITORY`)がリポジトリを選び、それぞれが自身のセクションを読む。Redis のものは `REDIS_CLIENTS_URL` が設定する共有接続の上で動くので、自身の URL やパスワードは持たない。
451
+
452
+ | 変数 | デフォルト | 説明 |
453
+ |---|---|---|
454
+ | `REDIS_CODE_REPOSITORY_DEFAULT_EXPIRES_IN` | `600` | `redis-code-repository.defaultExpiresIn`: Redis のリポジトリの認可コードのデフォルト有効期間(秒) |
455
+ | `REDIS_CODE_REPOSITORY_KEY_PREFIX` | `oauth:code:` | `redis-code-repository.keyPrefix`: Redis のリポジトリのキー名前空間([Redis の名前空間](#redis-の名前空間) を参照) |
456
+ | `STANDALONE_IN_MEMORY_CODE_REPOSITORY_DEFAULT_EXPIRES_IN` | `600` | `standalone-in-memory-code-repository.defaultExpiresIn`: in-process のリポジトリのもの。正の整数(秒) |
457
+
458
+ `CLIENT_CODE_DEFAULT_EXPIRES_IN` と `CLIENT_CODE_KEY_PREFIX` はこれらとともに改名され、`CLIENT_CODE_ENDPOINT_URI` と `CLIENT_CODE_PASSWORD` は削除された。改名されたものが単独で、または新しい名前と異なる値で設定されていると、そして削除されたものが設定されていると、代わりに設定すべきものを名指しして起動に失敗する。
459
+
460
+ ### フェデレーショントークンストア
461
+
462
+ セッションに代わって保持する上流 IdP のトークン(たとえば Google のリフレッシュトークン)。デフォルトは memory で、レプリカごとに分岐し、`CORE_DEPLOYMENT_MODE=multi` のもとでは拒否される。Redis ストアは `REDIS_CLIENTS_URL` で設定したソケットを共有し、保存時にレコードを暗号化するため、鍵が必要になる。
463
+
164
464
  | 変数 | デフォルト | 説明 |
165
465
  |---|---|---|
166
- | `CLIENT_CODE_TYPE` | `redis` | 認可コードストアのバックエンド: `redis` |
167
- | `CLIENT_CODE_ENDPOINT_URI` | — | コードストア用 Redis 接続 URI |
168
- | `CLIENT_CODE_PASSWORD` | — | コードストア用 Redis パスワード |
169
- | `CLIENT_CODE_DEFAULT_EXPIRES_IN` | `600` | 認可コードのデフォルト有効期間(秒) |
466
+ | `ADAPTERS_FEDERATION_TOKEN_STORE` | `memory` | フェデレーショントークンストアのバックエンド: `memory` または `redis` |
467
+ | `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_KEY` | — | 保存時のレコード暗号化に使う AES-256-GCM 鍵: 32 バイト、base64 エンコード(`openssl rand -base64 32`)。下のモードが `allow-plaintext` でない限り、`redis` では**必須** |
468
+ | `REDIS_FEDERATION_TOKEN_STORE_ENCRYPTION_MODE` | `required` | `required` または `allow-plaintext`。平文は、config が production/staging 環境(`CONFIG_ENV` または `NODE_ENV`)によって選択された場合と、任意の環境での `CORE_DEPLOYMENT_MODE=multi` のもとでは拒否される。ただし `FEDERATION_TOKENS_ALLOW_INSECURE=1` も設定されている場合を除く — 開発専用 |
469
+
470
+ `ttl`(秒。上流のリフレッシュトークンの有効期間より大きくしておくこと)と #291 の `scanFallback` 移行フラグは、環境変数ではなく config レイヤーの `redis-federation-token-store` の下に置く。
170
471
 
171
- ### フェデレーショングラント(#593)
472
+ ### 同意ストア
172
473
 
173
- 上流 IdP のトークンをオフラインで委譲する仕組み。ユーザーが一度同意すると、クライアント(バックエンドやエージェント)は本人不在のまま、後から HTTP で上流のアクセストークンを取得できる。グラントは**ログアウトを越えて残り**、資格情報の変更(subject-revocation service)で終わる。既定はオフで、オフなら何もインストールされない。ルートと各応答の意味は[パッケージ README](../../packages/federation-grants/README.md)、IdP ごとに必要な設定は [`docs/offline-access.md`](../../packages/federation-grants/docs/offline-access.md) を参照。
474
+ ファーストパーティでないクライアントに対するエンドユーザーの同意と、同意ページが確認している間保留される `/authorize` リクエストを記録する場所(#527、#552)。1 つのスイッチで両方を配線する。デフォルトは `none` で、そのようなクライアントは拒否され、ファーストパーティのクライアントだけが受け付けられる。`memory` はレプリカごとに分岐し、`CORE_DEPLOYMENT_MODE=multi` のもとでは拒否される。`redis`(#561)は `REDIS_CLIENTS_URL` で設定したソケット上で両方を共有する。
174
475
 
175
476
  | 変数 | デフォルト | 説明 |
176
477
  |---|---|---|
177
- | `FEDERATION_GRANTS_ENABLED` | `false` | ルート、下記 2 つのストア、subject-revocation service をインストールする |
178
- | `FEDERATION_GRANTS_CONSENT_URL` | — | グラント用の同意ページ(デプロイ側が用意する)。パス、またはプロバイダー origin 上の絶対 URL。既定値なし — 有効化は「ページがある」という宣言で、無ければ起動を拒否する |
179
- | `FEDERATION_GRANTS_IDENTITY_LOOKUP` | `required` | connect callback で、別ローカルユーザーに紐づいた上流アカウントを拒否するか。`required` は各 connection の registration を cover するユーザーリポジトリが必要(下記)。`unsupported` は「この検査をしない」という記録された決定 |
180
- | `FEDERATION_GRANT_STORE_ADAPTER` | `redis` | グラントの保存先: `memory`(1 レプリカ、再起動で消え全ユーザーが再接続)/ `redis`(共有ソケット) |
181
- | `FEDERATION_GRANT_INTENT_STORE_ADAPTER` | `redis` | 取得フローの記録(intent、同意チャレンジ、connect トランザクション)の保存先: `memory`(1 レプリカ、再起動で進行中のフローだけ失う)/ `redis` |
182
- | `FEDERATION_GRANTS_ENCRYPTION_MODE` | `required` | `required` / `allow-plaintext`。平文は production/staging と `DEPLOYMENT_MODE=multi` で拒否される(`FEDERATION_TOKENS_ALLOW_INSECURE=1` を除く) |
183
- | `FEDERATION_GRANTS_ALLOW_KEEP_ON_SUBJECT_REVOCATION` | `false` | subject 全体の失効で、確立済みグラントを残すよう*求められる*ことを許すか。許可であって指示ではない |
478
+ | `ADAPTERS_CONSENT_STORE` | `none` | ファーストパーティでないクライアントのための同意ストア(#527): `none`(そのようなクライアントは拒否される)、`memory`(単一レプリカ)、または `redis`(共有、#561) |
479
+
480
+ ### 多要素認証
481
+
482
+ パスワードログインの後の第二要素と、relying party が `/authorize` で求めたときのステップアップ — [MFA パッケージ](../../packages/mfa/README.md)。`MFA_MODE` はテンプレート自身のスイッチ `mfaMode`([`config/reference.conf`](config/reference.conf))である: デフォルトの `required`(すべてのパスワードログインに第二要素がある)と `optional`(要素を持つユーザーには求め、誰にも強制しない)は、MFA モジュール、TOTP・リカバリーコード・メールの各要素(メールは `MFA_EMAIL_FACTOR_ENABLED` が無ければ off)、オペレーターによるリセット(`handle.components.mfaReset`)、session パッケージのログイン完了、`adapters` が選ぶ 2 つの MFA ストアを組み込む。そのときテンプレートは `core.sessionRequirements.expected` に `mfa` を加え、それを `core.sessionRequirements.secondFactorAuthority` に書き、`mfa.mode` をスイッチから書く。したがって、第二要素を求めながらそれを強制できない合成は、パスワードだけでサインインさせるのではなく boot で拒否される。`off` は MFA を何も組み込まない。3 つのどれでもない値は、`mfaMode` と `MFA_MODE` を名指して boot の前に拒否される。
483
+
484
+ **デフォルトで有効。** development(`make dev`)では MFA にほかに何も要らない: `config/development.conf` のサンプル鍵、各コードをログに出す送信者、2 つのストアには `docker-compose.yml` の Redis を使う。development 以外では、デプロイは MFA に要るもの — 2 つのストアを `redis`(または要素を `store`)、`MFA_ENCRYPTION_KEY`、SMTP リレー(`STANDARD_SMTP_MAIL_SENDER_HOST` と `_FROM`、または `buildModules` の `mailSenderModules` で渡す独自の送信者) — を設定するか、`MFA_MODE=off` を設定する。決めるまでは、アプリが作られる前に boot が拒否される: 最初に来るのはストアの拒否で、そのそれぞれと `MFA_MODE=off` を名指す。MFA ページが拒否されることは無い: `MFA_PAGE_URL` のデフォルトは `/mfa` で、デプロイは development でもそこでページを用意しなければならない。無ければ、すべてのパスワードログインはそこで止まる。
485
+
486
+ | 変数 | デフォルト | 説明 |
487
+ |---|---|---|
488
+ | `MFA_MODE` | `required` | `mfaMode`: `required`、`optional` または `off` |
489
+ | `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` のもとでは拒否される |
490
+ | `MFA_PAGE_URL` | `/mfa` | デプロイの MFA ページ。ログインの第二要素とステップアップはここから始まる。テンプレートはページを同梱しない: ページの契約は [MFA パッケージのもの](../../packages/mfa/README.md#the-routes) |
491
+ | `MFA_STORE_TIMEOUT_MS` | `5000` | `mfa.storeTimeoutMs`、Store の呼び出し 1 回の時間: 各ストアの呼び出しごとのタイムアウト以上でなければならない。Store を呼ぶ構成(`ADAPTERS_USER_REPOSITORY=http`、または要素を Store に置く)で `REPOSITORIES_USER_HTTP_TIMEOUT` を下回ると boot を拒否するので、二つは一緒に上げる。37500 ms を超えても拒否する |
492
+ | `STANDARD_SMTP_MAIL_SENDER_HOST` | — | development 以外での SMTP リレー。MFA はここにアカウントのメールの証明とメール要素のコードを送る。MFA が有効なら、boot にはこれと `STANDARD_SMTP_MAIL_SENDER_FROM` が要る |
493
+ | `STANDARD_SMTP_MAIL_SENDER_FROM` | — | そのメールの唯一の送信者アドレス |
494
+ | `STANDARD_SMTP_MAIL_SENDER_PORT` | `587` | リレーのポート |
495
+ | `STANDARD_SMTP_MAIL_SENDER_SECURE` | `starttls` | `starttls`、`tls`、またはループバックのリレーに限り `none` |
496
+ | `STANDARD_SMTP_MAIL_SENDER_USER` | — | リレーのアカウント。`STANDARD_SMTP_MAIL_SENDER_PASSWORD` とともに使う |
497
+ | `STANDARD_SMTP_MAIL_SENDER_PASSWORD` | — | そのアカウントのパスワード |
498
+
499
+ 残りの MFA の設定 — 各要素のもの、ロック、トランザクションの寿命 — は MFA パッケージのもので、その変数とともに [README](../../packages/mfa/README.md#configuration) にある。知っておくべきこと:
500
+
501
+ - **ストア。** 同梱の選択である `memory` は development と test のためだけのものである: 再起動ですべての要素、ロック、記録したメールの証明を失い、その後はパスワードを持つ者が自分の要素を紐付けられる。MFA が有効なら、設定の名前と、設定されていれば `CONFIG_ENV` と `NODE_ENV` のそれぞれが `development` か `test` でない限り boot の前に拒否され、`CORE_DEPLOYMENT_MODE=multi` のもとでは core が拒否する。本番には両方に `redis`、または要素に `store` が要る。`redis` は `REDIS_CLIENTS_URL` が開くソケットを共有する。`store`(要素のみ)は Store の 4 つのエンドポイント(`FOUNDATION_MFA_FACTOR_STORE_*`、[foundation の README](../../packages/foundation/README.md))の向こうに保持し、ユーザーリポジトリと同じ bearer トークンを送る。
502
+ - **登録の証人。** `ADAPTERS_USER_REPOSITORY=http` なら `REPOSITORIES_USER_HTTP_MARK_MFA_ENROLLED_URL` を設定する([ユーザーリポジトリ](#ユーザーリポジトリ))。無ければ boot は一度警告する(`mfa_enrollment_witness_unwritable`)。
503
+ - **`acr`。** MFA が有効なら、テンプレートは `oauth.authorize.acrValues` に `"urn:o3co:acr:mfa" = ["mfa"]` を加え(設定がそのエントリを書いていなければ)、ディスカバリーがそれを広告する。MFA が off なら何も加えない: acr の表とディスカバリーは MFA が無いときと同じである。
504
+ - **development でのメール。** `CONFIG_ENV=development` では、テンプレートの送信者は各コードをログに出す(`mail_code_issued`)だけで何も送らない。SMTP 送信者を動かし、送ったものを読むには、[Docker](#docker) の Mailpit オーバーレイを使う。
505
+ - **スイッチは一つ。** `MFA_MODE` が別の値に設定されているのにファイルが `mfaMode` を書くこと、また設定がスイッチと違う `mfa.mode` を書くことは、キーを名指して boot の前に拒否される。MFA が off の間、設定が `mfa` の下に書くものは何も boot に渡されない。
506
+
507
+ ### フェデレーショングラント
508
+
509
+ 上流 IdP のトークンのオフライン委譲(#593): ユーザーは、あるクライアント — バックエンドやエージェント — が 1 つの上流 connection についてユーザーに代わってアクセストークンを取得してよいことに一度だけ同意し、クライアントは後から、ユーザー不在のまま HTTP でそれを取得する。グラントは**ログアウトを越えて残り**、資格情報の変更が subject-revocation service を通じてそれを終わらせる。デフォルトはオフで、オフなら何もインストールされない。ルートと各応答の意味は [パッケージの README](../../packages/federation-grants/README.md) に、各 IdP がリフレッシュトークンを発行する前に必要とするものは [`docs/offline-access.md`](../../packages/federation-grants/docs/offline-access.md) にある。
510
+
511
+ | 変数 | デフォルト | 説明 |
512
+ |---|---|---|
513
+ | `FEDERATION_GRANTS_ENABLED` | `false` | ルート、下記で選んだ 2 つのストア(選ぶまではなし)、subject-revocation service をインストールする |
514
+ | `FEDERATION_GRANTS_CONSENT_URL` | — | グラント用の、デプロイ側の同意ページ: パス、またはプロバイダーの origin 上の絶対 URL。デフォルトは無い — グラントの有効化はページが存在するという表明であり、無ければ起動を拒否する |
515
+ | `FEDERATION_GRANTS_IDENTITY_LOOKUP` | `required` | connect callback が、既に別のローカルユーザーに紐づいた上流アカウントを拒否するかどうか。`required` には、すべての connection の registration を cover するユーザーリポジトリが必要(下記)。`unsupported` はこの検査を行わないことを記録する |
516
+ | `ADAPTERS_FEDERATION_GRANT_STORE` | `none` | グラントの保存先: `none`(ストアなし。機能を有効にするとそれを名指しして起動が拒否される)、`memory`(1 レプリカ。再起動で失われ、全ユーザーが再接続する)または `redis`(共有ソケット) |
517
+ | `ADAPTERS_FEDERATION_GRANT_INTENT_STORE` | `none` | 取得フローの記録 — バックエンドが登録した intent、同意チャレンジ、connect トランザクション — の保存先: `none`(ストアなし。機能を有効にするとそれを名指しして起動が拒否される)、`memory`(1 レプリカ。再起動で失うのは進行中のフローだけ)または `redis` |
518
+ | `REDIS_FEDERATION_GRANT_STORE_ENCRYPTION_MODE` | `required` | `required` または `allow-plaintext`。`FEDERATION_TOKENS_ALLOW_INSECURE=1` でない限り、平文は production/staging と `CORE_DEPLOYMENT_MODE=multi` のもとでは拒否される |
519
+ | `FEDERATION_GRANTS_ALLOW_KEEP_ON_SUBJECT_REVOCATION` | `false` | subject 全体の失効に、確立済みのグラントを残すよう*求めて*よいかどうか。許可であって指示ではない |
184
520
  | `REDIS_FEDERATION_GRANT_STORE_KEY_PREFIX` | `fg:` | Redis グラントストアのキー名前空間 |
521
+ | `REDIS_FEDERATION_GRANT_INTENT_STORE_KEY_PREFIX` | `fg:` | Redis インテントストアのキー名前空間。グラントストアのものと一緒に設定する。グラントストアのものを設定してこちらを既定値のままにすると、テンプレートは起動を拒否する |
185
522
 
186
- **環境変数の形を持たないものが 2 つ**ある — connection と暗号鍵リング。リストは HOCON のものなので、デプロイ側のレイヤー(例: `config/production.conf`)に書く:
523
+ 機能自身の設定は `federation-grants {}` セクション、Redis グラントストアの設定 — 鍵リング、暗号化モード、prefix — は `redis-federation-grant-store {}` にある。**環境変数の形を持たないものが 2 つある** — リストは HOCON のものだからである: connection と、暗号鍵リング。デプロイ側が所有するレイヤー — たとえば `config/production.conf` — に書く:
187
524
 
188
525
  ```hocon
189
- federationGrants {
526
+ redis-federation-grant-store {
190
527
  encryptionKeys = [
191
- # 先頭の鍵で封じ、列挙した全鍵で開く。GRANT_KEY_2026_09 は自分で決める名前
192
- # (openssl rand -base64 32) で、テンプレートの override ではない。
528
+ # The first key seals; every listed key opens. GRANT_KEY_2026_09 is a name
529
+ # you choose (openssl rand -base64 32), not a template override.
193
530
  { id = "2026-09", key = ${GRANT_KEY_2026_09} }
194
531
  ]
532
+ }
533
+
534
+ federation-grants {
195
535
  connections {
196
536
  files {
197
- federation = "entra-files" # 有効な type = "oidc" の federations.<name>。専用の app registration を持つ
537
+ federation = "entra-files" # an enabled core.federations.<name> of type "oidc", with an app registration of its own
198
538
  scopes = ["openid", "profile", "offline_access", "Files.Read"]
199
539
  boundary = "production"
200
540
  maxAccessTokenLifetime = 3600
201
- # グラントフロー自身の callback(このプロバイダーの origin 上)。federation のログイン callback ではない
541
+ # The grant flow's own callback on this provider's origin — not the
542
+ # federation's login callback.
202
543
  callbackURL = "https://auth.example/session/federation-grants/callback/files"
203
544
  allowScopeSubsets = false
204
545
  identityClaims = ["oid", "tid"]
@@ -207,20 +548,20 @@ federationGrants {
207
548
  }
208
549
  ```
209
550
 
210
- **起動時に拒否されるもの**(ユーザーがフロー途中で出会う代わりに、名指しで): 同意ページ未設定 / `callbackURL` のない connection、無効な federation を指す connection、委譲 capability を持たないアダプターの federation(持つのは汎用 OIDC アダプターだけ — Google の connection は `type = "oidc"` の federation にする)/ `required` なのに connection の registration を cover しないユーザーリポジトリ / Redis グラント + memory のユーザーセッションストア(`USER_SESSION_STORES_ADAPTER=memory` — グラントがそれを終わらせる境界より長生きしてしまう。compose 2 本は `redis` を設定済み)/ Redis グラントストアで `encryptionMode = "required"` なのにリングに鍵がない(memory ストアは何も封じないので鍵不要)/ `DEPLOYMENT_MODE=multi` でどちらかのストアが `memory`。
551
+ **起動時に拒否されるもの**(ユーザーがフローの途中で出くわすのではなく、それぞれ名指しで): 同意ページが無い;`callbackURL` の無い connection、有効になっていない federation 上の connection、または委譲 capability を持たないアダプターの federation 上の connection — それを持つのは汎用 OIDC アダプターだけなので、Google の connection は `type = "oidc"` の federation にする;`required` なのに connection の registration を cover しないユーザーリポジトリ;memory のユーザーセッションストアと並ぶ Redis のグラント(`ADAPTERS_USER_SESSION_STORES=memory` — グラントが、それを終わらせる境界より長生きしてしまう。compose ファイルは 2 つとも `redis` を設定している);`encryptionMode = "required"` なのにリングに鍵が無い Redis グラントストア(memory ストアは何も封じないので鍵を必要としない);`CORE_DEPLOYMENT_MODE=multi` のもとで、いずれかのストアが `memory`。
211
552
 
212
- **ユーザーリポジトリ。** `required` では、起動時に各 connection の registration を cover するかをリポジトリに問い、connect callback で上流アカウントの持ち主を問う。テンプレート既定の `http` リポジトリはこれを Store に問う(#613): `CLIENT_USER_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` にエンドポイントを設定し、そのエンドポイントが cover するもの(registration ごとに 1 エントリ、Store の戦略が必要とする claim 付き)を HOCON レイヤーに宣言する:
553
+ **ユーザーリポジトリ。** `required` のもとでは、起動時に各 connection の registration を cover するかをリポジトリに問い、connect callback は上流アカウントの持ち主をリポジトリに問う。テンプレートの `http` リポジトリはそれを Store に問う(#613): `REPOSITORIES_USER_HTTP_FIND_SUBJECT_BY_FEDERATED_IDENTITY_URL` にエンドポイントを設定し、そのエンドポイントが cover するもの — registration ごとに 1 エントリ、Store の戦略が必要とする claim 付き — を HOCON レイヤーに宣言する:
213
554
 
214
555
  ```hocon
215
556
  repositories.user.http.federatedIdentityLookupCoverage = [
216
557
  { provider = "entra-files", issuer = "https://login.microsoftonline.com/<tenant>/v2.0",
217
- clientId = "<グラント用 app registration>", requiredClaims = ["tid", "oid"] }
558
+ clientId = "<the grants app registration>", requiredClaims = ["tid", "oid"] }
218
559
  ]
219
560
  ```
220
561
 
221
- 起動時にすべての connection がこれと突き合わされる: registration が宣言されていない、または `identityClaims` に必要な claim が欠けている connection は名指しで拒否される。Store が実装する wire 契約と、`unlinked` と答えるために何を確認していなければならないかは [foundation README](../../packages/foundation/README.md#the-identity-lookup-613) にある。起動時に Store は問われない。同梱の in-memory リポジトリはどの registration も cover しない。cover する Store が無いなら `FEDERATION_GRANTS_IDENTITY_LOOKUP=unsupported`(別ローカルユーザーが既に持つ上流アカウントを拒否しない、という記録された決定)にする。connection が無ければ何も要求されない。
562
+ 起動時にはすべての connection がこれと突き合わされる: registration が宣言されていない connection、または `identityClaims` に必須の claim が欠けている connection は名指しで拒否される。Store が実装する wire 契約 — そして「unlinked」と答えるために何を確認済みでなければならないか — は [foundation README](../../packages/foundation/README.md#the-identity-lookup-613) にある。起動時に Store は問われない。同梱の in-memory リポジトリはどの registration も cover しない。cover する Store が無い場合は `FEDERATION_GRANTS_IDENTITY_LOOKUP=unsupported` を設定する — 別のローカルユーザーが既に持つ上流アカウントを拒否しない、という記録された決定である。connection を 1 つも設定していなければ、何も要求されない。
222
563
 
223
- **クライアント登録。** `config/clients.yaml` で、グラントを持てる confidential クライアントに、要求できる connection とブラウザの戻り先を書く。`allowedRedirectUris` や `firstParty` はどちらも代わりにならない:
564
+ **クライアントの登録。** `config/clients.yaml` で、グラントを保持してよい confidential クライアントには、要求してよい connection と、ブラウザを戻してよい先を列挙する。`allowedRedirectUris` も `firstParty` も、そのどちらも与えない:
224
565
 
225
566
  ```yaml
226
567
  worker:
@@ -230,43 +571,64 @@ worker:
230
571
  federationGrantRedirectUris: ["https://worker.example/connected"]
231
572
  ```
232
573
 
233
- **クライアント側から見たフロー。** `POST /oauth/federation-grants`(クライアント認証)で `sub` に対する intent を登録すると `grant_id` と `connect_uri` が返るので、ユーザーのブラウザをそこへ送る。プロバイダーは必要ならログインさせ、同意ページを見せ、上流へ送り、`grant_id` と `state` を付けてクライアントの `redirect_uri` に戻す — トークンは決して載らない。以後クライアントは自身の資格情報とユーザーの `sub` で `/oauth/federation-grants/:grantId/token` / `/status` / `/revoke` を呼ぶ。本人は不在でよい。
574
+ **クライアント側から見たフロー。** `POST /oauth/federation-grants`(クライアント認証付き)は `sub` に対する intent を登録し、`grant_id` と `connect_uri` を返す。クライアントはユーザーのブラウザをそこへ送る。プロバイダーは必要ならユーザーをサインインさせ、デプロイ側の同意ページを見せ、上流へ送り、`grant_id` と `state` を付けてブラウザをクライアントの `redirect_uri` に戻す — トークンは決して載せない。その後クライアントは、自身の資格情報とユーザーの `sub` を使い、ユーザー不在のまま `/oauth/federation-grants/:grantId/token`、`/status`、`/revoke` を呼ぶ。
575
+
576
+ **同意ページ**はデプロイ側のもので、プロバイダーと same-origin であり、その契約は `/oauth/consent` のものと同じなので、1 つのページで両方を担える。`GET /session/federation-grants/consent?challenge=…` は JSON を返す: `client_id`、`client_name`、`connection`、`scopes`、`resource`、`grant_expires_in`(承認後の期間であって日付ではない)、`continues_after_logout`(ページが必ず表示すること)、`expires_in`(フローの残り時間)。`challenge` と `decision`(`accept` | `deny`)を付けた `POST` は `303` を返す — 上流へ、またはクライアントへ戻す。取得も更新もすべてここを通る。回答は session モジュールの CSRF ポリシーに照らされる: ページからフォームで POST し、ページは `Referrer-Policy: same-origin` で配信する — `no-referrer` ではない(ヘッダー、`<meta name="referrer">`、フォームの `rel="noreferrer"` のいずれによるものも)。その下ではブラウザが `Origin: null` を送り、回答は拒否される。このアプリの `helmet()` はすべての応答に `no-referrer` を付けるので、このアプリが配信するページは自身のルートで `Referrer-Policy: same-origin` を付ける。プロキシの背後では `HTTP_TRUST_PROXY` がそのプロキシを指し、プロキシは `X-Forwarded-Proto` と `X-Forwarded-Host` を転送する(またはブラウザの `Host` を保つ)。`Origin` も `Referer` も送らないユーザーエージェントは、`POST /session/login` と同じく `GET /session/csrf` が渡すトークンを送り返す。トークンはどちらも送られないときにだけ効くので、ページはいつでもトークンを含めてよい。規則はパッケージの README にある。
577
+
578
+ **ログインページ**は `SESSION_LOGIN_PAGE_URL` が指すもので、`redirect_to=<the connect link>` 付きで到達する。ユーザーをサインインさせ、`/oauth/authorize` の場合と同様に、そのリンクへそのまま戻る。そのリンクを `POST /session/login` の `redirect_to` として送信してはならない: そのルートの完全一致 allowlist はランディングページ用で、フローごとの handle は拒否する。
234
579
 
235
- **同意ページ**はデプロイ側のもので、プロバイダーと same-origin。契約は `/oauth/consent` と同じなので 1 ページで両方を担える。`GET /session/federation-grants/consent?challenge=…` は JSON を返す: `client_id`、`client_name`、`connection`、`scopes`、`resource`、`grant_expires_in`(承認後の期間。日付ではない)、`continues_after_logout`(ページが必ず表示するもの)、`expires_in`(フローの残り時間)。`POST` に `challenge` と `decision`(`accept` | `deny`)を送ると `303` — 上流へ、または クライアントへ戻る。取得も更新も必ずここを通る。
580
+ **運用。** 鍵リングは [オペレーター runbook](../../docs/operator-runbook.md) の手順でローテーションする — 新しい鍵をまず末尾に、次に先頭に置き、古い鍵は 365 日残す。この機能が有効な間、シャットダウンは cleanup に 45 秒を与え(`upstreamHardTimeoutMs`、`persistRetryBudgetMs`、`lockWaitMs` を上げればそれより長くなる。allowance はそれらの合計に余裕を加えたものだからである)、compose ファイルは 2 つともプロセスに 60 秒を与える。[シャットダウンの保証](#シャットダウンの保証) を参照。機能の無効化は失効ではない: グラントは失効させるまで Redis に残るので、先に終わらせること。また一時的な停止の間も、鍵と失効境界は保持しておくこと。
236
581
 
237
- **ログインページ**は `ENDPOINTS_LOGIN_URL` のもので、`redirect_to=<connect リンク>` 付きで呼ばれる。ログイン後はそのリンクへそのまま戻す(`/oauth/authorize` と同じ往復)。そのリンクを `POST /session/login` の `redirect_to` に送ってはいけない — あちらの完全一致 allowlist はランディングページ用で、フローごとの handle を拒否する。
582
+ ### Redis の名前空間
238
583
 
239
- **運用。** 鍵リングのローテーションは[オペレーター runbook](../../docs/operator-runbook.md)の手順で(新しい鍵を末尾に → 先頭に、古い鍵は 365 日残す)。シャットダウンは機能が有効なら cleanup に 45 秒を与え(`upstreamHardTimeoutMs` / `persistRetryBudgetMs` / `lockWaitMs` を上げればその合計 + 余裕まで増える)、compose 2 本はプロセスに 60 秒を与える([シャットダウンの保証](#シャットダウンの保証))。無効化は失効ではない: グラントは失効させるまで Redis に残るので、先に終わらせること。一時停止中も鍵と失効境界は保持する。
584
+ マルチテナントの Redis クラスターでは、2 つの auth.provider インスタンスが同じデータベース内で衝突しないよう、デプロイ固有のキー prefix を設定する:
585
+
586
+ | 変数 | デフォルト | 説明 |
587
+ |---|---|---|
588
+ | `REDIS_SESSION_STORES_KEY_PREFIX` | `ss:` | ユーザーセッション、RP レジストリ、session-family インデックス、session-federation インデックスの外側の prefix。 |
589
+ | `REDIS_REFRESH_TOKEN_FAMILY_STORE_KEY_PREFIX` | `rtfam:` | refresh token family レコードの prefix。 |
590
+ | `REDIS_CODE_REPOSITORY_KEY_PREFIX` | `oauth:code:` | OAuth 認可コードの prefix。 |
591
+ | `REDIS_FEDERATION_TOKEN_STORE_KEY_PREFIX` | `ft:` | フェデレーショントークンのレコード、そのセッションごとのインデックス、およびそのロックキーの prefix。 |
592
+ | `REDIS_CONSENT_STORE_KEY_PREFIX` | `consent:` | 同意レコードと、保留中の同意リクエストおよびそのセッションごとのインデックスの prefix(`ADAPTERS_CONSENT_STORE=redis`、#561)。 |
593
+
594
+ デプロイ名を含む値を使う。たとえば `tenant-a:ss:`、`tenant-a:rtfam:`、`tenant-a:code:`、`tenant-a:ft:`、`tenant-a:consent:`。
240
595
 
241
596
  ### エンドポイント
242
597
 
243
598
  | 変数 | デフォルト | 説明 |
244
599
  |---|---|---|
245
- | `ENDPOINTS_LOGIN_URL` | — | ログインページの URL(リダイレクト先) |
246
- | `ENDPOINTS_CLIENT_URL` | — | クライアントアプリケーションの URL |
247
- | `ENDPOINTS_AUTH_CALLBACK_URL` | — | 認可コールバックの URL |
600
+ | `SESSION_LOGIN_PAGE_URL` | `/login` | ログインページの URL(リダイレクト用) |
601
+ | `OAUTH_CONSENT_PAGE_URL` | `/consent` | ファーストパーティでないクライアントが経由させられる同意ページの URL。`?challenge=<id>` 付きで遷移する(#527) |
602
+
603
+ デプロイ全体で共通のクライアント URL やコールバック URL は無い: フェデレーションのコールバック URL はフェデレーションごとに設定する(`CORE_FEDERATIONS_GOOGLE_CALLBACK_URL`、`CORE_FEDERATIONS_OIDC_CALLBACK_URL`)。
248
604
 
249
605
  ## モジュール合成順序
250
606
 
251
- モジュールは以下の順序で登録されます。各モジュールは独自のルートとグラントタイプを登録します。
607
+ どのモジュールをどの順序で合成するかについては、[`src/buildModules.mts`](src/buildModules.mts) が唯一の信頼できる情報源である — そのコピーではなく、それ自体を読むこと。boot planner は `requires` / `provides` をトポロジカルに解決するが、ルートとミドルウェアは、モジュールが `before` / `after` を宣言しない限りリストの順にマウントする — したがって、何かをマウントするモジュールでは位置が意味を持つ。リストを編集するときに守るべきルールは次のとおり:
608
+
609
+ 1. **`sessionStoreModuleFor(config)` は先頭のままにする。** これは `express-session` の middleware をマウントし、`before` / `after` を宣言しないため、`req.session` を読むすべてのルートより前に来るのは、リスト内の位置のおかげである。`config` から組み立てるのは、`session-store.storage.type = "memory"` が自らを replica-unsafe と宣言するようにするためである。
610
+ 2. **`/oauth` の下では順序は関係しない。** `federationGrantsModules`(フェデレーショングラントが有効な間)、`oauthModule`、独自のモジュールは、いずれも `/oauth` の下にルートをマウントしうる。`oauthModule` のルーターが body をパースするのは自身のルートだけなので、各モジュールへのリクエストは、リストの順に関係なくそのモジュール自身の parser に届く — ただし、`/oauth` 配下のどのモジュールも自分の body を自分でパースし、その parser を自分のパスちょうどに限定している場合に限る(同梱のモジュールはそうしている)。限定はルートとして行う(`router.all(path, parser)` か、ルート自身のハンドラ列)。`router.use(path, parser)` は `path` の下のすべてのパスにもマッチする。フェデレーショングラントのブラウザ側の半分は、自身の `after` によってセッション middleware の後ろに自らを並べる。
611
+ 3. **ストアスロット 1 つにつきモジュール 1 つ。** 各アダプタースイッチ — `adapters.federationTokenStore`、`adapters.userSessionStores`、`adapters.rateLimiter`、`adapters.codeRepository`、`adapters.accessTokenDenylist`、`adapters.replaySeenSet`、`adapters.consentStore`、フェデレーショングラントの 2 つのストアスイッチ、MFA の 2 つのストアスイッチ — は、memory / Redis の組(MFA の要素については Store も)から 1 つを選ぶ。両者は同じスロットを提供するため、両方を配線すると起動時のスロット衝突になる。`adapters.consentStore = "none"` はどちらも配線せず、フェデレーショングラントのストアは機能が有効な間だけ、MFA のストアは `MFA_MODE` が MFA を組み込む間だけ配線される。
612
+ 4. **共有 Redis 接続は、最初の Redis バックエンドのモジュールとともに加わる。** `standaloneRedisClientsModule` は、ここにあるすべての Redis アダプターが使う 1 本の ioredis 接続を自身のセクション(`redis-clients`)から開き、合成されたモジュールがそれを必要とするときには必ず追加される。同梱の合成では refresh token family ストアが Redis 上にあるため、デプロイには常にこれがある。in-memory の family ストアはテスト用の override(`overrides.refreshTokenFamilyModules`)である。
613
+ 5. **テンプレート自身の設定モジュールは常に合成される。** `loggingModule` と `httpModule` は `logging {}` と `http {}` を所有し、CORS のリストは `http` のキーの一つ(`http.cors.allowedOrigins`)である。`httpModule` は core の `httpSettings` を提供する。これは authoritative なので、モジュールが読み込まれている間は `overrideComponents` のエントリで置き換えられない。`httpModule` はさらに、`app.mts` が boot の後にポートと readiness の期限を読むテンプレートの `httpHostSettings` を提供する。logger はモジュールではなく、boot の前に `logging` セクションから作られ(`readLogging`)、`logger` コンポーネントとして boot に渡される。
614
+ 6. **フェデレーションアダプターは、その config bridge とともに加わる。** `googleFederationModule` には `googleFederationConfigModule` が伴い — これは有効化され、かつ `type` が `google` である `core.federations.google` エントリに対してのみで、そのため `google` という名前の `type = "oidc"` セクションが二重に合成されることはない — 有効化された `type = "oidc"` のセクションごとに 1 つずつの `oidcFederationModule(name)` には、それらが共有する 1 つの `oidcFederationConfigModule` が伴う。bridge の provider は対応するセクションが無いと throw するため、この組は内部でゲートされるのではなく、合成時に含めるかどうかが決まる。
615
+ 7. **メール送信者は環境に従う。** 設定が `development` として選ばれたところでは `@o3co/auth-provider-standard` の開発用送信者で、これは各コードをログに書く。そのモジュールは、その名前と、設定されていれば `CONFIG_ENV` と `NODE_ENV` のそれぞれが `development` か `test` であるところでだけ入り、それ以外のところ、または `core.deployment.mode` が `multi` のところでは起動を拒否する。それ以外の名前では SMTP 送信者のモジュールで、そのセクションは `standard-smtp-mail-sender`([パッケージの README](../../packages/standard/README.md))。その送信者は `mailSender` スロットを何かが読むところでだけ作られ、そこでは起動に `STANDARD_SMTP_MAIL_SENDER_HOST` と `STANDARD_SMTP_MAIL_SENDER_FROM` が要る。`MFA_MODE` が `off` の間はスロットを読むものが無いので、テンプレートはそれらが無くても起動する。MFA が有効なら、MFA モジュールとオペレーターによるリセットがそれを読む。独自の送信者はこの選択を置き換える: [`src/app.mts`](src/app.mts) が `buildModules` を呼ぶところで、そのモジュールを `buildModules(switches, { environment: env, logger, mailSenderModules: [mySenderModule] })` のように渡す。`mailSenderModules` を渡すと、どの環境名でもそれが入り、同梱の送信者は入らないので、両方がスロットを提供することは無い。空のリストを渡すと送信者は入らず、送信者を必要とするモジュール(MFA のメール要素、または `requireEmailProof = "always"` の MFA モジュール)は起動を拒否する。
252
616
 
253
- 1. **`oauthModule`** — OAuth 2.0 コアエンドポイント: `POST /oauth/token`、`POST /oauth/introspect`、`GET /oauth/authorize`
254
- 2. **`sessionModule`** — セッションベースのログイン・ログアウトおよびフェデレーション: `POST /session/login`、`POST /session/logout`、`GET/POST /session/oauth/federation/*`
255
- 3. **`oauthSessionModule`** — session グラントタイプ(有効なセッションをトークンに交換)
256
- 4. **`oauthAuthorizationModule`** — authorization code および refresh token グラントタイプ
617
+ `jwksModule`(core 由来)は常に合成される: トークンに署名するプロバイダーは、issuer が設定されているかどうかにかかわらず検証鍵を公開する。各ルートモジュールが何をマウントするかは、それぞれのパッケージの README にある。合成時に知っておくべき振る舞いが 1 つある: `sessionModule` の `POST /session/logout` は `UserSession` レコード(これにより `/oauth/introspect` と `/oauth/userinfo` はそのセッションから発行されたトークンを受け付けなくなる)、subject インデックス、フェデレーションのエントリを削除する — しかし refresh token family は失効させ**ない**。完全なカスケードを実行するエンドポイントは `POST /oauth/logout` である。[どのログアウトエンドポイントが何を無効化するか](../../docs/operator-runbook.md#which-logout-endpoint-invalidates-what) を参照。
257
618
 
258
619
  ## 組み込みルート
259
620
 
260
- 以下のルートはモジュールシステムの外部で登録されます。
621
+ `src/routes.mts` はこれらを、合成された auth ルーターより前にホストアプリへマウントする。そのため auth パイプラインが劣化している間も応答し続ける — オペレーターがこれらを必要とするのはまさにそのときである。そしてそれらすべての後ろに core の終端のエラーハンドラーをマウントするので、これらのルートが通してしまったエラーは auth のルートと同じく答えられる(`500 server_error`、`unhandled_request_error` として 1 回ログに出す)。JWKS ルートはこれらに含まれない: それは `jwksModule` が提供する(上記参照)。
261
622
 
262
623
  | メソッド | パス | 説明 |
263
624
  |---|---|---|
264
- | `GET` | `/health` | ヘルスチェックエンドポイント |
265
- | `GET` | `/.well-known/jwks.json` | トークン検証用公開鍵セット(JWKS) |
625
+ | `GET` | `/_healthcheck` | liveness — Redis の状態にかかわらず、プロセスが起動していれば常に `200 {"status":"ok"}`。[ヘルスエンドポイント](#ヘルスエンドポイント) を参照 |
626
+ | `GET` | `/readyz` | readiness — 登録されたすべての依存先 probe(`redis`、`session-store`、…)を `HTTP_READINESS_TIMEOUT_MS` のもとで実行する。`200 {"status":"ready"}` または `503 {"status":"unready"}` で、キャッシュされることはない |
627
+ | `GET` | `/metrics` | Prometheus のテキスト exposition。依存先の gauge はスクレイプのたびに probe し直される。公開 listener には置かないこと — [メトリクス](#メトリクス) を参照 |
266
628
 
267
629
  ## Docker
268
630
 
269
- Dockerfile はマルチステージビルドを使用しており、以下のターゲットがあります。
631
+ Dockerfile はマルチステージビルドを使い、以下のターゲットを持つ:
270
632
 
271
633
  | ターゲット | 用途 |
272
634
  |---|---|
@@ -285,106 +647,237 @@ make dev
285
647
  make test
286
648
  ```
287
649
 
288
- `docker-compose.yml` は認証サーバーと Redis コンテナをまとめて起動します。環境変数は `.env` で設定してください。
650
+ `docker-compose.yml` は認証サーバーと Redis コンテナをまとめて起動する。環境変数は `.env` で設定する。
289
651
 
290
- `docker-compose.yml` は**開発用**です(`develop` target をビルドし `./src` / `./config` を bind-mount するため、そのままデプロイすると working tree を hot-reload で配信します)。デプロイ可能な形は
291
- [`docker-compose.production.yml`](docker-compose.production.yml) — `runtime` target・source mount なし・restart policy・ネットワーク内部限定の永続 Redis・**必須**の `.env`(実際の `SESSION_SECRET` が無い boot は大声で失敗すべき)です。TLS 終端 + `HTTP_TRUST_PROXY`、`--scale` 前の multi-replica 手順など「意図的に委ねる範囲」はファイル冒頭のコメントに明記しています。
652
+ `docker-compose.yml` は**開発用**のファイルである: `develop` ターゲットをビルドし、`./src` と `./config` を bind-mount するため、そのままデプロイすると working tree を動かす hot-reload サーバーを配信することになる。デプロイ可能な形は [`docker-compose.production.yml`](docker-compose.production.yml) である — `runtime` ターゲット、ソースのマウントなし、restart policy、ネットワーク内部限定の永続 Redis、そして**必須**の `.env`(実際の `SESSION_STORE_SECRET` の無い起動ははっきりと失敗しなければならない)。意図的に利用者に委ねている範囲 — 前段での TLS 終端と、`--scale` の前の [マルチレプリカ](#マルチレプリカ構成) 手順 — は、ファイル冒頭のコメントに記載している。
653
+
654
+ そこでは `HTTP_TRUST_PROXY` は明示的な `${HTTP_TRUST_PROXY:?…}` エントリになっているため、`.env` でホップを指定するまで `docker compose up` は**起動を拒否する**。これは意図的である: ファイルがデフォルトにできるアドレスで、自分が選んでいないホップを黙って信頼しないものは存在しない。そしてこの変数が無いと、ファイルが固定している Secure Cookie は一度もセットされず、CSRF の origin チェックはブラウザからのすべての POST を 403 にし、IP をキーとするすべての rate limit が 1 つのバケットを共有する。
655
+
656
+ レコードが 1 プロセスより長く生き残らなければならないストアは、継承に任せるのではなく、すべてそのファイルの `environment:` ブロックで名指しされている — `SESSION_STORE_STORAGE_TYPE=redis` と必ずセットで設定しなければならない `ADAPTERS_USER_SESSION_STORES=redis` も含めて。両者が揃っていないとき、`CORE_DEPLOYMENT_MODE=single` はそれを教えてくれない: レプリカガードが答えるのは「これらのストアは共有できるか」であって、「この 2 つのストアは同じ寿命を持つか」ではない。両者を分けると、再起動後にすべてのブラウザが、生き残った express-session — 背後に `UserSession` が無いのにまだ `isAuthenticated` と読めるもの — を保持したままになる。`/authorize` はログインへ飛ばし、Cookie がそれを送り返し、このループはユーザーが Cookie を削除するまで解消しない。
657
+
658
+ 2 つの MFA ストアは例外である。MFA はデフォルトで有効で、それを残すかはデプロイが決めることなので、本番用のファイルはそれらを `memory` のままにしている。テンプレートは本番ではこれを boot の前に拒否し、ストア、`MFA_ENCRYPTION_KEY`、SMTP リレー、`MFA_PAGE_URL`、`MFA_MODE=off` を名指す。`.env` で設定する — `ADAPTERS_MFA_FACTOR_STORE=redis` と `ADAPTERS_MFA_TRANSACTION_STORE=redis`(そのファイルの永続化された Redis 上)を、[多要素認証](#多要素認証) の残りとともに — か、`MFA_MODE=off` を設定する。`docker-compose.yml` は両方をその Redis に置くので、ホットリロードでセッションが残ったままストアだけが空になることは無い。
292
659
 
293
660
  ```bash
294
- # 署名鍵は必須の入力です。デフォルトは EdDSA で鍵のデフォルト値は存在しないため、
295
- # 鍵を生成していないデプロイは boot で失敗します。
661
+ # 署名鍵は必須の入力である。デフォルトは EdDSA で鍵素材のデフォルト値は存在しないため、
662
+ # 鍵を生成していないデプロイは起動時に失敗する。
296
663
  openssl genpkey -algorithm ed25519 -out jwt-private.pem
297
664
  openssl pkey -in jwt-private.pem -pubout -out jwt-public.pem
298
665
  chmod 600 jwt-private.pem
299
666
 
667
+ # クライアントレジストリも必須の入力: scaffold の config/clients.yaml か、
668
+ # config/clients.yaml.example から作ったもの。
669
+
300
670
  docker compose -f docker-compose.production.yml up -d --build
301
671
  ```
302
672
 
303
- **コンテナへの鍵の渡し方**。pem のペアは compose の **secret** として
304
- `/run/secrets/` に read-only でマウントされ、compose 側が
305
- `OAUTH_JWT_*_KEY_PATH` をそこに向けます。`config/` には置かないでください —
306
- `Dockerfile` がこのディレクトリを `COPY` するため、置いた鍵はイメージレイヤに
307
- 焼き込まれ、push のたびに一緒に運ばれます(保管場所を誤っただけでは済まず、
308
- ローテーションが必要になります)。`.env` も同様に git の外に置いてください。
309
-
310
- production ファイルは `environment:` で `SESSION_SECURE=true` と `__Host-`
311
- cookie 名も固定します(`environment` は `env_file` より優先されます)。
312
- `.env.example` は plain-HTTP のローカル実行のために `SESSION_SECURE=false` を
313
- 出荷しており、このファイルはその同じ `.env` を要求するためです。前段で TLS を
314
- 終端する前提の構成で非 Secure な session cookie を配ることは、平文区間を読める
315
- 相手にセッションを渡すことと同じです。
316
-
317
- イメージは `pnpm install --frozen-lockfile` でインストールするため、コミット済みの
318
- `pnpm-lock.yaml` がビルドの必須入力です。`create-auth-provider` が scaffold 時に
319
- 生成します。手元に無い場合は一度 `pnpm install` を実行して結果をコミットしてください。
320
- これにより同じソースからのリビルドは同じ依存ツリーになります。
673
+ **コンテナ内の鍵。** pem のペアは compose の **secret** としてコンテナに届き、`/run/secrets/` に read-only でマウントされ、compose ファイルが `KEY_STORE_LOCAL_*_KEY_PATH` をそこに向ける。`config/` に置いてはならない: `Dockerfile` はこのディレクトリを `COPY` する。`.dockerignore` は、`.gitignore` が git から外すのと同じく、すべての `*.pem` と `*.key` をビルドコンテキストから外すが、それ以外の名前でそこに置いた鍵はイメージレイヤーに焼き込まれ、push のたびに一緒に運ばれる — 保管場所を誤っただけの鍵ではなく、ローテーションしなければならない鍵になる。`.env` も同様である。どちらも git の外に置くこと。
321
674
 
322
- ## カスタムモジュールの追加
675
+ **コンテナ内のクライアントレジストリ。** `config/clients.yaml` も同じ経路で、compose の secret `client_registry` として届き、`REPOSITORIES_CLIENT_YAML_PATH` がそこを指す。これはデプロイごとのもので、クライアントのシークレットを含みうるため、`.dockerignore` がビルドコンテキストから外す: どのイメージもレジストリを持たず、作業コピーからビルドしたイメージと clean checkout からビルドしたイメージは同じになる。ファイルが存在するまで compose は起動を拒否する。別の方法でイメージを動かすなら、自分でマウントして `REPOSITORIES_CLIENT_YAML_PATH` を設定する。
676
+
677
+ production ファイルは `environment:` ブロックで `SESSION_STORE_SECURE=true` と `__Host-` の Cookie 名も固定しており、これは `env_file` より優先される。`.env.example` は plain-HTTP の compose 実行(`make dev`)が `__Host-` Cookie の検査に引っかからないよう `SESSION_STORE_SECURE=false` を同梱しており、このファイルはその同じ `.env` を要求する — このファイルが前提とする TLS の背後では、非 Secure のセッション Cookie は、平文区間を 1 つでも読める相手にセッションを渡すことに等しい。
678
+
679
+ イメージは `pnpm install --frozen-lockfile` でインストールするため、コミット済みの `pnpm-lock.yaml` がビルドの必須入力である — `create-auth-provider` が scaffold 時に生成する。手元に無い場合は一度 `pnpm install` を実行して結果をコミットすること。これにより、同じソースからのリビルドは同じ依存ツリーを生む。
680
+
681
+ 本番イメージは `ENV HTTP_PORT=3000` を設定し、それを `EXPOSE` と、`/_healthcheck` に対する Docker ネイティブの healthcheck の両方に使う。HOCON 設定も `http.port` に `${?HTTP_PORT}` を読むため、リスナーと healthcheck がずれることはない。別のポートで動かすには:
682
+
683
+ ```bash
684
+ docker run -e HTTP_PORT=8080 -p 8080:8080 my-auth-provider
685
+ ```
686
+
687
+ `EXPOSE` はイメージのメタデータであり、それだけではポートを公開しないため、明示的な `-p` マッピングが引き続き必要である。
688
+
689
+ ### SMTP 送信者のための Mailpit(開発専用)
690
+
691
+ [`docker-compose.mailpit.yml`](docker-compose.mailpit.yml) は `docker-compose.yml` に重ねるオーバーレイで、メールを受け止める [Mailpit](https://mailpit.axllent.org/) を加える。これにより、MFA を有効にした開発時の実行は、そのメール — アカウントのメールの証明、メール要素のコード — を、各コードをログに出すだけの開発用送信者ではなく SMTP 送信者で送る:
692
+
693
+ ```bash
694
+ docker compose -f docker-compose.yml -f docker-compose.mailpit.yml up --build
695
+ ```
696
+
697
+ メールは <http://localhost:8025> で読む。Mailpit はメールを保持し、先へは何も配送しない。素の `docker compose up`(`make dev`)は Mailpit を起動せず、本番用のファイルにも Mailpit は無い。開発専用である: デプロイでは `STANDARD_SMTP_MAIL_SENDER_*` の変数で実際のリレーを指定する([多要素認証](#多要素認証))。
698
+
699
+ オーバーレイが app サービスに設定するもの:
700
+
701
+ - `CONFIG_ENV=mailpit`。`development` ではテンプレートは各コードをログに出す送信者を入れる([モジュール合成順序](#モジュール合成順序) のルール 7)。[`config/mailpit.conf`](config/mailpit.conf) は `development.conf` を include するので、それ以外は MFA のサンプル鍵も含めて development の実行と同じである。
702
+ - `MFA_MODE` は設定しない: MFA はテンプレートの出荷どおり、または `.env` が設定するとおりに有効である。MFA が off ならメールを送るものが無い。
703
+ - `ADAPTERS_MFA_FACTOR_STORE=redis` と `ADAPTERS_MFA_TRANSACTION_STORE=redis`(compose の Redis 上、`docker-compose.yml` と同じ): `development` と `test` 以外の名前では、MFA のストアをメモリに置けない。
704
+ - `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 サービスがループバックに公開する。
705
+
706
+ compose の profile ではなくオーバーレイにしているのは、profile はサービスを足すことしかできず、app サービスの環境変数やポートを変えられないからである。
707
+
708
+ ### ヘルスエンドポイント
709
+
710
+ 2 つのルートが、それぞれ異なる問いに答える。どちらも auth ルーターより前にホストアプリへマウントされるため、auth パイプラインが劣化している間も応答し続ける。
711
+
712
+ | ルート | 問い | 答え |
713
+ |---|---|---|
714
+ | `GET /_healthcheck` | プロセスは起動しているか? | 常に `200 {"status":"ok"}` |
715
+ | `GET /readyz` | このレプリカは今リクエストを処理できるか? | `200 {"status":"ready", …}` または `503 {"status":"unready", …}` |
716
+
717
+ それぞれ別の probe に使う:
718
+
719
+ ```yaml
720
+ livenessProbe:
721
+ httpGet: { path: /_healthcheck, port: 3000 }
722
+ readinessProbe:
723
+ httpGet: { path: /readyz, port: 3000 }
724
+ ```
725
+
726
+ liveness を `/readyz` に向けるのは避けるべき誤りである: そうすると Redis のネットワーク分断が壊れたコンテナのように見え、Kubernetes はすべてのレプリカをループで再起動する。それで再接続されるものは何も無く、インシデントにコールドスタートが加わるだけである。Redis を失うことは、レプリカへの*ルーティング*を止める理由であって、レプリカを kill する理由ではない。
727
+
728
+ イメージの `HEALTHCHECK` が `/_healthcheck` を probe するのも同じ理由による。Docker が持つ health シグナルはちょうど 1 つで、Swarm や ECS では unhealthy なコンテナは停止されて置き換えられる — そのためこれを `/readyz` につなぐと、Kubernetes 以外のすべての orchestrator で再起動ループが再び生じる。`depends_on: condition: service_healthy` に「alive」ではなく「ready」を意味させたいなら、イメージを変更するのではなく、compose ファイルでその 1 つのサービスの `healthcheck:` を上書きすること。
323
729
 
324
- カスタムモジュールを追加するには、`src/buildModules.mts` でインポートし、`createApp` に渡す `modules` 配列に追加します。
325
-
326
- ```typescript
327
- // src/buildModules.mts
328
- import { myCustomModule } from "./modules/my-custom-module.mjs";
329
-
330
- export function buildModules(config: AppConfig, overrides: BuildModulesOverrides = {}): Module[] {
331
- const googleEnabled =
332
- (config.federations?.google as { enabled?: boolean } | undefined)?.enabled === true;
333
-
334
- return [
335
- oauthModule({ config }),
336
- oauthSessionModule({ config }),
337
- oauthAuthorizationModule({ config }),
338
- sessionModule,
339
- ...(googleEnabled ? [googleFederationModule, googleFederationConfigModule] : []),
340
- myCustomModule, // ここに追加
341
- overrides.keyStoreModule ?? keyStoreModule,
342
- overrides.repositoriesModule ?? repositoriesModule,
343
- overrides.storesModule ?? storesModule,
344
- ];
730
+ `/readyz` はバックエンド接続ごとに 1 つの probe を実行し、それぞれを名前付きで報告する:
731
+
732
+ ```json
733
+ {
734
+ "status": "unready",
735
+ "checks": [
736
+ { "name": "redis", "ok": true, "durationMs": 2 },
737
+ { "name": "session-store", "ok": false, "durationMs": 1001 }
738
+ ]
345
739
  }
740
+ ```
741
+
742
+ 失敗の*理由*は意図的にボディから除いてあり、代わりにログに出る(`readiness_probe_failed`、チェックごとの完全な詳細付き)。ドライバーのエラーは `connect ECONNREFUSED 10.0.3.14:6379` — 内部のホストとポート — のように読め、しかもこのエンドポイントは、orchestrator が提示できる資格情報を持たないため未認証である。`createReadinessRouter` に `includeErrorDetail: true` を設定するのは、エンドポイントがデプロイの内側からしか到達できない場合に限ること。
743
+
744
+ 同じ理由で、`/readyz` は公開 listener に置かないこと。auth ルーターより前にあるためそのルーターの rate limiter の外にあり、各リクエストは、まさに報告対象の依存先に対して probe ごとに 1 コマンドを発行する — 匿名のリクエストループがインシデント中に負荷を上乗せするには十分である。内部ネットワーク、管理用 listener、または ingress の内部専用パスルールにバインドすること。
745
+
746
+ probe は接続を開いた builder が登録するため、リストはこのデプロイが実際に配線したものを反映する: Redis アダプターが選択されていれば共有 ioredis クライアント(`redis`)と connect-redis のセッションクライアント(`session-store`)、Redis をバックエンドとするモジュールを持たない構成では**何も無い** — その構成は unready になる原因となる依存先が無いので、常に ready になる。同梱の構成はそうはならない: refresh token family は常に Redis にあるので、`redis` は常に probe される。
747
+
748
+ `http.readinessTimeoutMs`(環境変数 `HTTP_READINESS_TIMEOUT_MS`、デフォルト `1000`)は probe ごとの deadline である。orchestrator 自身の probe タイムアウトより小さくしておくこと。さもないと、到達できない依存先が、unready なレプリカではなく遅いレプリカとして読まれてしまう。
346
749
 
347
- // src/app.mts
348
- const handle = await createApp({
349
- modules: buildModules(config),
350
- bootstrapComponents: {
351
- config,
352
- pathResolver: import.meta.resolve,
353
- },
354
- });
355
-
356
- app.use(handle.router);
357
- const server = app.listen(config.http.port);
358
- installGracefulShutdown(server, { logger, cleanup: () => handle.dispose() });
750
+ ## カスタムモジュールの追加
751
+
752
+ カスタムモジュールを追加するには、`src/buildModules.mts` でインポートし、`buildModules` が返す配列に追加する。boot planner は依存関係をモジュールが `require` / `provide` するものによって解決するが、ルートとミドルウェアは、`before` / `after` の指定が無い限りリストの順にマウントする。したがって、他のルートが依存する middleware では位置が意味を持つ — セッションストアモジュール、[モジュール合成順序](#モジュール合成順序) のルール 1 である。`/oauth` のように別のモジュールも使うプレフィックスの下のルートは、自分の body を自分でパースし、その parser を自分のパスちょうどに限定する限り、特定の位置を必要としない(ルール 2): `oauthModule` のルーターがパースするのは自身のルートの body だけで、プレフィックス配下のすべてのリクエストに走る parser は、他のモジュールの body を代わりに読んでしまう。前者のようなものをマウントしないモジュールは末尾に置けばよい:
753
+
754
+ ```diff
755
+ // src/buildModules.mts
756
+ +import { myCustomModule } from "./my-custom-module.mjs";
757
+ …
758
+ return [
759
+ sessionStoreModuleFor(config),
760
+ …
761
+ ...(federationGrantsEnabled || mfaInstalled ? [subjectRevocationServiceModule] : []),
762
+ + myCustomModule,
763
+ ];
359
764
  ```
360
765
 
766
+ そこにある他のルールも守ること: セッションストアモジュールは先頭のままにし、ストアスロットを埋めるモジュールは、そのスロットのアダプタースイッチの横に追加するのではなく、スイッチを置き換える。[`src/app.mts`](src/app.mts) は変更不要である: `buildModules(config, …)` を `createApp` に渡し、`createApp` が返すルーターをマウントし、サーバーのライフタイムを配線している — `installGracefulShutdown`(下記)がサーバーを drain し、`handle.dispose()` を呼ぶ。例外は独自のメール送信者で、これはリストに足すのではなく、その呼び出しの `mailSenderModules` override から入れる(ルール 7)。リストに足すと、同梱の送信者と `mailSender` スロットで衝突する。
767
+
768
+ セッション要件を寄与するモジュールは「ログイン済み」の意味を変えるので、その名前を `core.sessionRequirements.expected` に書く。`config/application.conf` はこれを `[]` として出荷する。boot はこのリストをモジュールが登録したものと比較する。リストにあって何も登録しない名前はブートを拒否し(`session-requirement-missing`)、登録された要件をリストが書き漏らしても拒否する(`session-requirements-undeclared`)。`MFA_MODE` が MFA を組み込むとき、テンプレートはリストに `mfa` を加える(`expectedSessionRequirements`、[`src/configPath.mts`](src/configPath.mts))。書いた名前はそのまま残り、その横に `core.sessionRequirements.secondFactorAuthority = "mfa"` を書く。そこに `mfa` 以外の値を自分で書くと boot の前に拒否される。テンプレートが組み込む MFA モジュールが `mfa` を登録し、第二要素の権限(second-factor authority)を宣言する。core は、宣言された権限の要件がそれを宣言していないこと(`second-factor-authority-not-declared`)も、`mfa` という名前の二つ目の要件も、権限を宣言する二つ目の要件(`duplicate-second-factor-authority`)も拒否するので、追加したモジュールがその代わりになることはない。MFA はリストにモジュールを加えるのではなく `MFA_MODE` で組み込む: そうしなければテンプレートがそれらを二度組み込むことになる。
769
+
361
770
  ### シャットダウンの保証
362
771
 
363
- `src/shutdown.mts` は依存パッケージではなく scaffold 側に置いてある (#290)。全ユーザーセッションを終端するコンポーネントについて「SIGTERM は in-flight リクエストを待つのか、待つなら何秒か」は、デプロイするコード自体から答えられる必要があるため。
772
+ `src/shutdown.mts` は意図的に、依存パッケージではなく scaffold の一部にしてある(#290): すべてのユーザーセッションを終端するコンポーネントについて、*「SIGTERM は in-flight リクエストを待つのか、待つならどれだけの時間か?」*という問いには、デプロイするコード自体から答えられなければならないためである。
773
+
774
+ 1. **`SIGTERM` と `SIGINT`** のどちらでも開始する。2 回目のシグナルは、1 回目の dispose が扱っているストアに重ねて 2 回目の dispose を始めるのではなく、無視される。
775
+ 2. **新規接続は即座に停止**し、idle な keep-alive ソケットは解放される — これらは背後にリクエストの無いままサーバーを開いたままにするため、解放しなければ、閑散としたサーバーが deadline 全体を無駄に待つことになる。
776
+ 3. **in-flight リクエストには `drainTimeoutMs`**(デフォルト **10 秒**)が与えられ、その間に完了する。
777
+ 4. **deadline を過ぎると残りの接続は切断され、プロセスは非ゼロで終了する。** 常に `0` しか見ない orchestrator には、正常な drain と時間切れになった drain を区別できない。
778
+ 5. **`cleanup` は drain の後、終了の前に実行される** — `handle.dispose()`、すなわち逆トポロジカル順のコンポーネント cleanup と Redis/タイマーの drain である。そこでの失敗はこのサービス自身の logger(他のすべての行と同じ NDJSON)で `shutdown_cleanup_failed`(または `shutdown_cleanup_timed_out`)としてログに出力され、終了コードにも反映される。各段階はそれぞれ 1 つのイベントである(下の表を参照)。throw した dispose でもプロセスは終了し、プロセスが固まることはない。その行が運ぶのは失敗の core の [`loggableError`](../../packages/core/README.ja.md#logger) による射影であり、エラーそのものではない: `dispose()` はすべての cleanup 自身のエラーをまとめた AggregateError で reject し、その行はそれぞれをコードとともに名前で示し(`aggregateErrors`、先頭 5 つ)、それが持つものは何も出さない — その中には失敗したストアへの書き込みが、書き込もうとしていた内容ごと含まれうる。
364
779
 
365
- 1. **`SIGTERM` / `SIGINT`** のどちらでも開始する。2 回目のシグナルは無視する(1 回目の dispose に重ねて 2 回目を走らせない)。
366
- 2. **新規接続は即座に停止**し、idle な keep-alive ソケットは解放する。リクエストを持たないまま server を開いたままにするソケットなので、解放しないと閑散時に deadline を丸ごと待つことになる。
367
- 3. **in-flight リクエストには `drainTimeoutMs`**(既定 **10 秒**)を与える。
368
- 4. **deadline を超えたら残接続を切り、プロセスは非ゼロ終了する。** 常に `0` しか見えない orchestrator では、正常な drain と時間切れの強制切断を区別できない。
369
- 5. **`cleanup` は drain 後・exit 前**に走る(`handle.dispose()` = 逆トポロジカルなコンポーネント cleanup + Redis/タイマーの drain)。失敗はこのサービス自身の logger(他の行と同じ NDJSON)に出し、exit code にも反映する。dispose が throw してもプロセスは終了する。
780
+ 6. **`cleanup` には少なくとも、モジュールが登録した allowance が与えられる**: `handle.cleanupAllowanceMs` — モジュールが cleanup とともに登録した tail のうち最長のもの — を、`src/app.mts` が `installGracefulShutdown` に `cleanupAllowanceMs: () => handle.cleanupAllowanceMs` として渡し、シグナルが届いた時点で読む。cleanup の予算は、それと drain の 10 秒のうち長いほうであり、`shutdown_draining` がそれを `cleanupTimeoutMs` として記録する。1 から 2147483647 までの整数ミリ秒でない allowance は無視される。テンプレートはこれを決めるためにモジュールの設定を読まない。フェデレーショングラントが有効なら、パッケージは自分の drain の tail を `federation-grants.upstreamHardTimeoutMs` + `persistRetryBudgetMs` + `lockWaitMs` + 12 秒として登録し、45 秒を下回ることはない — 同梱の予算(25 + 3 + 5 + 12)ではちょうど 45 秒 — dispose が、ローテーションされた上流資格情報の書き込みを待つためである。予算を上げれば allowance もそれに応じて増えるので、orchestrator の grace もそれに合わせて上げること。無効なら、allowance を登録するものはなく、cleanup の予算は drain のものと同じままである。
370
781
 
371
- 6. **フェデレーショングラントが有効なら `cleanup` に「refresh の最長の尻尾 + 余裕」**(`src/shutdown.mts` の `cleanupAllowanceFor`): `federationGrants.upstreamHardTimeoutMs` + `persistRetryBudgetMs` + `lockWaitMs` + 12 秒、下限 45 秒 — 同梱の予算(25 + 3 + 5 + 12)ではちょうど 45 秒。drain の 10 秒を継承しないのは、dispose がローテーションした上流資格情報の書き込みを待つため。予算を上げれば allowance も増えるので、orchestrator の grace も合わせて上げる。無効なら cleanup の予算は drain のまま。
782
+ 各段階はそれぞれ 1 行をログに出す:
372
783
 
373
- **`drainTimeoutMs` と `cleanupTimeoutMs` の合計が orchestrator の kill grace period を下回るようにすること。** Kubernetes の `terminationGracePeriodSeconds` は既定 30 秒、compose の `stop_grace_period` は既定 10 秒。フェデレーショングラントが有効なときの最悪値は drain 10 秒 + cleanup 45 秒 = 55 秒なので、**grace は 60 秒以上**にする — 同梱の compose 2 本はそうしてあり、Kubernetes では `terminationGracePeriodSeconds: 60` を自分で設定する。さもないとローリング再起動のたびに、cleanup が待っている書き込みの途中で `SIGKILL` される。他人の都合の `SIGKILL` が来る前に、自分の都合で閉じるのが目的。
784
+ | イベント | レベル | フィールド |
785
+ |---|---|---|
786
+ | `shutdown_draining` | info | `drainTimeoutMs`, `cleanupTimeoutMs` |
787
+ | `shutdown_drain_deadline_exceeded` | error | `drainTimeoutMs` |
788
+ | `shutdown_server_close_failed` | error | `err` |
789
+ | `shutdown_cleanup_timed_out` | error | `cleanupTimeoutMs` |
790
+ | `shutdown_cleanup_failed` | error | `err` |
791
+ | `shutdown_complete` | info | `reason`、`drain`、`exitCode` |
792
+
793
+ `err` は `loggableError` による射影である。`drain` は drain がどう終わったか — `drained`、`drain-timeout`、`close-failed` のいずれか(閉じられなかったリスナーは正常な drain とはみなされず、非ゼロで終了する)。`reason` は `exitCode` を決めたもの — drain の結果、またはその後の cleanup が時間切れになったか throw したときの `cleanup-timeout` / `cleanup-failed` — なので、`reason: "drained"` が非ゼロのコードと並ぶことはない。
794
+
795
+ **`drainTimeoutMs` と `cleanupTimeoutMs` の合計が orchestrator の kill grace period を下回るようにすること。** Kubernetes の `terminationGracePeriodSeconds` はデフォルトで 30 秒、compose の `stop_grace_period` は 10 秒である。フェデレーショングラントが有効な場合の最悪値は drain(10 秒)+ cleanup(45 秒)= 55 秒なので、**grace は 60 秒以上に設定すること** — 同梱の compose ファイルはそうしてあり、Kubernetes のデプロイは自分で `terminationGracePeriodSeconds: 60` を設定しなければならない。さもないと、ローリング再起動のたびに、cleanup が完了させるためにある書き込みの途中でプロセスが SIGKILL される。目的は、他人の都合で `SIGKILL` が届く前に、自分の都合で閉じることである。
374
796
 
375
797
  ## npm スクリプト
376
798
 
377
799
  | スクリプト | 説明 |
378
800
  |---|---|
379
801
  | `pnpm run build` | TypeScript を `dist/` にコンパイル |
380
- | `pnpm start` | コンパイル済みサーバーを起動 |
802
+ | `pnpm run start` | コンパイル済みサーバーを起動 |
381
803
  | `pnpm run debug` | `tsx watch` でホットリロード起動(開発モード) |
382
- | `pnpm test` | Vitest でテストを実行 |
804
+ | `pnpm run test` | 型検査(`tsc --noEmit`)のあと、Vitest でテストを実行 |
383
805
 
384
806
  ## 関連
385
807
 
808
+ - [オペレーター runbook](../../docs/operator-runbook.md) — デプロイ形態と起動拒否、probe、各依存先の障害がどう見えるか、アラート対象のイベント、Redis のキーファミリーと障害のタイミング、鍵ローテーション、アップグレードとロールバック
386
809
  - [`@o3co/auth-provider-core`](../../packages/core) — アプリケーションファクトリと設定スキーマ
387
810
  - [`@o3co/auth-provider-oauth`](../../packages/oauth) — OAuth モジュール
388
811
  - [`@o3co/auth-provider-session`](../../packages/session) — Session モジュール
389
812
  - [`@o3co/auth-provider-foundation`](../../packages/foundation) — 組み込みアダプター登録
390
813
  - [`@o3co/create-auth-provider`](../../create-app) — このテンプレートを生成する CLI スキャフォルダー
814
+
815
+ ## 可観測性
816
+
817
+ ### ログ
818
+
819
+ プロバイダーは [pino](https://getpino.io) を通じて stdout に改行区切りの JSON をログ出力する。これはログアグリゲーターがパーサーなしで取り込める形式である。`LOGGING_LEVEL`(HOCON では `logging.level`。`logging` モジュールのセクションで、boot の前にそのモジュールのスキーマで読む)が閾値を設定する。`trace` と `debug` はリクエスト単位の詳細を含み、デフォルトではオフである。pino は閾値未満の呼び出しをフォーマット前に捨てるため、出力されてから下流でフィルタされるのではなく、本番環境ではコストがかからない。
820
+
821
+ logger は boot planner の `logger` コンポーネントスロットに配線されるため、`optional: ["logger"]` を宣言するすべてのモジュール — oauth、session、dpop、mtls、token-exchange — はこの 1 つのインスタンスを通じてログを出す。`LOGGING_LEVEL` がそれらに届くのは、このスロットを埋めているからである。スロットを空のままにした composition root では、代わりに各モジュール自身のデフォルトの `consoleLogger` が使われ、レベルを設定しても何も変わらない。
822
+
823
+ イベントは構造化され、名前が付いている: `logger.error({ err }, "session_store_redis_error")`。アラートはメッセージ本文ではなく名前に対して設定すること。
824
+
825
+ バックエンドを差し替えるには、`src/logger.mts` の `createAppLogger` を置き換える。core の `Logger` を満たすものなら何でも動く — このインターフェースは pino の 2 つのオーバーロードを持つ呼び出し形なので、pino 互換の logger ならアダプターは不要である。
826
+
827
+ ### 監査ログ
828
+
829
+ セキュリティに関わるイベント — 誰が認証したか、何が発行されたか、何が拒否されたか、rate limiter がいつ応答できなかったか — は、`adapters.auditSink`(環境変数 `ADAPTERS_AUDIT_SINK`)が指す sink に送られる。テンプレートは `"logger"` を同梱しており、これは他のすべてと同じ pino ストリームを通じて 1 行に 1 イベントを書き出す:
830
+
831
+ ```json
832
+ {"level":30,"time":1787841564013,"name":"audit","audit":{"timestamp":"2026-08-27T14:39:24.013Z","type":"token.issued.failure","ip":"203.0.113.7","details":{"reason":"unsupported_grant_type"}},"msg":"token.issued.failure"}
833
+ ```
834
+
835
+ 出力形式は 1 つなので、アグリゲーターはアプリケーションログと監査イベントを 1 つのパーサーで取り込み、`name`(`"provider"` か `"audit"` か)で分けられる。イベント種別はメッセージでもあるため、`session_store_redis_error` の場合と同じように、名前 — `authorize.rejected`、`token.issued.failure`、`rate_limit.unavailable` — に対してアラートを設定すること。
836
+
837
+ `ADAPTERS_AUDIT_SINK=console` を指定すると、代わりに core の組み込み sink が選ばれる: ログのエンベロープ無しで、イベントそのものを 1 行に 1 つの JSON オブジェクトとして出す。イベントだけを必要とし、それ以外は要らないパイプライン向けである。
838
+
839
+ **`LOGGING_LEVEL` は監査ログの出力を左右しない。** 監査ストリームのレベルは `info` に固定されている。`warn` は本番では普通の設定であり、`silent` も正当な設定である。そのどちらかがこれを黙らせれば、ログの好みの設定のように見えながら、すべての認証と発行の記録を消してしまう。監査イベントの送り先は `adapters.auditSink` で選ぶ — そしてこのセレクターには **`"none"` が無い**。未知の sink は、監査ログの無いデプロイを生むのではなく、登録されている sink を名指しして起動を失敗させる。sink を意図して組み込まない合成は、core 自身のセクションで `core.declaredAbsent = ["auditSink"]` とそれを宣言する。このテンプレートは常に 1 つ組み込む。
840
+
841
+ 実際の sink — SIEM、ログパイプライン、メッセージバス — に向けるには、`auditSinkModuleFor`(`src/modules.mts`)に builder を登録し、それを選択する。オプションは `audit-sink` モジュールのセクションに、sink の名前の下に書く:
842
+
843
+ ```ts
844
+ factory.register("splunk-hec", (cfg) => createSplunkSink(cfg));
845
+ ```
846
+
847
+ ```hocon
848
+ adapters.auditSink = "splunk-hec"
849
+ audit-sink {
850
+ splunk-hec { endpoint = ${?SPLUNK_HEC_URL}, token = ${?SPLUNK_HEC_TOKEN} }
851
+ }
852
+ ```
853
+
854
+ 設定した送り先と並べて監査の送り先を加えるには、自分のモジュールから `auditHooks` を寄与する。core は `auditSink` と `auditHooks` をまとめてファンアウトする。
855
+
856
+ sink は契約上 fire-and-forget である: core は await せずにディスパッチし、reject を握りつぶすため、遅い sink や失敗する sink が認証フローにレイテンシを加えたり、フローを失敗させたりすることはない。その裏返しとして、配信できない sink はイベントを落とす。sink が 1 つだけなら、その失敗は黙って握りつぶされる。モジュールが `auditHooks` を寄与していれば、ファンアウトが失敗した呼び出しをそれぞれ、sink の位置とイベントの `type` を添えて `audit_sink_failed` としてエラーレベルでログに出す。取りこぼしはまだカウントされていない — 下記「メトリクス」の **まだ公開していないもの** を参照。
857
+
858
+ ### メトリクス
859
+
860
+ `GET /metrics` は Prometheus のテキスト exposition 形式を返す。
861
+
862
+ | メトリクス | 種類 | 何に答えるか |
863
+ |---|---|---|
864
+ | `http_request_duration_seconds` | histogram | リクエストレート、エラーレート、レイテンシ(`method` / `route` / `status` 別) |
865
+ | `auth_dependency_up` | gauge | バックエンドの各依存先が readiness probe に応答したかどうか(`dependency="redis"`、`"session-store"`) |
866
+ | `auth_provider_*` | 各種 | Node プロセスのデフォルト — イベントループの遅延、ヒープ、GC、ハンドル |
867
+
868
+ `auth_dependency_up` は「Redis が落ちた」と「アプリが遅い」を区別する系列であり、これが無ければコンテナログを grep しない限り区別できない。アダプターの builder が既に登録した probe を再利用するため、ずれていく第二のリストではなく、このデプロイが実際に配線したものを反映する。依存先はスクレイプのたびにサンプリングし直され — キャッシュされた判定は、インシデントが続いている間ずっと healthy と報告してしまう — 実行中の probe は再発行されずに合流されるため、スクレイプのループが、既に問題を抱えている依存先にコマンドを積み重ねることはない。
869
+
870
+ ルートのラベルには URL ではなく Express のルート**パターン**(`/oauth/token`、`/api/widgets/:id`)を使い、一致しないリクエストは `route="unmatched"` にまとめられる。パスでラベル付けすると異なる URL ごとに系列が生まれ、しかもこのサーバーのパスは不透明な値を含む — メトリクスエンドポイントが、自分を監視するはずの監視基盤を落としてしまうのは、このようにしてである。
871
+
872
+ スクレイプ設定の例:
873
+
874
+ ```yaml
875
+ scrape_configs:
876
+ - job_name: auth-provider
877
+ static_configs:
878
+ - targets: ["auth-provider:3000"]
879
+ ```
880
+
881
+ `/readyz` と同じ理由で、`/metrics` は公開 listener に置かないこと: 未認証であり、auth ルーターより前にあるためその rate limiter の外にあり、スクレイプのたびに readiness probe をサンプリングする。
882
+
883
+ **まだ公開していないもの:** rate limiter の fail-closed の回数と、監査 sink の取りこぼし。どちらも `@o3co/auth-provider-core` の内部で起きるため、composition root からは観測できない。これらを数えるには、このテンプレートから手の届くものではなく、core 側のメトリクスフックが必要である。それまでは、`rate_limiter_failed_closed` と `rate_limit.unavailable` のログイベントに対してアラートを設定すること。