@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.
- package/package.json +3 -3
- package/templates/standalone/README.ja.md +5 -4
- package/templates/standalone/README.md +23 -4
- package/templates/standalone/config/clients.yaml.example +11 -5
- package/templates/standalone/config/reference.conf +5 -2
- package/templates/standalone/package.json +7 -6
- package/templates/standalone/src/__tests__/all-modules-composition.fixture.mts +6 -10
- package/templates/standalone/src/__tests__/all-modules-composition.test.mts +2 -2
- package/templates/standalone/src/__tests__/audit-sink.test.mts +13 -4
- package/templates/standalone/src/__tests__/backchannel-peer.fixture.mts +64 -0
- package/templates/standalone/src/__tests__/documented-env-overrides.test.mts +14 -12
- package/templates/standalone/src/__tests__/federation-grants-composition.test.mts +7 -4
- package/templates/standalone/src/__tests__/federation-grants-survive-logout.test.mts +7 -4
- package/templates/standalone/src/__tests__/frontchannel-logout-csp.test.mts +168 -0
- package/templates/standalone/src/__tests__/google-session.fixture.mts +17 -7
- package/templates/standalone/src/__tests__/lifecycle-pending-close.test.mts +13 -12
- package/templates/standalone/src/__tests__/logout-session-cookie.test.mts +113 -0
- package/templates/standalone/src/__tests__/own-modules.test.mts +3 -35
- package/templates/standalone/src/__tests__/redis-assume-noeviction.test.mts +73 -4
- package/templates/standalone/src/__tests__/redis-stand-in.fixture.mts +98 -0
- package/templates/standalone/src/__tests__/replica-safety.test.mts +7 -4
- package/templates/standalone/src/__tests__/repositories-logger.test.mts +119 -0
- package/templates/standalone/src/__tests__/sections.test.mts +15 -3
- package/templates/standalone/src/__tests__/session-lifecycle.test.mts +8 -10
- package/templates/standalone/src/__tests__/session-logout-invalidates-token.test.mts +13 -4
- package/templates/standalone/src/__tests__/session-logout-lifecycle.test.mts +7 -7
- package/templates/standalone/src/__tests__/smoke.test.mts +13 -5
- package/templates/standalone/src/__tests__/startup-log-lines.test.mts +24 -8
- package/templates/standalone/src/__tests__/store-credential.test.mts +7 -5
- package/templates/standalone/src/__tests__/upstream-amr.acceptance.test.mts +7 -4
- package/templates/standalone/src/app.mts +2 -11
- package/templates/standalone/src/modules.mts +9 -3
- package/templates/standalone/src/sections.mts +3 -2
- package/templates/standalone/src/securityHeaders.mts +36 -0
- package/templates/standalone/tests/package-lock.json +95 -95
- package/templates/standalone/tests/package.json +1 -1
- 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
|
|
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.
|
|
32
|
+
"@types/node": "^26.6.4",
|
|
33
33
|
"typescript": "^6.0.3",
|
|
34
|
-
"vitest": "^5.0.
|
|
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-
|
|
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
|
|
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` | `
|
|
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-
|
|
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
|
|
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` | `
|
|
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
|
|
58
|
-
# client, or its `aud` contains it. `true` lets this one
|
|
59
|
-
# tokens issued to other clients (a
|
|
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
|
|
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.
|
|
40
|
+
"pino": "^10.4.0",
|
|
41
41
|
"prom-client": "^15.1.3",
|
|
42
|
-
"redis": "^6.
|
|
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.
|
|
49
|
+
"@types/node": "^26.6.4",
|
|
49
50
|
"@types/supertest": "^7.2.1",
|
|
50
|
-
"supertest": "^7.3.
|
|
51
|
+
"supertest": "^7.3.1",
|
|
51
52
|
"tsx": "^4.23.15",
|
|
52
53
|
"typescript": "^6.0.3",
|
|
53
|
-
"vitest": "^5.0.
|
|
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,
|
|
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:
|
|
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
|
|
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: "
|
|
151
|
-
|
|
159
|
+
name: "key-store",
|
|
160
|
+
section: { schema: keyStoreSectionSchema },
|
|
152
161
|
provides: {
|
|
153
|
-
keyStore: async ({
|
|
162
|
+
keyStore: async ({ section }) => {
|
|
154
163
|
const factory = createKeyStoreFactory();
|
|
155
164
|
registerBuiltinKeyStores(factory);
|
|
156
165
|
return factory.create({
|
|
157
166
|
type: "local",
|
|
158
|
-
...(
|
|
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
|
-
"
|
|
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: "
|
|
192
|
-
|
|
194
|
+
name: "key-store",
|
|
195
|
+
section: { schema: keyStoreSectionSchema },
|
|
193
196
|
provides: {
|
|
194
|
-
keyStore: async ({
|
|
197
|
+
keyStore: async ({ section }) => {
|
|
195
198
|
const factory = createKeyStoreFactory();
|
|
196
199
|
registerBuiltinKeyStores(factory);
|
|
197
200
|
return factory.create({
|
|
198
201
|
type: "local",
|
|
199
|
-
...(
|
|
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: "
|
|
206
|
-
|
|
208
|
+
name: "key-store",
|
|
209
|
+
section: { schema: keyStoreSectionSchema },
|
|
207
210
|
provides: {
|
|
208
|
-
keyStore: async ({
|
|
211
|
+
keyStore: async ({ section }) => {
|
|
209
212
|
const factory = createKeyStoreFactory();
|
|
210
213
|
registerBuiltinKeyStores(factory);
|
|
211
214
|
return factory.create({
|
|
212
215
|
type: "local",
|
|
213
|
-
...(
|
|
216
|
+
...(section.local ?? {}),
|
|
214
217
|
});
|
|
215
218
|
},
|
|
216
219
|
},
|