@o3co/create-auth-provider 0.17.0-rc.3 → 0.17.0

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 (37) hide show
  1. package/package.json +3 -3
  2. package/templates/standalone/README.ja.md +5 -4
  3. package/templates/standalone/README.md +23 -4
  4. package/templates/standalone/config/clients.yaml.example +11 -5
  5. package/templates/standalone/config/reference.conf +5 -2
  6. package/templates/standalone/package.json +7 -6
  7. package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +6 -10
  8. package/templates/standalone/src/__tests__/all-modules-composition.test.mts +2 -2
  9. package/templates/standalone/src/__tests__/audit-sink.test.mts +13 -4
  10. package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +64 -0
  11. package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +14 -12
  12. package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +7 -4
  13. package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +7 -4
  14. package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +168 -0
  15. package/templates/standalone/src/__tests__/google-session.fixture.mts +17 -7
  16. package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +13 -12
  17. package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +113 -0
  18. package/templates/standalone/src/__tests__/own-modules.test.mts +3 -35
  19. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +73 -4
  20. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +98 -0
  21. package/templates/standalone/src/__tests__/replica-safety.test.mts +7 -4
  22. package/templates/standalone/src/__tests__/repositories-logger.test.mts +119 -0
  23. package/templates/standalone/src/__tests__/sections.test.mts +15 -3
  24. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +8 -10
  25. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +13 -4
  26. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +7 -7
  27. package/templates/standalone/src/__tests__/smoke.test.mts +13 -5
  28. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +24 -8
  29. package/templates/standalone/src/__tests__/store-credential.test.mts +7 -5
  30. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +7 -4
  31. package/templates/standalone/src/app.mts +2 -11
  32. package/templates/standalone/src/modules.mts +9 -3
  33. package/templates/standalone/src/sections.mts +3 -2
  34. package/templates/standalone/src/securityHeaders.mts +36 -0
  35. package/templates/standalone/tests/package-lock.json +95 -95
  36. package/templates/standalone/tests/package.json +1 -1
  37. package/templates/versions.json +18 -18
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/create-auth-provider",
3
- "version": "0.17.0-rc.3",
3
+ "version": "0.17.0",
4
4
  "description": "Scaffold a new o3co/auth.provider project from one of its templates",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -29,9 +29,9 @@
29
29
  },
30
30
  "devDependencies": {
31
31
  "@o3co/ts.hocon": "^1.13.0",
32
- "@types/node": "^26.6.3",
32
+ "@types/node": "^26.6.4",
33
33
  "typescript": "^6.0.3",
34
- "vitest": "^5.0.2"
34
+ "vitest": "^5.0.3"
35
35
  },
36
36
  "scripts": {
37
37
  "build": "tsc",
@@ -1,6 +1,6 @@
1
1
  # @o3co/auth-provider-standalone
2
2
 
3
- 最終更新: 2026-10-06
3
+ 最終更新: 2026-10-07
4
4
 
5
5
  auth.provider のデプロイ可能なサーバーテンプレート。これは composition root であり、設定を読み込み、モジュールをロードし、Express サーバーを起動する。`@o3co/create-auth-provider` で生成される。
6
6
 
@@ -58,7 +58,7 @@ my-app:
58
58
 
59
59
  ロードバランサ配下で standalone サーバーを複数インスタンス動かすときは、`REDIS_CLIENTS_URL` を**共有**の Redis 7.2+ インスタンスに向けること。共有の Redis URL が無いと、各レプリカは refresh token family を自分の接続(と、ローカル専用の Redis)に保持するため、トークンを発行していないレプリカに届いた refresh リクエストは `invalid_grant` を返し、ラウンドロビンの LB では 1 回おきにそうなる。
60
60
 
61
- **その Redis は `maxmemory-policy noeviction`(Redis のデフォルト)で動かすこと。** 期限まで残らなければならないキーを持つ Redis のストア — attempt counter、ユーザーセッションストアのライフサイクルストア、フェデレーショントークンストア、MFA のストア — は起動時にポリシーを読む(`INFO memory`、次に `CONFIG GET maxmemory-policy`)。ほかのポリシーでは起動を拒否し、サーバーがポリシーを答えないときも拒否する。接続のユーザーが `INFO` や `CONFIG` を実行できない(ACL で制限されたユーザー、それらを改名するマネージドサービス)が、サーバーが `noeviction` で動いていると分かっているなら、`REDIS_CLIENTS_ASSUME_NO_EVICTION=true`(`redis-clients.assumeNoEviction`、デフォルト `false`)を設定すること。これはポリシーを読めないときにだけ読まれる: サーバーが答えたポリシーが常に優先するので、`allkeys-*` や `volatile-*` のサーバーを通すことはない。
61
+ **その Redis は `maxmemory-policy noeviction`(Redis のデフォルト)で動かすこと。** 期限まで残らなければならないキーを持つ Redis のストア — attempt counter、ユーザーセッションストアのライフサイクルストア、フェデレーショントークンストア、MFA のストア、失効の状態を持つストア(アクセストークン denylist、subject revocation ストア、refresh token family ストア)、そして replay seen-set — は起動時にポリシーを読む(`INFO memory`、次に `CONFIG GET maxmemory-policy`)。ほかのポリシーでは起動を拒否し、サーバーがポリシーを答えないときも拒否する。接続のユーザーが `INFO` や `CONFIG` を実行できない(ACL で制限されたユーザー、それらを改名するマネージドサービス)が、サーバーが `noeviction` で動いていると分かっているなら、`REDIS_CLIENTS_ASSUME_NO_EVICTION=true`(`redis-clients.assumeNoEviction`、デフォルト `false`)を設定すること。これはポリシーを読めないときにだけ読まれる: サーバーが答えたポリシーが常に優先するので、`allkeys-*` や `volatile-*` のサーバーを通すことはない。これらのストアは構築時にサーバーに問い合わせ、refresh token family ストアは常に、アクセストークン denylist と replay seen-set はデフォルトで Redis 上にあるため、Redis がまだ起動していない、または到達できないと起動は失敗する — ほかの `adapters` の選択がすべて memory でも同じである: provider より先に Redis を起動すること。
62
62
 
63
63
  **ロードバランサ配下では `HTTP_TRUST_PROXY` をプロキシのアドレスに設定すること。** デフォルトは `false` で、この場合 `req.ip` はクライアントではなく*ロードバランサの*アドレスになる。すると IP をキーとするすべての rate limit — OAuth エンドポイントの limiter と `POST /session/login` の総当たり対策ガード — が**全ユーザーで 1 つのバケットを共有する**: 誰からであれ最初の 20 回のログイン試行でウィンドウを使い切り、以降のユーザーは全員 `429` を受け取る。この障害は設定ミスではなく攻撃のように見えるため、深夜 3 時の診断が高くつく。このサービスの前段で何かが TLS を終端したりプロキシしたりしているなら必ず設定し、`X-Forwarded-For` を設定しているのがそのホップであることを確認すること。`ip` のない監査イベントは、リクエストのアドレスがアドレスでなかった — `req.ip` がアドレス以外を入れた `X-Forwarded-For` から来た — ことを意味するので、`HTTP_TRUST_PROXY` がそのヘッダーを設定するホップだけを信頼しているか確認すること。
64
64
 
@@ -268,7 +268,7 @@ overlay の値は `application.conf` より優先される。scaffold には `de
268
268
  | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_TIMEOUT_MS` | `5000` | 取得のタイムアウト。 |
269
269
  | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_CACHE_MAX_AGE_MS` | `600000` | 有効なドキュメントをキャッシュから返す期間の上限。`Cache-Control: max-age` によって短くなることがある。 |
270
270
  | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CACHE_ENTRIES` | `256` | 同時に記憶しておくドキュメントの数。キーは未認証の呼び出し元が選ぶため、マップには上限を設けている。 |
271
- | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_STALE_IF_ERROR_MS` | `300000` | 検証済みの登録情報を、ドキュメント側に原因の無い理由(DNS の一時的な不調、5xx、タイムアウト)で再検証が失敗した後も返し続ける期間 — 障害で、動いているクライアントを壊すべきではないため。*拒否*されたドキュメントは即座に破棄される。`0` で無効化する。 |
271
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_STALE_IF_ERROR_MS` | `0` | 検証済みの登録情報を、ドキュメント側に原因の無い理由(DNS の一時的な不調、5xx、429、タイムアウト)で再検証が失敗した後も、期限を過ぎてから返し続ける期間。既定の `0` では返さない — 期限切れのドキュメントの再検証が失敗すると、取得が再び成功するまで、かつ失敗から少なくとも `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_NEGATIVE_CACHE_MS` の間そのクライアントは解決されない(`invalid_client`)。猶予を設定すると、ホストの障害の間も動いているクライアントを止めず、その間は最後に検証したリダイレクト許可リストを有効として扱う。*拒否*されたドキュメントは即座に破棄される。 |
272
272
  | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_NEGATIVE_CACHE_MS` | `60000` | 拒否を記憶しておく期間。同じ `client_id` をリクエストのたびに解決・取得し直さないためである。ドキュメントを修正したクライアントが締め出されないよう、短くしてある。 |
273
273
  | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CONCURRENT_FETCHES` | `8` | すべての `client_id` を通じて、同時に取得中にできるドキュメントの数。未認証の呼び出し元がこのサーバーに接続させられる量を制限する。 |
274
274
  | `KEY_STORE_LOCAL_KID` | `v0` | JWT ヘッダーに含まれる key ID。制御文字を含まない 1〜256 文字。それ以外は起動時に拒否される — export されているが空の変数も含む(以前は kid `""` で署名されていた。そうして発行されたトークンは修正後に拒否されるため、そのユーザーは再ログインになる) |
@@ -276,7 +276,6 @@ overlay の値は `application.conf` より優先される。scaffold には `de
276
276
  | `KEY_STORE_LOCAL_PRIVATE_KEY_PATH` | — | PEM 秘密鍵ファイルのパス |
277
277
  | `KEY_STORE_LOCAL_PUBLIC_KEY` | — | PEM エンコードされた公開鍵 |
278
278
  | `KEY_STORE_LOCAL_PUBLIC_KEY_PATH` | — | PEM 公開鍵ファイルのパス |
279
- | `OAUTH_JWT_LEGACY_TYP_ACCEPT` | `false` | `typ` ヘッダーの無いトークンを受け付ける。`false` ではそれらを拒否し、typ の無いトークンを、たいていそうであるように設定ミスかダウングレードの試みとして扱う。`true` にするのは、v0.4.x のトークンがまだ流通している間の、期限を区切った移行期間に限ること。 |
280
279
  | `JWKS_PATH` | `/.well-known/jwks.json` | issuer の下で検証鍵を公開するパスで、discovery が `jwks_uri` として広告するもの(`jwks.path`)。`//`、ドットセグメント、クエリ、フラグメント、バックスラッシュ、パーセントエンコーディング、制御文字を含まない絶対パス。 |
281
280
  | `JWKS_CACHE_MAX_AGE` | `300` | JWKS レスポンスの `Cache-Control: public, max-age`(秒、`jwks.cacheMaxAge`)。ローテーションした鍵がキャッシュする検証者に間に合って届くよう、鍵の重複期間より十分短く保つこと。 |
282
281
 
@@ -465,6 +464,8 @@ core.federations {
465
464
  | `REPOSITORIES_USER_HTTP_TIMEOUT` | `5000` | HTTP リクエストタイムアウト(ミリ秒)。`2147483647` 以下の正の整数 |
466
465
  | `REPOSITORIES_USER_HTTP_MAX_RESPONSE_BYTES` | `1048576` | 上流レスポンスボディの受け入れ上限(バイト) |
467
466
 
467
+ **`yaml` のユーザーファイル**はユーザー名をキーとし、各エントリは `password` と、任意で `id`(無ければユーザー名)とクレームを持つ。パスワードは平文か bcrypt ハッシュ: `$2` で始まるものはハッシュとして読み、コスト 04 から 15 の整った `$2a$`・`$2b$`・`$2y$` ハッシュ(`$2y$` は `$2b$` として比較する)でなければならない(比較 1 回は、コスト 15 で約 2 秒、10 で約 60 ms、Node の libuv スレッドプールの 1 スレッドを使う)。`$2b$` と `$2y$` はパスワードの先頭 72 バイト(UTF-8 の日本語で約 24 文字)しか比較しない。`$2a$` は 254 バイトまでのパスワードでは同じ 72 バイトを読むが、それより長いものは長さを 256 で割った余りとして数えるため、255 バイト以上のパスワードは別のパスワードとして比較されることがある。新しいエントリには `$2b$` を使う。平文のエントリは全長で比較する。bcrypt エントリはすべて一つのコストを使う。これらの規則に反するパスワード、複数のコストにまたがる bcrypt エントリ(コストを名指しする)、空の `id` やユーザー名、同じ id を持つ二人のユーザー、エントリ内の `username` キーは、フィールドと、ユーザーかコストを名指しし、値は出さずに起動時に拒否される。`yaml` バックエンドで起動するたびに、環境を問わず `user_repository_in_memory` をテンプレートのロガーに warn で記録する(`LOGGING_LEVEL` が効く): ユーザーファイルは開発とテスト用である。[core パッケージの README](../../packages/core/README.ja.md#yaml-からクライアントとユーザーを読み込む) を参照。
468
+
468
469
  ユーザー認証 URL は 2 つとも上流ストアへ**平文のユーザー資格情報**を運ぶため、いずれも絶対 `https://` URL でなければならない。`http://` は loopback ホスト(`localhost`、`127.0.0.0/8` 内のアドレス、`[::1]`)に限って許可され、ローカル開発で証明書を用意せずに済むようにしている。プライベートレンジのアドレス(`http://10.0.0.5/…`)やコンテナネットワークのサービス名(`http://user-service/…`)には依然として `https://` が必要 — これらはデプロイが端から端まで制御していないネットワークを越えるため。URL のどちらか、タイムアウト、レスポンス上限のいずれかが使えない値なら、最初のログイン時ではなく起動時に失敗する。
469
470
 
470
471
  各 URL には、リダイレクトするエンドポイントではなく応答するエンドポイントを設定する: どのリクエストも `3xx` を追わないので、リダイレクトする URL ではすべての呼び出しが失敗する — [Store が自分で守るべきこと](../../packages/foundation/README.ja.md#store-が自分で守るべきこと) を参照。
@@ -1,6 +1,6 @@
1
1
  # @o3co/auth-provider-standalone
2
2
 
3
- Last updated: 2026-10-06
3
+ Last updated: 2026-10-07
4
4
 
5
5
  Deployable server template for auth.provider. This is the composition root — it reads config, loads modules, and starts the Express server. Generated by `@o3co/create-auth-provider`.
6
6
 
@@ -112,7 +112,7 @@ The [operator runbook](../../docs/operator-runbook.md) covers the operational si
112
112
 
113
113
  When running multiple instances of the standalone server behind a load balancer, point `REDIS_CLIENTS_URL` at a **shared** Redis 7.2+ instance. Without a shared Redis URL, every replica holds refresh-token families in its own connection (and any local-only Redis), so a refresh request that lands on a replica that did not issue the token returns `invalid_grant` on every other request in a round-robin LB.
114
114
 
115
- **That Redis must run `maxmemory-policy noeviction`** (Redis's default). The Redis stores whose keys must stay until they expire — the attempt counter, the user-session stores' lifecycle store, the federation token store and the MFA stores — read the policy at boot (`INFO memory`, then `CONFIG GET maxmemory-policy`), and refuse to boot on any other policy or on one the server will not report. If the connection's user may not run `INFO` or `CONFIG` (an ACL-restricted user, or a managed service that renames them) and you know the server runs `noeviction`, set `REDIS_CLIENTS_ASSUME_NO_EVICTION=true` (`redis-clients.assumeNoEviction`, default `false`). It is read only while the policy cannot be read: a policy the server reports always takes precedence, so it never lets an `allkeys-*` or `volatile-*` server through.
115
+ **That Redis must run `maxmemory-policy noeviction`** (Redis's default). The Redis stores whose keys must stay until they expire — the attempt counter, the user-session stores' lifecycle store, the federation token store, the MFA stores, the stores that hold revocation state (the access-token denylist, the subject revocation store and the refresh-token family store) and the replay seen-set — read the policy at boot (`INFO memory`, then `CONFIG GET maxmemory-policy`), and refuse to boot on any other policy or on one the server will not report. If the connection's user may not run `INFO` or `CONFIG` (an ACL-restricted user, or a managed service that renames them) and you know the server runs `noeviction`, set `REDIS_CLIENTS_ASSUME_NO_EVICTION=true` (`redis-clients.assumeNoEviction`, default `false`). It is read only while the policy cannot be read: a policy the server reports always takes precedence, so it never lets an `allkeys-*` or `volatile-*` server through. Since those stores ask the server when they are built, and the refresh-token family store is always on Redis and the access-token denylist and the replay seen-set are by default, a boot with Redis not yet started or unreachable fails — even with every other `adapters` selection on memory: start Redis before the provider.
116
116
 
117
117
  **Set `HTTP_TRUST_PROXY` to your proxy's address behind a load balancer.** It defaults to `false`, which makes `req.ip` the *load balancer's* address rather than the client's. Every rate limit keyed by IP — the OAuth endpoint limiters and the `POST /session/login` brute-force guard — then shares **one bucket across all users**: the first 20 login attempts from anyone exhaust the window and every subsequent user gets `429`. The failure reads like an attack rather than a misconfiguration, which is what makes it expensive to diagnose at 3am. Set it whenever anything terminates TLS or proxies in front of this service, and make sure that hop is the one setting `X-Forwarded-For`. An audit event with no `ip` means the request's address was not an address — `req.ip` came from an `X-Forwarded-For` that held something else — so check that `HTTP_TRUST_PROXY` trusts only the hop that sets the header.
118
118
 
@@ -445,7 +445,7 @@ The signing key is the `key-store` module's section: `key-store.provider`
445
445
  | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_TIMEOUT_MS` | `5000` | Fetch timeout. |
446
446
  | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_CACHE_MAX_AGE_MS` | `600000` | Upper bound on how long a valid document is served from cache; `Cache-Control: max-age` may shorten it. |
447
447
  | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CACHE_ENTRIES` | `256` | How many documents are remembered at once. The keys are chosen by an unauthenticated caller, so the map is bounded. |
448
- | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_STALE_IF_ERROR_MS` | `300000` | How long a registration already validated is still served after a revalidation that failed for a reason that is not the document's (a DNS blip, a 5xx, a timeout) — an outage should not break a working client. A document that was *rejected* is dropped immediately. `0` disables it. |
448
+ | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_STALE_IF_ERROR_MS` | `0` | How long past its expiry a registration already validated is still served after a revalidation that failed for a reason that is not the document's (a DNS blip, a 5xx, a 429, a timeout). `0`, the default, serves none: an expired document whose revalidation fails leaves the client unresolved (`invalid_client`) until a fetch succeeds again and for at least `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_NEGATIVE_CACHE_MS` after the failure. A window keeps a working client through an outage at its host, and honours the redirect allowlist last validated for that long. A document that was *rejected* is dropped immediately. |
449
449
  | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_NEGATIVE_CACHE_MS` | `60000` | How long a refusal is remembered, so the same `client_id` is not resolved and fetched again on every request. Short, so a client that fixes its document is not locked out. |
450
450
  | `OAUTH_CLIENT_ID_METADATA_DOCUMENTS_MAX_CONCURRENT_FETCHES` | `8` | How many documents may be in flight at once, across every `client_id`. Bounds what an unauthenticated caller can make this server dial. |
451
451
  | `KEY_STORE_LOCAL_KID` | `v0` | Key ID included in the JWT header. 1 to 256 characters with no control character; anything else fails boot, including a variable exported but empty (which used to sign under the kid `""`; tokens issued that way are refused once it is corrected, so their users sign in again) |
@@ -453,7 +453,6 @@ The signing key is the `key-store` module's section: `key-store.provider`
453
453
  | `KEY_STORE_LOCAL_PRIVATE_KEY_PATH` | — | Path to PEM private key file |
454
454
  | `KEY_STORE_LOCAL_PUBLIC_KEY` | — | PEM-encoded public key |
455
455
  | `KEY_STORE_LOCAL_PUBLIC_KEY_PATH` | — | Path to PEM public key file |
456
- | `OAUTH_JWT_LEGACY_TYP_ACCEPT` | `false` | Accept tokens whose `typ` header is absent. `false` rejects them, treating a typ-less token as the misconfiguration or downgrade attempt it usually is. Set `true` only for a bounded migration window while v0.4.x tokens are still in circulation. |
457
456
  | `JWKS_PATH` | `/.well-known/jwks.json` | Where the verification keys are published under the issuer, and what discovery advertises as `jwks_uri` (`jwks.path`). An absolute path with no `//`, dot-segment, query, fragment, backslash, percent-encoding or control character. |
458
457
  | `JWKS_CACHE_MAX_AGE` | `300` | The JWKS response's `Cache-Control: public, max-age`, in seconds (`jwks.cacheMaxAge`). Keep it well below the key-overlap window, so a rotated key reaches caching verifiers in time. |
459
458
 
@@ -751,6 +750,26 @@ and the production compose file mounts it (see [Docker](#docker)).
751
750
  | `REPOSITORIES_USER_HTTP_TIMEOUT` | `5000` | HTTP request timeout in milliseconds. Positive integer ≤ `2147483647` |
752
751
  | `REPOSITORIES_USER_HTTP_MAX_RESPONSE_BYTES` | `1048576` | Largest upstream response body accepted, in bytes |
753
752
 
753
+ **The `yaml` users file** is keyed by username; each entry holds `password`,
754
+ and optionally `id` (the username when absent) and claims. A password is plain
755
+ text, or a bcrypt hash: anything starting with `$2` is read as one, and must be
756
+ a well-formed `$2a$`, `$2b$` or `$2y$` hash (the last compared as `$2b$`) at a
757
+ cost from 04 to 15 (a compare at 15 takes about 2 s, at 10 about 60 ms, on a
758
+ thread of Node's libuv pool). `$2b$` and `$2y$` compare only the first 72
759
+ bytes of a password (about 24 Japanese characters in UTF-8). `$2a$` reads the
760
+ same 72 bytes of a password up to 254 bytes long, but counts a longer one's
761
+ length modulo 256, so a password of 255 bytes or more can compare as a
762
+ different one; use `$2b$` for new entries. A plain-text entry is compared over
763
+ its full length.
764
+ Every bcrypt entry uses one cost. Boot refuses a password that breaks these
765
+ rules, bcrypt entries at more than one cost (naming the costs), an empty `id`
766
+ or username, two users with the same id, and a `username` key inside an entry,
767
+ naming the field and the user or the costs, never a value. Each start with the `yaml` backend logs
768
+ `user_repository_in_memory` at warn on the template's logger (`LOGGING_LEVEL`
769
+ applies), whatever the environment: the users file is meant for development
770
+ and tests. See
771
+ [the core package's README](../../packages/core/README.md#loading-clients-and-users-from-yaml).
772
+
754
773
  Both user-authentication URLs carry **plaintext user credentials** to the
755
774
  upstream store, so both must be absolute `https://` URLs. `http://` is accepted
756
775
  only for a loopback host — `localhost`, an address in `127.0.0.0/8`, or `[::1]`
@@ -54,17 +54,23 @@
54
54
  # `allowExchangeOfTokensIssuedToOthers`: read only where the token-exchange
55
55
  # module (@o3co/auth-provider-oauth-token-exchange) is installed, which this
56
56
  # template does not do by default. Token exchange requires the caller to be
57
- # an audience of the subject token by default: the token's `azp` is the
58
- # client, or its `aud` contains it. `true` lets this one client exchange
59
- # tokens issued to other clients (a gateway, an on-behalf-of service). Only
60
- # a strict `true` counts.
57
+ # an audience of the subject token, and of an actor token, by default: the
58
+ # token's `azp` is the client, or its `aud` contains it. `true` lets this one
59
+ # client present subject and actor tokens issued to other clients (a
60
+ # gateway, an on-behalf-of service). Only a strict `true` counts. The token
61
+ # it is issued is for one of the subject token's audiences, so such a
62
+ # client's `allowedAudiences` must list the audiences of the tokens it
63
+ # exchanges.
61
64
 
62
65
  # A machine client that authenticates with a key pair (#484). Nothing here is
63
66
  # secret: the private half stays with the client, which signs an assertion
64
67
  # (iss = sub = client id, aud = the issuer or the token endpoint, exp within
65
68
  # an hour, a fresh jti) per token request. Rotating the key is a JWKS
66
69
  # publish when `jwksUri` is used. Needs the replay seen-set wired
67
- # (REPLAY_SEEN_SET_ADAPTER, on by default).
70
+ # (REPLAY_SEEN_SET_ADAPTER, on by default). A `jwksUri` (or a
71
+ # `backchannelLogoutUri`) served from a loopback, private, link-local or other
72
+ # special-use address (for example 100.64.0.0/10) needs its host in `core.outbound.internalHosts`
73
+ # (CORE_OUTBOUND_INTERNAL_HOSTS); plain http only for a listed loopback host.
68
74
  #
69
75
  # orders-service:
70
76
  # tokenEndpointAuthMethod: "private_key_jwt"
@@ -236,15 +236,18 @@ adapters {
236
236
  attemptCounter = ${?ADAPTERS_ATTEMPT_COUNTER}
237
237
  # The four user-session stores and the subject-level revocation pair. They
238
238
  # must be on Redis whenever express-session's store is, or a restart leaves
239
- # every browser a session whose user session is gone.
239
+ # every browser a session whose user session is gone. `redis` needs a server
240
+ # whose maxmemory-policy is `noeviction` once the revocation store is read.
240
241
  userSessionStores = "memory"
241
242
  userSessionStores = ${?ADAPTERS_USER_SESSION_STORES}
242
243
  # The RFC 7009 access-token denylist, which POST /oauth/revoke writes and
243
244
  # verification reads. On Redis, so a token revoked on one replica is revoked
244
- # on every one.
245
+ # on every one; `redis` needs a server whose maxmemory-policy is
246
+ # `noeviction`.
245
247
  accessTokenDenylist = "redis"
246
248
  accessTokenDenylist = ${?ADAPTERS_ACCESS_TOKEN_DENYLIST}
247
249
  # The `jti` single-use record behind private_key_jwt client authentication.
250
+ # `redis` needs a server whose maxmemory-policy is `noeviction`.
248
251
  replaySeenSet = "redis"
249
252
  replaySeenSet = ${?ADAPTERS_REPLAY_SEEN_SET}
250
253
  # Consent to a client that is not first-party: "none" wires no store and
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@o3co/auth-provider-standalone",
3
- "version": "0.17.0-rc.3",
3
+ "version": "0.17.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -37,19 +37,20 @@
37
37
  "express-session": "^1.19.0",
38
38
  "helmet": "^8.3.0",
39
39
  "ioredis": "^6.0.0",
40
- "pino": "^10.3.1",
40
+ "pino": "^10.4.0",
41
41
  "prom-client": "^15.1.3",
42
- "redis": "^6.2.1",
42
+ "redis": "^6.3.0",
43
43
  "zod": "^4.6.5"
44
44
  },
45
45
  "devDependencies": {
46
+ "@o3co/auth-provider-test-kit": "workspace:*",
46
47
  "@types/express": "^5.0.6",
47
48
  "@types/express-session": "^1",
48
- "@types/node": "^26.6.3",
49
+ "@types/node": "^26.6.4",
49
50
  "@types/supertest": "^7.2.1",
50
- "supertest": "^7.3.0",
51
+ "supertest": "^7.3.1",
51
52
  "tsx": "^4.23.15",
52
53
  "typescript": "^6.0.3",
53
- "vitest": "^5.0.2"
54
+ "vitest": "^5.0.3"
54
55
  }
55
56
  }
@@ -23,8 +23,8 @@
23
23
  * `reference.conf`, core's last, under the environment a deployment would
24
24
  * export) read in `app.mts`'s two phases (`readSwitches`, then
25
25
  * `resolveForBoot` for `createApp`), `buildModules`, core's `createApp`, every
26
- * module and store it selects, the key store, the audit sink, `helmet` and the
27
- * terminal error handler.
26
+ * module and store it selects, the key store, the audit sink, the security
27
+ * headers (`securityHeaders`) and the terminal error handler.
28
28
  *
29
29
  * Substituted (the first two are overrides `buildModules` offers):
30
30
  * - client and user repositories: in memory, not YAML off disk;
@@ -65,7 +65,6 @@ import { createFakeIdp, type FakeIdp } from "@o3co/auth-provider-core/testing";
65
65
  import { googleFederationTypeModule } from "@o3co/auth-provider-federation-google";
66
66
  import { oidcFederationTypeModule } from "@o3co/auth-provider-federation-oidc";
67
67
  import express from "express";
68
- import helmet from "helmet";
69
68
  import request from "supertest";
70
69
  import { beforeAll, describe, expect, it } from "vitest";
71
70
  import { buildModules } from "#/buildModules.mjs";
@@ -78,6 +77,7 @@ import {
78
77
  resolveForBoot,
79
78
  type Switches,
80
79
  } from "#/configPath.mjs";
80
+ import { securityHeaders } from "#/securityHeaders.mjs";
81
81
 
82
82
  export const ISSUER = "https://auth.test";
83
83
  const OIDC_ISSUER = "https://idp.test";
@@ -657,8 +657,8 @@ export interface Composition {
657
657
  }
658
658
 
659
659
  /**
660
- * Boots the composition and mounts it as `app.mts` does: `helmet`, the
661
- * composed router, and the terminal error handler last (unless
660
+ * Boots the composition and mounts it as `app.mts` does: its security headers
661
+ * (`securityHeaders`), the composed router, and the terminal error handler last (unless
662
662
  * `terminalErrorHandler` is `false`), all on one logger that is also the
663
663
  * boot's `logger` component.
664
664
  */
@@ -697,11 +697,7 @@ export async function compose(options: ComposeOptions = {}): Promise<Composition
697
697
  if (httpSettings === undefined) throw new Error("createApp booted without httpSettings");
698
698
  const app = express();
699
699
  app.set("trust proxy", httpSettings.trustProxy);
700
- app.use(
701
- helmet({
702
- contentSecurityPolicy: { directives: { defaultSrc: ["'none'"], frameAncestors: ["'none'"] } },
703
- }),
704
- );
700
+ app.use(securityHeaders());
705
701
  app.use(handle.router);
706
702
  if (options.terminalErrorHandler !== false) app.use(terminalErrorHandler(logger));
707
703
  return { app, handle, config: parsed, resolved, modules, logger, upstreams: fakes };
@@ -122,8 +122,8 @@ const TEMPLATE_PACKAGES: Readonly<Record<string, string>> = {
122
122
  };
123
123
 
124
124
  /**
125
- * Workspace packages the template does not depend on, so nothing here can
126
- * boot them. Each but the test kit, which has no module, is a module a
125
+ * Workspace packages the template does not compose (the test kit is a
126
+ * devDependency only), so nothing here can boot them. Each but the test kit, which has no module, is a module a
127
127
  * deployment adds to this manifest by hand —
128
128
  * `modules.mts` provides the Redis clients their stores need for that reason
129
129
  * — and `tools/composition` in the monorepo boots each beside every module
@@ -48,6 +48,7 @@ import { buildModules } from "../buildModules.mjs";
48
48
  import type { Switches } from "../configPath.mjs";
49
49
  import { createAppLogger, createAuditLogger, createLoggerAuditSink } from "../logger.mjs";
50
50
  import { auditSinkModuleFor } from "../modules.mjs";
51
+ import { keyStoreSectionSchema } from "../sections.mjs";
51
52
  import { capturedRenames, shippedAdapters } from "./library-references.fixture.mjs";
52
53
 
53
54
  const keyPair = generateKeyPairSync("ed25519", {
@@ -115,6 +116,12 @@ const baseConfig: Switches & AppConfig & Record<string, unknown> = {
115
116
  },
116
117
  rateLimit: { failMode: "open" },
117
118
  "standalone-in-memory-code-repository": { defaultExpiresIn: 600 },
119
+ // The in-process limiter's section as core's `reference.conf` ships it.
120
+ "core-rate-limiter-memory": {
121
+ maxBuckets: 10000,
122
+ defaultLimit: { limit: 60, windowSeconds: 60 },
123
+ limits: {},
124
+ },
118
125
  };
119
126
 
120
127
  const CLIENT_ID = "audit-client";
@@ -146,16 +153,18 @@ const testRepositoriesModule = defineModule({
146
153
  },
147
154
  });
148
155
 
156
+ // The template's `key-store` section, read by its own schema, without the
157
+ // section's relocation bridge.
149
158
  const testKeyStoreModule = defineModule({
150
- name: "test:key-store",
151
- requires: ["config"] as const,
159
+ name: "key-store",
160
+ section: { schema: keyStoreSectionSchema },
152
161
  provides: {
153
- keyStore: async ({ config: c }) => {
162
+ keyStore: async ({ section }) => {
154
163
  const factory = createKeyStoreFactory();
155
164
  registerBuiltinKeyStores(factory);
156
165
  return factory.create({
157
166
  type: "local",
158
- ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
167
+ ...(section.local ?? {}),
159
168
  });
160
169
  },
161
170
  },
@@ -0,0 +1,64 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * A relying party's back-channel logout endpoint on loopback, for the
19
+ * template's tests: it answers every POST with one status and keeps each
20
+ * `logout_token` it was sent. The composition reaches it only with its host
21
+ * in `core.outbound.internalHosts` (`withLoopbackRelyingParties`).
22
+ */
23
+
24
+ import { createServer } from "node:http";
25
+ import type { AddressInfo } from "node:net";
26
+ import type { AppConfig } from "@o3co/auth-provider-core";
27
+ import { withOutbound } from "@o3co/auth-provider-core/testing";
28
+
29
+ export interface BackchannelPeer {
30
+ /** The endpoint, `http://127.0.0.1:<port>/logout`. */
31
+ readonly uri: string;
32
+ /** Each `logout_token` posted to it, in order. */
33
+ readonly tokens: readonly string[];
34
+ close(): Promise<void>;
35
+ }
36
+
37
+ /** A loopback back-channel endpoint answering `status` to every POST. */
38
+ export async function backchannelPeer(status = 200): Promise<BackchannelPeer> {
39
+ const tokens: string[] = [];
40
+ const server = createServer((req, res) => {
41
+ let body = "";
42
+ req.setEncoding("utf8");
43
+ req.on("data", (chunk: string) => {
44
+ body += chunk;
45
+ });
46
+ req.on("end", () => {
47
+ const token = new URLSearchParams(body).get("logout_token");
48
+ if (token !== null) tokens.push(token);
49
+ res.writeHead(status);
50
+ res.end();
51
+ });
52
+ });
53
+ await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
54
+ const { port } = server.address() as AddressInfo;
55
+ return {
56
+ uri: `http://127.0.0.1:${port}/logout`,
57
+ tokens,
58
+ close: () => new Promise<void>((resolve) => server.close(() => resolve())),
59
+ };
60
+ }
61
+
62
+ /** `config` with loopback listed in `core.outbound.internalHosts`, so a `BackchannelPeer` is reachable. */
63
+ export const withLoopbackRelyingParties = (config: AppConfig): AppConfig =>
64
+ withOutbound(config, { internalHosts: ["127.0.0.1"] }) as AppConfig;
@@ -101,7 +101,6 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
101
101
  KEY_STORE_LOCAL_PRIVATE_KEY_PATH: "./config/jwt-private.pem",
102
102
  KEY_STORE_LOCAL_PUBLIC_KEY: "-----BEGIN PUBLIC KEY-----\nMCo=\n-----END PUBLIC KEY-----",
103
103
  KEY_STORE_LOCAL_PUBLIC_KEY_PATH: "./config/jwt-public.pem",
104
- OAUTH_JWT_LEGACY_TYP_ACCEPT: "true",
105
104
 
106
105
  // --- oauth tokens / policy ----------------------------------------
107
106
  OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN: "900",
@@ -139,7 +138,6 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
139
138
  OAUTH_SESSION_ENABLED: "false",
140
139
  OAUTH_AUTHORIZATION_GRANTS_AUTHORIZATION_CODE_ENABLED: "true",
141
140
  OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED: "true",
142
- OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY: "reject",
143
141
  OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED: "true",
144
142
  OAUTH_AUTHORIZATION_GRANTS_JWT_BEARER_ENABLED: "false",
145
143
 
@@ -316,6 +314,8 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
316
314
  const DELIBERATELY_UNSET: Readonly<Record<string, string>> = {
317
315
  OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS:
318
316
  "the variable of a removed key, and only captured — any value fails boot",
317
+ OAUTH_JWT_LEGACY_TYP_ACCEPT:
318
+ "the variable of a removed key, and only captured — any value fails boot",
319
319
  OAUTH_ACCESS_TOKEN_EXPIRES_IN:
320
320
  "renamed OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN, and only captured — set at any value, beside its new name or not, it fails boot",
321
321
  DEPLOYMENT_MODE:
@@ -341,7 +341,9 @@ const DELIBERATELY_UNSET: Readonly<Record<string, string>> = {
341
341
  OAUTH_GRANTS_REFRESH_TOKEN_ENABLED:
342
342
  "renamed OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_ENABLED, and only captured — set at any value, beside its new name or not, it fails boot",
343
343
  OAUTH_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY:
344
- "renamed OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY, and only captured — set at any value, beside its new name or not, it fails boot",
344
+ "the variable of a removed key, and only captured — any value fails boot",
345
+ OAUTH_AUTHORIZATION_GRANTS_REFRESH_TOKEN_UNKNOWN_FAMILY_POLICY:
346
+ "the variable of a removed key, and only captured — any value fails boot",
345
347
  OAUTH_GRANTS_CLIENT_CREDENTIALS_ENABLED:
346
348
  "renamed OAUTH_AUTHORIZATION_GRANTS_CLIENT_CREDENTIALS_ENABLED, and only captured — set at any value, beside its new name or not, it fails boot",
347
349
  OAUTH_GRANTS_JWT_BEARER_ENABLED:
@@ -766,7 +768,6 @@ describe("the shipped config boots with every documented override supplied as a
766
768
  // Each of these arrives from HOCON as a string. A leftover string is
767
769
  // not a cosmetic defect: `=== true` is how the runtime reads them.
768
770
  expect(sessionStoreSection(config).secure).toBe(false);
769
- expect(oauthSection(config).jwt.legacyTypAccept).toBe(true);
770
771
  expect(oauthSection(config).requireEmailVerified).toBe(true);
771
772
  expect(oauthSection(config).resourceIndicator?.enabled).toBe(true);
772
773
  expect(config.core?.federations?.google?.enabled).toBe(true);
@@ -1105,14 +1106,6 @@ describe("the shipped config boots with every documented override supplied as a
1105
1106
  });
1106
1107
  expect(sessionStoreSection(config).secure).toBe(expected);
1107
1108
  });
1108
-
1109
- it(`OAUTH_JWT_LEGACY_TYP_ACCEPT=${JSON.stringify(supplied)} resolves to ${expected}`, async () => {
1110
- const config = await bootParsed({
1111
- ...DOCUMENTED_ENV,
1112
- OAUTH_JWT_LEGACY_TYP_ACCEPT: supplied,
1113
- });
1114
- expect(oauthSection(config).jwt.legacyTypAccept).toBe(expected);
1115
- });
1116
1109
  }
1117
1110
 
1118
1111
  it("refuses a spelling it does not recognise rather than guessing", async () => {
@@ -1184,6 +1177,15 @@ describe("the shipped config boots with every documented override supplied as a
1184
1177
  await expect(booting).rejects.toThrow(/oauth\.authorize\.allowUnmarkedClients/);
1185
1178
  });
1186
1179
 
1180
+ it("refuses OAUTH_JWT_LEGACY_TYP_ACCEPT as the variable of a removed key, naming oauth.jwt.legacyTypAccept and the upgrade guide", async () => {
1181
+ for (const value of ["true", "false"]) {
1182
+ const booting = bootParsed({ ...DOCUMENTED_ENV, OAUTH_JWT_LEGACY_TYP_ACCEPT: value });
1183
+ await expect(booting).rejects.toMatchObject({ reason: "environment-variable-renamed" });
1184
+ await expect(booting).rejects.toThrow(/oauth\.jwt\.legacyTypAccept/);
1185
+ await expect(booting).rejects.toThrow(/docs\/upgrading-from-v0\.16\.0\.md/);
1186
+ }
1187
+ });
1188
+
1187
1189
  it("refuses an access-token default above the max, naming both keys", async () => {
1188
1190
  await expect(
1189
1191
  bootParsed({
@@ -44,6 +44,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
44
44
  import { buildModules } from "../buildModules.mjs";
45
45
  import { resolveConfigPaths } from "../configPath.mjs";
46
46
  import { templateReference } from "../modules.mjs";
47
+ import { keyStoreSectionSchema } from "../sections.mjs";
47
48
  import { installGracefulShutdown } from "../shutdown.mjs";
48
49
  import {
49
50
  type BothPhases,
@@ -187,16 +188,18 @@ const testRepositoriesModule = defineModule({
187
188
  },
188
189
  });
189
190
 
191
+ // The template's `key-store` section, read by its own schema, without the
192
+ // section's relocation bridge.
190
193
  const testKeyStoreModule = defineModule({
191
- name: "test:key-store",
192
- requires: ["config"] as const,
194
+ name: "key-store",
195
+ section: { schema: keyStoreSectionSchema },
193
196
  provides: {
194
- keyStore: async ({ config: c }) => {
197
+ keyStore: async ({ section }) => {
195
198
  const factory = createKeyStoreFactory();
196
199
  registerBuiltinKeyStores(factory);
197
200
  return factory.create({
198
201
  type: "local",
199
- ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
202
+ ...(section.local ?? {}),
200
203
  });
201
204
  },
202
205
  },
@@ -55,6 +55,7 @@ import { afterEach, describe, expect, it } from "vitest";
55
55
  import { buildModules } from "../buildModules.mjs";
56
56
  import { resolveConfigPaths } from "../configPath.mjs";
57
57
  import { templateReference } from "../modules.mjs";
58
+ import { keyStoreSectionSchema } from "../sections.mjs";
58
59
  import {
59
60
  type BothPhases,
60
61
  bothPhasesOf,
@@ -201,16 +202,18 @@ const testRepositoriesModule = defineModule({
201
202
  } as never,
202
203
  });
203
204
 
205
+ // The template's `key-store` section, read by its own schema, without the
206
+ // section's relocation bridge.
204
207
  const testKeyStoreModule = defineModule({
205
- name: "test:key-store",
206
- requires: ["config"] as const,
208
+ name: "key-store",
209
+ section: { schema: keyStoreSectionSchema },
207
210
  provides: {
208
- keyStore: async ({ config: c }) => {
211
+ keyStore: async ({ section }) => {
209
212
  const factory = createKeyStoreFactory();
210
213
  registerBuiltinKeyStores(factory);
211
214
  return factory.create({
212
215
  type: "local",
213
- ...((c as { "key-store"?: { local?: object } })["key-store"]?.local ?? {}),
216
+ ...(section.local ?? {}),
214
217
  });
215
218
  },
216
219
  },