@o3co/create-auth-provider 0.17.0-rc.4 → 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 (32) hide show
  1. package/package.json +1 -1
  2. package/templates/standalone/README.ja.md +2 -3
  3. package/templates/standalone/README.md +2 -3
  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 +2 -1
  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 +11 -10
  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__/own-modules.test.mts +3 -35
  18. package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +73 -4
  19. package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +98 -0
  20. package/templates/standalone/src/__tests__/replica-safety.test.mts +7 -4
  21. package/templates/standalone/src/__tests__/sections.test.mts +15 -3
  22. package/templates/standalone/src/__tests__/session-lifecycle.test.mts +8 -10
  23. package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +13 -4
  24. package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +7 -7
  25. package/templates/standalone/src/__tests__/smoke.test.mts +13 -5
  26. package/templates/standalone/src/__tests__/startup-log-lines.test.mts +24 -8
  27. package/templates/standalone/src/__tests__/store-credential.test.mts +7 -5
  28. package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +7 -4
  29. package/templates/standalone/src/app.mts +2 -11
  30. package/templates/standalone/src/sections.mts +3 -2
  31. package/templates/standalone/src/securityHeaders.mts +36 -0
  32. 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.4",
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",
@@ -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
 
@@ -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
 
@@ -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.4",
3
+ "version": "0.17.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "engines": {
@@ -43,6 +43,7 @@
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
49
  "@types/node": "^26.6.4",
@@ -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",
@@ -315,6 +314,8 @@ const DOCUMENTED_ENV: Readonly<Record<string, string>> = {
315
314
  const DELIBERATELY_UNSET: Readonly<Record<string, string>> = {
316
315
  OAUTH_AUTHORIZE_ALLOW_UNMARKED_CLIENTS:
317
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",
318
319
  OAUTH_ACCESS_TOKEN_EXPIRES_IN:
319
320
  "renamed OAUTH_ACCESS_TOKEN_DEFAULT_EXPIRES_IN, and only captured — set at any value, beside its new name or not, it fails boot",
320
321
  DEPLOYMENT_MODE:
@@ -767,7 +768,6 @@ describe("the shipped config boots with every documented override supplied as a
767
768
  // Each of these arrives from HOCON as a string. A leftover string is
768
769
  // not a cosmetic defect: `=== true` is how the runtime reads them.
769
770
  expect(sessionStoreSection(config).secure).toBe(false);
770
- expect(oauthSection(config).jwt.legacyTypAccept).toBe(true);
771
771
  expect(oauthSection(config).requireEmailVerified).toBe(true);
772
772
  expect(oauthSection(config).resourceIndicator?.enabled).toBe(true);
773
773
  expect(config.core?.federations?.google?.enabled).toBe(true);
@@ -1106,14 +1106,6 @@ describe("the shipped config boots with every documented override supplied as a
1106
1106
  });
1107
1107
  expect(sessionStoreSection(config).secure).toBe(expected);
1108
1108
  });
1109
-
1110
- it(`OAUTH_JWT_LEGACY_TYP_ACCEPT=${JSON.stringify(supplied)} resolves to ${expected}`, async () => {
1111
- const config = await bootParsed({
1112
- ...DOCUMENTED_ENV,
1113
- OAUTH_JWT_LEGACY_TYP_ACCEPT: supplied,
1114
- });
1115
- expect(oauthSection(config).jwt.legacyTypAccept).toBe(expected);
1116
- });
1117
1109
  }
1118
1110
 
1119
1111
  it("refuses a spelling it does not recognise rather than guessing", async () => {
@@ -1185,6 +1177,15 @@ describe("the shipped config boots with every documented override supplied as a
1185
1177
  await expect(booting).rejects.toThrow(/oauth\.authorize\.allowUnmarkedClients/);
1186
1178
  });
1187
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
+
1188
1189
  it("refuses an access-token default above the max, naming both keys", async () => {
1189
1190
  await expect(
1190
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
  },
@@ -0,0 +1,168 @@
1
+ /*
2
+ * Copyright 2026 1o1 Co. Ltd.
3
+ *
4
+ * Licensed under the Apache License, Version 2.0 (the "License");
5
+ * you may not use this file except in compliance with the License.
6
+ * You may obtain a copy of the License at
7
+ *
8
+ * http://www.apache.org/licenses/LICENSE-2.0
9
+ *
10
+ * Unless required by applicable law or agreed to in writing, software
11
+ * distributed under the License is distributed on an "AS IS" BASIS,
12
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13
+ * See the License for the specific language governing permissions and
14
+ * limitations under the License.
15
+ */
16
+
17
+ /**
18
+ * The front-channel logout page, served through the template behind its
19
+ * security headers, works under the one policy the response carries: its
20
+ * relying party's frame and its redirect are allowed, and nothing else is.
21
+ * Every other answer keeps the template's policy.
22
+ */
23
+
24
+ import { createHash } from "node:crypto";
25
+ import request from "supertest";
26
+ import { describe, expect, it } from "vitest";
27
+ import {
28
+ authorize,
29
+ basic,
30
+ codeFrom,
31
+ compose,
32
+ login,
33
+ PKCE,
34
+ } from "./all-modules-composition.fixture.mjs";
35
+
36
+ const RP = {
37
+ id: "rp-fc",
38
+ secret: "rp-fc-secret-long-enough",
39
+ redirectUri: "https://rp-fc.test/cb",
40
+ frontchannelLogoutUri: "https://rp-fc.test:8443/frontchannel-logout",
41
+ postLogoutRedirectUri: "https://rp-fc.test/signed-out",
42
+ } as const;
43
+
44
+ /** A serialized policy as directive name → sources; a directive named twice fails. */
45
+ const parsePolicy = (policy: string): Map<string, string[]> => {
46
+ const directives = new Map<string, string[]>();
47
+ for (const part of policy.split(";")) {
48
+ const [name, ...sources] = part.trim().split(/\s+/);
49
+ if (name === undefined || name === "") continue;
50
+ if (directives.has(name)) throw new Error(`directive ${name} appears twice`);
51
+ directives.set(name, sources);
52
+ }
53
+ return directives;
54
+ };
55
+
56
+ /** The sources a fetch directive resolves to: its own, else `default-src`'s. */
57
+ const effective = (policy: Map<string, string[]>, directive: string): string[] =>
58
+ policy.get(directive) ?? policy.get("default-src") ?? [];
59
+
60
+ const decode = (value: string): string =>
61
+ value
62
+ .replace(/&quot;/g, '"')
63
+ .replace(/&#39;/g, "'")
64
+ .replace(/&lt;/g, "<")
65
+ .replace(/&gt;/g, ">")
66
+ .replace(/&amp;/g, "&");
67
+
68
+ /** How many `Content-Security-Policy` header lines the response carried, as sent. */
69
+ const policyHeaderCount = (response: object): number => {
70
+ const raw = (response as { res?: { rawHeaders?: unknown } }).res?.rawHeaders;
71
+ if (!Array.isArray(raw)) throw new Error("the response carries no raw headers");
72
+ return raw.filter(
73
+ (name, i) => i % 2 === 0 && String(name).toLowerCase() === "content-security-policy",
74
+ ).length;
75
+ };
76
+
77
+ describe("the front-channel logout page under the template's security headers", () => {
78
+ it("is allowed its relying party's frame and its redirect by the one policy it is served with", async () => {
79
+ const { app, handle } = await compose({
80
+ extraClients: {
81
+ [RP.id]: {
82
+ tokenEndpointAuthMethod: "client_secret_basic",
83
+ clientSecret: RP.secret,
84
+ allowedRedirectUris: [RP.redirectUri],
85
+ allowedScopes: ["openid"],
86
+ allowedGrantTypes: ["authorization_code"],
87
+ frontchannelLogoutUri: RP.frontchannelLogoutUri,
88
+ postLogoutRedirectUris: [RP.postLogoutRedirectUri],
89
+ firstParty: true,
90
+ },
91
+ },
92
+ });
93
+ try {
94
+ // A session the relying party joined by exchanging a code.
95
+ const { cookies } = await login(app);
96
+ const exchanged = await request(app)
97
+ .post("/oauth/token")
98
+ .set("Authorization", basic(RP))
99
+ .type("form")
100
+ .send({
101
+ grant_type: "authorization_code",
102
+ code: codeFrom(await authorize(app, cookies, RP)),
103
+ redirect_uri: RP.redirectUri,
104
+ code_verifier: PKCE.verifier,
105
+ });
106
+ expect(exchanged.status).toBe(200);
107
+
108
+ const res = await request(app)
109
+ .get("/oauth/logout")
110
+ .set("Accept", "text/html")
111
+ .set("Cookie", cookies)
112
+ .query({
113
+ id_token_hint: exchanged.body.id_token as string,
114
+ post_logout_redirect_uri: RP.postLogoutRedirectUri,
115
+ state: "s-1",
116
+ });
117
+
118
+ expect(res.status).toBe(200);
119
+ expect(res.headers["content-type"]).toBe("text/html; charset=utf-8");
120
+ expect(policyHeaderCount(res)).toBe(1);
121
+ const policy = parsePolicy(res.headers["content-security-policy"] as string);
122
+
123
+ // The frame: the relying party's, from the one origin the policy allows.
124
+ const srcs = [...res.text.matchAll(/<iframe src="([^"]*)"/g)].map((m) => decode(m[1] ?? ""));
125
+ expect(srcs).toHaveLength(1);
126
+ expect(new URL(srcs[0] ?? "").origin).toBe("https://rp-fc.test:8443");
127
+ expect(effective(policy, "frame-src")).toEqual(["https://rp-fc.test:8443"]);
128
+
129
+ // The redirect: one inline script, allowed by its hash, sending the
130
+ // browser to the registered URI with the state.
131
+ const scripts = [
132
+ ...res.text.matchAll(/<script data-target="([^"]*)" data-delay="(\d+)">([^<]*)<\/script>/g),
133
+ ];
134
+ expect(scripts).toHaveLength(1);
135
+ expect(res.text.match(/<script/g)).toHaveLength(1);
136
+ const [, target, , text] = scripts[0] ?? [];
137
+ expect(decode(target ?? "")).toBe(`${RP.postLogoutRedirectUri}?state=s-1`);
138
+ const hash = createHash("sha256")
139
+ .update(text ?? "", "utf8")
140
+ .digest("base64");
141
+ expect(effective(policy, "script-src")).toEqual([`'sha256-${hash}'`]);
142
+
143
+ // Nothing else.
144
+ expect(policy.get("default-src")).toEqual(["'none'"]);
145
+ expect(effective(policy, "style-src")).toEqual(["'none'"]);
146
+ expect(effective(policy, "connect-src")).toEqual(["'none'"]);
147
+ expect(policy.get("frame-ancestors")).toEqual(["'none'"]);
148
+ expect(res.headers["cache-control"]).toBe("no-store");
149
+ } finally {
150
+ await handle.dispose();
151
+ }
152
+ });
153
+
154
+ it("leaves the template's policy on every other answer", async () => {
155
+ const { app, handle } = await compose();
156
+ try {
157
+ const res = await request(app).get("/.well-known/openid-configuration");
158
+
159
+ expect(policyHeaderCount(res)).toBe(1);
160
+ const policy = parsePolicy(res.headers["content-security-policy"] as string);
161
+ expect(policy.get("default-src")).toEqual(["'none'"]);
162
+ expect(policy.get("frame-ancestors")).toEqual(["'none'"]);
163
+ expect(effective(policy, "frame-src")).toEqual(["'none'"]);
164
+ } finally {
165
+ await handle.dispose();
166
+ }
167
+ });
168
+ });