@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.
- package/package.json +1 -1
- package/templates/standalone/README.ja.md +2 -3
- package/templates/standalone/README.md +2 -3
- package/templates/standalone/config/clients.yaml.example +11 -5
- package/templates/standalone/config/reference.conf +5 -2
- package/templates/standalone/package.json +2 -1
- 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 +11 -10
- 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__/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__/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/sections.mts +3 -2
- package/templates/standalone/src/securityHeaders.mts +36 -0
- package/templates/versions.json +18 -18
package/package.json
CHANGED
|
@@ -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
|
|
|
@@ -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
|
|
|
@@ -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": {
|
|
@@ -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,
|
|
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",
|
|
@@ -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: "
|
|
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
|
},
|
|
@@ -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(/"/g, '"')
|
|
63
|
+
.replace(/'/g, "'")
|
|
64
|
+
.replace(/</g, "<")
|
|
65
|
+
.replace(/>/g, ">")
|
|
66
|
+
.replace(/&/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
|
+
});
|