From cea854ccdfceb33f74db12b3f825dbf0ee903316 Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Mon, 28 Sep 2026 16:58:08 -0500 Subject: [PATCH] Issuer and resource gates, retry floors, and authserver 0.2.0 alignment Brings main up to the current development line. The CHANGELOG's [Unreleased] section is the authoritative list; the themes are: - A failed metadata or JWKS fetch opens a retry floor (fetchFailureBackoffSeconds) instead of turning every wave of verification traffic into a fresh stalling fetch. - AS metadata is re-read on the verification path, and a jwks_uri rotation is followed under a stable kid. - Resource and issuer identifiers are gated wherever they are consumed: absolute URL with a scheme and a host, no fragment, whitespace refused, and a non-future server expiry ignored. - Internal error messages and the DPoP nonce no longer reach the WWW-Authenticate challenge or the JSON body. - resource_metadata can point at an AS-hosted PRM document. - ipaddr.js is loaded through a default import so the SSRF guard works under Node ESM. - authserver 0.2.0: access_denied and invalid_target are typed and kept out of the circuit breaker; VerifiedClaims.mayAct is deprecated. - The conformance catalog pin moves to 583a6d9. Two things keep what main already had rather than taking the development line's copy: the package versions stay on the 0.4.1-dev.0 line main was bumped to, and one CHANGELOG bullet is condensed to a single line to match the family's shape. --- .conformance-catalog-ref | 2 +- .../workflows/conformance-catalog-drift.yml | 22 +- CHANGELOG.md | 40 + CONTRIBUTING.md | 11 +- README.md | 5 + llm-full.txt | 22 +- package-lock.json | 55 +- package.json | 2 +- packages/fastmcp/demo/.env.example | 4 +- packages/fastmcp/demo/README.md | 10 +- packages/fastmcp/docs/user-guide.md | 39 +- packages/fastmcp/src/auth.ts | 28 +- .../fastmcp/tests/auth.integration.test.ts | 3 + packages/fastmcp/tests/auth.test.ts | 306 +++++- packages/hono/docs/user-guide.md | 27 +- packages/hono/src/authplaneHonoAuth.ts | 32 +- packages/hono/src/bearerAuth.ts | 13 +- packages/hono/src/errorResponse.ts | 18 +- packages/hono/src/requireScope.ts | 2 +- packages/hono/tests/auth.integration.test.ts | 1 + packages/hono/tests/authplaneHonoAuth.test.ts | 293 +++++- packages/hono/tests/authplaneOnError.test.ts | 10 +- packages/hono/tests/bearerAuth.test.ts | 81 +- packages/mcp/README.md | 2 +- packages/mcp/demo/.env.example | 8 +- packages/mcp/demo/README.md | 3 + packages/mcp/docs/user-guide.md | 61 +- packages/mcp/src/auth.ts | 78 +- packages/mcp/src/verifier.ts | 31 +- packages/mcp/tests/auth.integration.test.ts | 6 + packages/mcp/tests/auth.middleware.test.ts | 25 +- packages/mcp/tests/auth.test.ts | 316 +++++- packages/mcp/tests/verifier.test.ts | 24 +- packages/nestjs/docs/user-guide.md | 24 + .../application/authplane.exception-filter.ts | 23 +- .../nestjs/src/application/authplane.guard.ts | 17 +- .../nestjs/src/module/authplane.module.ts | 21 +- .../nestjs/src/module/authplane.options.ts | 2 +- .../authplane.exception-filter.test.ts | 86 +- .../tests/application/authplane.guard.test.ts | 33 + .../integration/auth.integration.test.ts | 94 +- .../tests/module/authplane.module.test.ts | 167 +++- .../tests/presentation/prm.controller.test.ts | 2 + packages/sdk/conformance-tests/README.md | 15 +- .../catalogAlignment.test.ts | 203 ++-- .../sdk/conformance-tests/conformanceCase.ts | 77 +- packages/sdk/conformance-tests/report.ts | 68 +- .../sdk/conformance-tests/reportWrite.test.ts | 38 +- .../test_jwt_and_dpop_conformance.test.ts | 150 ++- .../test_rfc8414_conformance.test.ts | 279 +++++- packages/sdk/docs/user-guide.md | 95 +- packages/sdk/src/auth/errors.ts | 31 + packages/sdk/src/auth/index.ts | 2 + packages/sdk/src/core/circuitPolicy.ts | 10 +- packages/sdk/src/core/claims.ts | 18 +- packages/sdk/src/core/client.ts | 81 +- packages/sdk/src/core/dpop.ts | 17 +- packages/sdk/src/core/errors.ts | 474 ++++++++- .../sdk/src/core/fetching/documentCache.ts | 344 +++++-- packages/sdk/src/core/fetching/metadataUrl.ts | 30 +- packages/sdk/src/core/prm.ts | 730 +++++++++++++- packages/sdk/src/core/requestContext.ts | 6 +- packages/sdk/src/core/resource.ts | 193 +++- packages/sdk/src/shared/ssrf.ts | 48 +- packages/sdk/tests/auth/index.test.ts | 4 + packages/sdk/tests/core/circuitPolicy.test.ts | 22 + packages/sdk/tests/core/claims.test.ts | 29 + .../sdk/tests/core/clientMoreBranches.test.ts | 41 + packages/sdk/tests/core/documentCache.test.ts | 938 +++++++++++++++++- packages/sdk/tests/core/dpopHelpers.test.ts | 58 +- packages/sdk/tests/core/errors.test.ts | 325 +++++- .../core/metadataRefreshOnVerify.test.ts | 583 +++++++++++ packages/sdk/tests/core/prm.test.ts | 307 +++++- .../sdk/tests/core/prmDocumentUrl.test.ts | 144 +++ .../sdk/tests/core/requestContext.test.ts | 18 + .../sdk/tests/core/resourceIndicator.test.ts | 653 ++++++++++++ .../tests/core/resourceMetadataUrl.test.ts | 196 ++++ packages/sdk/tests/core/ssrf.test.ts | 55 +- .../sdk/tests/core/ssrfBrokenImport.test.ts | 23 + .../sdk/tests/core/ssrfEsmInterop.test.ts | 68 ++ packages/sdk/tests/core/verifier.test.ts | 145 ++- scripts/manual-e2e-setup.sh | 12 +- 82 files changed, 8001 insertions(+), 578 deletions(-) create mode 100644 packages/sdk/tests/core/metadataRefreshOnVerify.test.ts create mode 100644 packages/sdk/tests/core/resourceIndicator.test.ts create mode 100644 packages/sdk/tests/core/resourceMetadataUrl.test.ts create mode 100644 packages/sdk/tests/core/ssrfBrokenImport.test.ts create mode 100644 packages/sdk/tests/core/ssrfEsmInterop.test.ts diff --git a/.conformance-catalog-ref b/.conformance-catalog-ref index efa9db0..bf2d5bd 100644 --- a/.conformance-catalog-ref +++ b/.conformance-catalog-ref @@ -1 +1 @@ -b4c758a7dac698d7fcacd32dafcd4bb2f5dbddaf +583a6d92412543ea352251c88f15f2c5a39d2593 diff --git a/.github/workflows/conformance-catalog-drift.yml b/.github/workflows/conformance-catalog-drift.yml index 0edadf9..c7c19c1 100644 --- a/.github/workflows/conformance-catalog-drift.yml +++ b/.github/workflows/conformance-catalog-drift.yml @@ -1,9 +1,11 @@ name: Conformance Catalog Drift -# Weekly early-warning check: does the LATEST conformance catalog (the default -# branch of AuthPlane/conformance, unpinned) contain cases the SDK does not yet -# cover? This runs the catalog-alignment meta-test against the tip of the -# catalog instead of the SHA pinned in `.conformance-catalog-ref`. +# Weekly early-warning check: has the LATEST conformance catalog (the default +# branch of AuthPlane/conformance, unpinned) moved away from what the SDK +# covers — either by carrying a case the SDK does not yet cover, or by renaming +# or dropping one the SDK still declares? This runs the catalog-alignment +# meta-test, which asserts both directions, against the tip of the catalog +# instead of the SHA pinned in `.conformance-catalog-ref`. # # It never blocks PR CI — this workflow has no `pull_request` trigger, so it # cannot gate a PR. On drift the alignment step FAILS, turning the scheduled run @@ -91,15 +93,19 @@ jobs: } >> "$GITHUB_STEP_SUMMARY" ;; failure) - echo "::warning::Conformance catalog drift — the latest catalog (default branch of AuthPlane/conformance) has cases the SDK does not yet cover. Add the missing conformanceCase(...) coverage, then bump .conformance-catalog-ref to the new SHA." + echo "::warning::Conformance catalog drift — the latest catalog (default branch of AuthPlane/conformance) no longer agrees with the SDK's conformanceCase(...) declarations. Read the failure for the direction and the case ids, then bump .conformance-catalog-ref to the new SHA." { echo "### Conformance catalog: drift detected ⚠️" echo "" - echo "The latest catalog (default branch) contains cases not yet covered by the SDK conformance suite." + echo "The latest catalog (default branch) and the SDK's declarations disagree. The alignment test asserts both directions, so this is one of:" + echo "" + echo "- the catalog carries a case the SDK does not cover — the usual case, a new case upstream;" + echo "- the SDK declares a case id the catalog no longer carries — a case renamed or dropped upstream;" + echo "- or the harness could not read a usable catalog at all — a truncated clone, a wrong \`CONFORMANCE_CATALOG_PATH\`. The failure text says so in as many words (\"this is a harness problem, not catalog drift\"), and nothing below applies until it is fixed." echo "" echo "**Next steps:**" - echo "1. Inspect the alignment-test failure in the job log above for the missing case ids." - echo "2. Add the corresponding \`conformanceCase(...)\` coverage under \`packages/sdk/\`." + echo "1. Inspect the alignment-test failure in the job log above — each line names the case id and which direction it broke." + echo "2. Add the missing \`conformanceCase(...)\` coverage under \`packages/sdk/\`, or update the declarations whose ids the catalog dropped." echo "3. Bump \`.conformance-catalog-ref\` to the new catalog SHA so CI pins the adopted cases." } >> "$GITHUB_STEP_SUMMARY" ;; diff --git a/CHANGELOG.md b/CHANGELOG.md index b95045f..775f160 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,46 @@ and this project follows [Semantic Versioning](https://semver.org/spec/v2.0.0.ht ## [Unreleased] +### Added + +- `@authplane/sdk` — new `resourceMetadataUrl` option on `client.resource(...)` (and on every adapter) pointing the `resource_metadata` challenge parameter at a PRM document hosted elsewhere, such as the one authserver 0.2.0 serves per registered Resource; the default stays the URL derived from `resource`, and the locally served document and its route are unchanged. Validated at construction with a `TypeError`: absolute with a scheme and a host, `http`/`https` only, no fragment, no userinfo, an RFC 3986 §3.4 query, and no `"`, `\`, whitespace or C0 control anywhere — the value is advertised exactly as typed, so those octets would reach the `WWW-Authenticate` quoted-string. +- `@authplane/sdk` — new `@authplane/sdk/core` helper `validateResourceIndicator(resource)`: the RFC 8707 §2 resource-indicator gate the `AuthplaneResource` constructor runs, exported for callers that want to check a configured identifier ahead of construction. Throws `TypeError`; see the corresponding entry under **Changed** for what it rejects. +- `@authplane/sdk` — new `@authplane/sdk/core` helper `validateIssuerIdentifier(issuer)`, the issuer-side counterpart to `validateResourceIndicator`. +- `@authplane/sdk` — new `AccessDeniedError` (`access_denied`, 403: the exchanging client is not allowlisted on the target Resource — an operator fix, unlike `ConsentRequiredError`) and `InvalidTargetError` (`invalid_target`, 400, RFC 8707 §2.2: `resource` does not match a granted resource byte for byte), mapped by `mapOAuthError` and exported from `@authplane/sdk/core` and `@authplane/sdk/auth`. +- `@authplane/sdk` — new `@authplane/sdk/core` helper `wwwAuthenticateChallenges(error, { schemes, algs, ... })`: one RFC 6750 §3 header value per scheme, so a resource accepting both `Bearer` and `DPoP` can advertise both (RFC 9449 §7.2) and emit the §7.1 `algs` parameter, whose values are validated against the supported set rather than escaped. +- `@authplane/sdk` — new `@authplane/sdk/core` helper `errorResponseBody(error, options)`: the RFC 6750 §3 JSON error body, built from the error code and fixed description `wwwAuthenticate()` already uses. + +### Changed + +- `@authplane/sdk` — a resource indicator carrying a URI fragment is now rejected with a `TypeError` at construction (RFC 8707 §2) instead of being silently dropped, where it survived into the PRM document's `resource` member and made conformant clients discard it. **Migration**: drop the fragment before upgrading — a deployment configured with one now fails at startup. +- `@authplane/sdk` — a resource identifier must now be an absolute URL with a scheme and a host, rejected with a `TypeError` at construction otherwise (RFC 8707 §2, RFC 9728 §3). A relative `/mcp`, a scheme-relative `//api.example.com/mcp` or an opaque `urn:example:api` previously derived a malformed document URL. **Migration**: configure `resource` as a full URL; `http://localhost:8080/mcp` is still accepted. +- `@authplane/sdk` — the derived PRM document URL now preserves the resource identifier's query (RFC 9728 §3), and a query outside the RFC 3986 §3.4 grammar is rejected at construction, since those octets would corrupt the `resource_metadata` quoted-string. **Migration**: update any hard-coded expectation of the query-less URL, and percent-encode brackets, spaces and quotes in the configured `resource`. +- `@authplane/sdk` — a resource identifier whose authority carries userinfo is now rejected with a `TypeError` at construction (RFC 9110 §4.2.4). The credential was published to unauthenticated callers in the PRM document and the 401 challenge, and the derivations drop userinfo, so the document never matched the URL it was fetched from. A port, an IPv6 literal and an `@` in the path stay accepted. **Migration**: carry the credential in a header instead. +- `@authplane/sdk` — `buildPrm(issuer, resource, scopes)` now runs the same resource-identifier gate the `AuthplaneResource` constructor runs. It was the one public path that could still emit a document whose `resource` member conformant clients discard. Only direct callers of the export change. **Migration**: correct a fragment-bearing, relative, opaque, out-of-grammar or userinfo-bearing identifier before upgrading. +- `@authplane/sdk` — `buildPrm()` and `AuthplaneClient.create()` now reject an issuer carrying a query, fragment or userinfo component, or that is not an absolute URL with a scheme and a host (RFC 8414 §2/§3.1, RFC 9110 §4.2.4). **Migration**: `buildPrm` previously published the issuer verbatim in `authorization_servers`, credentials included — correct any such issuer before upgrading. +- **BREAKING** `@authplane/sdk` — an issuer identifier containing whitespace or a control character is now rejected with a `TypeError` by `AuthplaneClient.create()`, `buildPrm()` and `buildMetadataUrl()` (RFC 3986 §2). `new URL` silently trimmed or stripped the byte, so the `.well-known` location was derived from the cleaned string while the raw one was published in `authorization_servers` and compared as the expected `iss` (RFC 8414 §3.3) — every token rejected on an identity comparison that reads as identical in a log. The message names the codepoint and its offset, never the value. **Migration**: remove the stray byte before upgrading — a trailing newline in an environment variable is the usual source. +- `@authplane/sdk` — `access_denied` and `invalid_target` are now listed explicitly as OAuth answers that never trip the circuit breaker (a healthy AS enforcing policy is not an outage). +- `@authplane/sdk` — introspection revocation configured without complete AS credentials (`asCredentials`, or `clientId` + `clientSecret`) now warns at construction that authserver ≥ 0.1.2 answers unauthenticated introspection with `active: false` and every token will be rejected; the first `active: false` on a token that passed local JWT verification logs the runtime-client requirement, once per resource. +- Docs — token-exchange sections explain `access_denied` vs `consent_required` and the `PATCH /admin/resources/{id}` allowlist step; introspection sections state the RS client must be confidential and the issuing client or a runtime-client of the Resource; README gains a Compatibility section (tested against authserver 0.2.0, introspection-based revocation needs ≥ 0.1.2); demo docs and `scripts/manual-e2e-setup.sh` drop `AUTHPLANE_*_ENABLED=true` (on by default since authserver 0.2.0) and the setup script accepts `AUTHSERVER_REF`. +- `@authplane/sdk` — **BREAKING**: `wwwAuthenticate(error, options)` now emits a fixed `error_description` chosen by the `error=` code instead of the exception message, which disclosed the unknown `kid`, the rejected `typ` or the expected `aud` to an unauthenticated caller; sanitising prevented header injection, not disclosure. **Migration**: log `error.message` server-side, or pass `verboseDescription: true` to restore the old text for local debugging only. +- **BREAKING** `@authplane/mcp`, `@authplane/hono`, `@authplane/nestjs` — the JSON error body now carries the fixed `error_description` the challenge carries, not the exception message, and names `invalid_dpop_proof` where it previously said `invalid_token`. **Migration**: log `error.message` server-side; these adapters do not accept `verboseDescription`, which is a core-only escape hatch. +- **BREAKING** `@authplane/mcp` — `tokenVerifier.verifyAccessToken()` now rejects with the fixed per-code sentence instead of core's message, which the MCP SDK's `requireBearerAuth` splices into `error_description=`. The original error stays on `.cause`. + +### Deprecated + +- `@authplane/sdk` — `VerifiedClaims.mayAct`: authserver 0.2.0 no longer issues `may_act`, so the getter always returns `undefined` against it; removed in the next minor. + +### Fixed + +- `@authplane/sdk` — the SSRF guard no longer rejects every address when the SDK is loaded by Node's own ESM loader: `ipaddr.js` is CommonJS, so the namespace import resolved to an object without `parse`, the resulting `TypeError` was swallowed, and `isIpAllowed()` returned `false` for all issuers (only `ssrfProtection: false` got past it). The import is now a default import, a `TypeError` from the import shape propagates instead of being read as "blocked", and a test loads the built module under `node` itself, since vitest's interop hid the defect. +- `@authplane/sdk` — a failed metadata or JWKS fetch now opens a retry floor of `fetchFailureBackoffSeconds` (new `AuthplaneClient.create()` option, default `30`, clamped to `[1s, the cache's refresh interval]`; opt out with a refresh interval of `0`). **Impact:** on a cold start against an unreachable AS every `verify()` now fails fast with the stored error until the window closes, even if the AS recovers inside it; cached documents keep being served, and the first suppressed attempt per window logs a warning naming the document. +- `@authplane/sdk` — a resource server that only verifies tokens now re-reads the authorization server metadata once `metadataRefreshSeconds` elapses, and follows a rotated `jwks_uri`. Previously metadata was read once at `create()` and the JWKS kept being fetched from the URI captured then, until the AS withdrew it and every token failed. A `kid` miss forces a re-read, floored at one per `min(metadataRefreshSeconds, 60)` seconds; `metadataRefreshSeconds: 0` opts out of both the refresh interval and that floor. +- `@authplane/sdk` — a server expiry at or before the moment the document was cached (`Cache-Control: max-age=0`, or a stale `Expires:` header) is now treated as no preference, so the configured refresh interval governs. Previously it left the metadata or JWKS document permanently expired, so every verification took the synchronous re-fetch path — one upstream fetch per request, driven by an unauthenticated caller, since the cache is read before the signature is checked. A future server expiry still shortens the interval. +- `@authplane/sdk` — the issuer and resource gates now quote and escape the identifier they echo, so a rejection can no longer carry a raw control character into a startup log; the redaction itself is unchanged, and a `\` or `"` in an echoed identifier now appears as its JSON escape. +- `@authplane/mcp` — `requireScope(scope, authInfo)` now throws core `InsufficientScope` carrying the missing scope instead of a plain `Error`, so a host that maps `AuthplaneError` answers 403 with an `insufficient_scope` challenge naming that scope rather than a JSON-RPC internal error or a generic 500. +- `@authplane/sdk` — `InsufficientScope` now carries the optional `requiredScopes` it was raised for, and `wwwAuthenticate()` falls back to it for `scope="…"` when the caller passes none; an explicit `scope` option still wins. +- `@authplane/sdk` — a DPoP proof nonce mismatch no longer names the expected or the received nonce, so the RFC 9449 §9 resource-server nonce is no longer handed back in the 401 challenge it was rejected by. + ## [0.4.0] - 2026-08-27 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 034817b..37fc33c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -105,14 +105,16 @@ parent-dir/ └── oauth-sdk-conformance-catalog.yaml ``` -With that layout in place, `npm test` auto-discovers the catalog — no configuration required. Override the path with `CONFORMANCE_CATALOG_PATH` if the catalog lives elsewhere: +With that layout in place, `npm test` auto-discovers the catalog — no configuration required. Keep the sibling checkout **at the pinned SHA**: the alignment test asserts that the catalog and the SDK's `conformanceCase(...)` declarations agree in both directions, so a sibling checkout sitting on any other revision fails it. Running against a catalog older than the pin reports the cases adopted since as declarations the catalog does not carry; running against a newer one reports its new cases as uncovered. Neither is a defect in your branch — re-run `git -C conformance checkout "$(cat ts-sdk/.conformance-catalog-ref)"` first. + +Override the path with `CONFORMANCE_CATALOG_PATH` if the catalog lives elsewhere: ```bash CONFORMANCE_CATALOG_PATH=/path/to/oauth-sdk-conformance-catalog.yaml \ npm run test -w @authplane/sdk ``` -If the catalog isn't available at all, skip the two catalog-dependent tests with: +If the catalog isn't available at all, skip the catalog-dependent tests with: ```bash AUTHPLANE_CONFORMANCE_SKIP_CATALOG=1 npm test @@ -164,7 +166,7 @@ The end-to-end demo exercises the FastMCP and MCP adapters against a local Authp Prerequisites: -1. OAuth server running locally on `:9000`/`:9001` with client credentials, token exchange, and DPoP enabled. +1. OAuth server (authserver 0.2.0) running locally on `:9000`/`:9001` — client credentials, token exchange and DPoP are on by default since authserver 0.2.0. 2. Demo client registration with required grant types and scopes. 3. Adapter demo server (`packages/fastmcp/demo/run.sh` or `packages/mcp/demo/run.sh`). 4. Demo client execution (matrix client). @@ -192,12 +194,13 @@ bash scripts/manual-e2e-smoke.sh --adapter fastmcp --skip-setup Optional overrides: - `AUTHSERVER_DIR=/path/to/authserver` +- `AUTHSERVER_REF=v0.2.0` — check out that ref of the authserver checkout before building (default: leave it as is) - `ISSUER_URL=http://localhost:9000` - `RESOURCE_URL=http://localhost:8080/mcp` ### Common demo failures -- `client_credentials grant is not enabled` — OAuth server is missing `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true`. +- `access_denied` on a token exchange — the exchanging client is not allowlisted on the target Resource (`policy.exchange.allowed_client_ids` / `policy.runtime.client_ids`). - `client is not authorized for this grant type` — client registration is missing `urn:ietf:params:oauth:grant-type:token-exchange`. - `requested scope is invalid or not allowed` — requested scopes are not registered or assigned to the demo client. - `invalid API key` — admin API requests are using a different key than the server startup key. diff --git a/README.md b/README.md index e6f8f60..bac6ca2 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,11 @@ OAuth, JWT validation, and MCP-authentication primitives for Node.js. Ships fram - Node.js 22 LTS (or newer) - TypeScript consumers: `moduleResolution` set to `bundler`, `node16`, or `nodenext` (required for the package `exports` subpaths) +## Compatibility + +- Tested against authserver 0.2.0. +- Introspection-based revocation (`IntrospectionRevocation`) requires authserver 0.1.2 or newer, and a resource-server client that is confidential and either the issuing client or a runtime-client of the Resource. + ## Quickstart ```ts diff --git a/llm-full.txt b/llm-full.txt index 45ef159..af51ead 100644 --- a/llm-full.txt +++ b/llm-full.txt @@ -32,6 +32,7 @@ Unified TypeScript SDK with framework adapters. It centralizes: ## Runtime and Tooling - Node.js `>=22` +- authserver: tested against 0.2.0; introspection-based revocation requires authserver `>=0.1.2` and an RS client that is confidential and either the issuing client or a runtime-client of the Resource (`authserver admin resource runtime-client add --client-id --slug `) - npm workspaces (`package.json` `workspaces` + `package-lock.json`) - TypeScript + Vitest + Biome @@ -173,6 +174,18 @@ main().catch((err) => { }); ``` +Exchange answers that are policy, not outages (neither trips the circuit +breaker): `access_denied` (403, `AccessDeniedError`) on a cross-client exchange +means the operator has not allowlisted the exchanging client on the target +Resource — unlike `consent_required`, re-prompting the user will not fix it; +`invalid_target` (400, `InvalidTargetError`) means `resource` does not match a +granted resource byte for byte (a trailing slash counts). Operator step for each +MCP server exchanging for a downstream resource it does not act as: +`PATCH /admin/resources/{id}` with +`{"policy": {"exchange": {"allowed_client_ids": [""]}}}`. +A client exchanging a token issued to itself, fronted exchanges and Broker +resources need nothing. + Run it with the same env vars the adapter demos use: ```bash @@ -186,11 +199,8 @@ npx tsx path/to/demo-client.ts ### Local authserver requirements -For local end-to-end demos, run the Authplane authserver on `http://127.0.0.1:9000` with: +For local end-to-end demos, run the Authplane authserver (0.2.0; client credentials, token exchange and DPoP are on by default since 0.2.0) on `http://127.0.0.1:9000` with: -- `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true` -- `AUTHPLANE_TOKEN_EXCHANGE_ENABLED=true` -- `AUTHPLANE_DPOP_ENABLED=true` - `AUTHPLANE_TOKEN_EXCHANGE_ALLOW_SELF_EXCHANGE=true` (needed for same-client demo flows) The OAuth client used by demos must be registered with: @@ -205,8 +215,8 @@ The OAuth client used by demos must be registered with: ## Common Local Demo Pitfalls -- `client_credentials grant is not enabled` - - OAuth server is missing `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true` +- `access_denied` on a token exchange + - the exchanging client is not in the target Resource's `policy.exchange.allowed_client_ids` / `policy.runtime.client_ids` (operator fix, not a consent prompt) - `client is not authorized for this grant type` - client registration is missing `urn:ietf:params:oauth:grant-type:token-exchange` - `requested scope is invalid or not allowed` diff --git a/package-lock.json b/package-lock.json index 243a244..a9d347e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -806,13 +806,6 @@ } } }, - "node_modules/@fastify/ajv-compiler/node_modules/fast-uri": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.4.tgz", - "integrity": "sha512-GntYZbd2KSiFfoZI3Y02rXKihfsPwdWfiHrwKVLuU1i810D0SYw7fCarLxaRO2VvneTrbzCxSz3GnvEfUiApug==", - "dev": true, - "license": "MIT" - }, "node_modules/@fastify/cors": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-9.0.1.tgz", @@ -2397,6 +2390,22 @@ } } }, + "node_modules/ajv/node_modules/fast-uri": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.8.tgz", + "integrity": "sha512-GZMtZUTNRpOVIECoXwLNZS5xUGE+mVNbTB8h/7Rwh2TFWcBQiPzTgyZi05BF9UMZKkLJv8XBRJTlU7zg8+ZfMg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ansi-regex": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz", @@ -3479,13 +3488,6 @@ "rfdc": "^1.2.0" } }, - "node_modules/fast-json-stringify/node_modules/fast-uri": { - "version": "2.4.4", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.4.tgz", - "integrity": "sha512-GntYZbd2KSiFfoZI3Y02rXKihfsPwdWfiHrwKVLuU1i810D0SYw7fCarLxaRO2VvneTrbzCxSz3GnvEfUiApug==", - "dev": true, - "license": "MIT" - }, "node_modules/fast-querystring": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/fast-querystring/-/fast-querystring-1.1.2.tgz", @@ -3504,20 +3506,11 @@ "license": "MIT" }, "node_modules/fast-uri": { - "version": "3.1.5", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", - "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fastify" - }, - { - "type": "opencollective", - "url": "https://opencollective.com/fastify" - } - ], - "license": "BSD-3-Clause" + "version": "2.4.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.4.7.tgz", + "integrity": "sha512-b8mggfg2R+m2OAb3Z+2HcJxUgU+2cY1JQondBDyXLdrEKNPxd96MvnB5yWKZdupsuX6GdBaAZHZo9msHX/Bb7A==", + "dev": true, + "license": "MIT" }, "node_modules/fastify": { "version": "4.29.1", @@ -4242,9 +4235,9 @@ "license": "ISC" }, "node_modules/ip-address": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.4.0.tgz", - "integrity": "sha512-oSK96Grm3aP6OrS263xVxbNDGVL7rzBtYdpGqlDG8iQdoenDoTs/nkki+DflYbAEE8Xl6o5YxhxlrKvI3nqKXQ==", + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", "license": "MIT", "engines": { "node": ">= 12" diff --git a/package.json b/package.json index f96d9b6..33e082e 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "overrides": { "ip-address": "^10.4.0", "ajv": { - "fast-uri": "^3.1.5" + "fast-uri": "^3.1.6" } }, "engines": { diff --git a/packages/fastmcp/demo/.env.example b/packages/fastmcp/demo/.env.example index 75a71ac..90ccd1a 100644 --- a/packages/fastmcp/demo/.env.example +++ b/packages/fastmcp/demo/.env.example @@ -7,6 +7,8 @@ AUTHPLANE_ISSUER=http://localhost:9000 # This server's base URL (resource is derived as BASE_URL/mcp) AUTHPLANE_BASE_URL=http://localhost:8080 -# Client secret for introspection (registered with the authorization server) +# Client secret for introspection (registered with the authorization server). +# Required: an empty secret makes introspection unauthenticated, and +# authserver >= 0.1.2 answers that with active:false, rejecting every token. # Set this from your local authserver provisioning. AUTHPLANE_CLIENT_SECRET= diff --git a/packages/fastmcp/demo/README.md b/packages/fastmcp/demo/README.md index 63378e2..9f346e4 100644 --- a/packages/fastmcp/demo/README.md +++ b/packages/fastmcp/demo/README.md @@ -14,16 +14,14 @@ Tokens must carry the scope for the specific tool being called. A token with onl ## Prerequisites - Node.js 22+ -- The **authserver authorization server** running locally with these token settings: - - `AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true` - - `AUTHPLANE_TOKEN_EXCHANGE_ENABLED=true` - - `AUTHPLANE_DPOP_ENABLED=true` +- The **authserver authorization server** (0.2.0 — client credentials, token exchange and DPoP are on by default since 0.2.0) running locally with: - `AUTHPLANE_TOKEN_EXCHANGE_ALLOW_SELF_EXCHANGE=true` (required when the same client performs both `client_credentials` and `token_exchange`, as in `demo-fastmcp-dpop.ts`) -Start `authserver` with Docker Compose from the `authserver` repository: +Start `authserver` with Docker Compose from the `authserver` repository, at the `v0.2.0` tag: ```bash cd /path/to/authserver + git checkout v0.2.0 export AUTHPLANE_SESSION_SECRET="$(openssl rand -hex 32)" export AUTHPLANE_ADMIN_API_KEY="$(openssl rand -hex 32)" docker compose -f deploy/docker-compose.sqlite.yml up -d --build @@ -77,6 +75,6 @@ MCP Client ──Bearer JWT──► mcpserver.ts (port 8080) **`authplaneFastMcpAuth()`** — wires up the verifier and auth provider in one call. The `scopes` list advertises supported scopes in the Protected Resource Metadata (`/.well-known/oauth-protected-resource`); it does **not** require all scopes to be present in every token. -**`IntrospectionRevocation`** — enables RFC 7662 token introspection on every `verify()` call using the adapter-supplied `asCredentials`. When `active: false` is returned, the token is rejected. +**`IntrospectionRevocation`** — enables RFC 7662 token introspection on every `verify()` call using the adapter-supplied `asCredentials`. When `active: false` is returned, the token is rejected. The demo client must be confidential and either the issuing client or a runtime-client of the resource (`authserver admin resource runtime-client add --client-id --slug `); since authserver 0.1.2 any other caller gets `active: false` and every token is rejected. > The MCP-adapter equivalent of this demo also ships a `consent_demo` tool that surfaces MCP's URL elicitation flow (`-32042`). It is intentionally **not** included here: fastmcp 3.35.0 catches every non-`UserError` thrown from a tool handler and wraps it as `{ isError: true, content: [...] }`, so a `UrlElicitationRequiredError` thrown from `client.exchange()` would never reach the JSON-RPC wire. Use the lower-level `@authplane/mcp` adapter to demo the flow end-to-end. See `packages/fastmcp/docs/user-guide.md` → URL elicitation for the full Limitation note. diff --git a/packages/fastmcp/docs/user-guide.md b/packages/fastmcp/docs/user-guide.md index 9270094..e267e84 100644 --- a/packages/fastmcp/docs/user-guide.md +++ b/packages/fastmcp/docs/user-guide.md @@ -7,6 +7,7 @@ Complete reference for the Authplane adapter for FastMCP. Starts with the quicks - [Install](#install) - [Quickstart](#quickstart) - [`authplaneFastMcpAuth(options)` reference](#authplanefastmcpauthoptions-reference) +- [Where the PRM document lives](#where-the-prm-document-lives) - [Session shape](#session-shape) - [Scope enforcement](#scope-enforcement) - [Resource URL from `baseUrl` + `mcpPath`](#resource-url-from-baseurl--mcppath) @@ -88,6 +89,7 @@ The adapter produces: | `revocationChecker` | `RevocationChecker \| IntrospectionRevocation` (optional) | Enable real-time revocation checking. | | `inboundDPoP` | `InboundDPoPOptions` (optional) | Per-resource inbound DPoP policy (RFC 9449 §7.1 + RFC 9728 §2). Presence is the on/off switch for advertising DPoP support in PRM and for accepting DPoP-bound tokens. See [DPoP-bound tokens](#dpop-bound-tokens). | | `failClosed` | `boolean` (optional, default `false`) | When `true`, revocation-checker errors reject the token (`TokenRevoked`) instead of accepting it. | +| `resourceMetadataUrl` | `string` (optional) | Absolute URL advertised as `resource_metadata=` on every challenge, overriding the URL derived from `resource`. See [Where the PRM document lives](#where-the-prm-document-lives). | | `allowedAlgorithms` | `string[]` (optional) | Allowed JWT `alg` values. Dangerous algorithms (`none`, `HS*`) are always rejected. Defaults to the SDK allow-list. | | `clockSkewSeconds` | `number` (optional) | Applied to `exp`/`nbf`/`iat` checks. DPoP proof age uses `inboundDPoP.clockSkewSeconds` independently. | @@ -103,7 +105,20 @@ The adapter produces: | `authenticate` | FastMCP `authenticate` callback | Plug into `new FastMCP({ authenticate: auth.authenticate, ... })`. Parses the bearer (or `DPoP ...`) header, verifies the token, and returns the session. | | `oauth` | FastMCP `oauth` config | Plug into `new FastMCP({ oauth: auth.oauth, ... })`. Publishes the PRM. | | `protectedResourceMetadata` | `ProtectedResourceMetadata` | The RFC 9728 JSON payload. | -| `protectedResourceMetadataUrl` | `string` | URL clients should fetch for the PRM. | +| `protectedResourceMetadataUrl` | `string` | URL advertised as `resource_metadata=` — the configured `resourceMetadataUrl` when set, the URL derived from `resource` otherwise. | + +## Where the PRM document lives + +RFC 9728 does not say who has to host the metadata document, only what a client finds when it follows the `resource_metadata` parameter of a `WWW-Authenticate` challenge. Two topologies work. + +**(a) Resource-hosted — the default.** This server serves the document itself at the URL derived from `resource`, `/.well-known/oauth-protected-resource[/path]`, and every challenge points there. Nothing to configure. Pass `auth.oauth` to `new FastMCP({ oauth })` and FastMCP publishes it. + +**(b) AS-hosted.** `authserver` >= 0.2.0 serves an RFC 9728 document for every registered Resource at `/.well-known/oauth-protected-resource/{ref}`, where `{ref}` is the Resource URI's path suffix (RFC 9728 §3.1) or its slug. Set `resourceMetadataUrl` to that URL and this server stops advertising its own; it only points at the AS's. Use it when the resource server cannot host well-known paths — a platform that owns `/.well-known`, a proxy that strips it, a resource mounted under a path it does not control. + +Only the advertisement moves. The `oauth` block still publishes this server's own document, and its `resource` member still names this server's identifier. So the two documents can be served side by side during a migration, and switching back is a config change. + +Whichever hosts it, RFC 9728 §3.3 binds the document to this server: the `resource` value **inside** the document must equal the URL clients call, byte for byte, or a conformant client discards the document — and the resource server then looks unreachable rather than misconfigured. So the Resource URI registered at the authorization server, the `resource` configured here, and this server's public URL must be the same string; a trailing slash or an `http`/`https` difference is enough to break it. + ## Session shape @@ -188,6 +203,14 @@ const auth = await authplaneFastMcpAuth({ `IntrospectionRevocation.get()` returns the marker singleton; the adapter then calls `authserver`'s RFC 7662 introspection endpoint on every token verification, and tokens with `active: false` are rejected. Adds one round-trip per authenticated request. Custom `RevocationChecker` callbacks are supported for DB-backed allowlists. +The introspecting client must be **confidential** (it needs a `clientSecret`) **and** either the client that was issued the token or a runtime-client of the Resource named in the token's `aud`. Since authserver 0.1.2 every other caller — a public (secret-less) client included — receives `{"active": false}`, which the SDK reads as "revoked", so a resource server introspecting with the wrong credentials silently rejects every token. Register the resource server on its Resource with: + +```bash +authserver admin resource runtime-client add --client-id --slug +``` + +A public client cannot introspect at all. + ## DPoP-bound tokens The adapter parses both `Authorization: Bearer ` and `Authorization: DPoP ` headers, and when a `DPoP` proof header is present it verifies the proof against the token's `cnf.jkt` binding — but only when the resource has opted in via `inboundDPoP`. @@ -327,6 +350,20 @@ The MCP client receives: Consent errors without a `consentUrl` pass through unchanged; non-consent errors are re-thrown as-is. +**Operator step.** For each MCP server that exchanges for a downstream resource it does not itself act as, the operator must allowlist the exchanging client on the target Resource: + +```http +PATCH /admin/resources/{id} +{"policy": {"exchange": {"allowed_client_ids": [""]}}} +``` + +A client exchanging a token issued to itself, a fronted exchange and a Broker resource need nothing. + +Two failure answers from the AS are policy, not outages, and neither counts toward the circuit breaker: + +- `access_denied` (HTTP 403, `AccessDeniedError`) on a cross-client exchange means the operator has not allowlisted the exchanging client on the target Resource. Unlike `consent_required`, re-prompting the user will not fix it. +- `invalid_target` (HTTP 400, `InvalidTargetError`, RFC 8707 §2.2) means the `resource` string does not match a granted resource exactly — the comparison is byte for byte, so a trailing slash counts. + > **Limitation — fastmcp swallows `McpError` from tool handlers.** As of fastmcp `3.35.0`, the tool-call dispatch catches every error that is not a `UserError` and wraps it as `{ isError: true, content: [...] }` in the tool result. That means an `UrlElicitationRequiredError` thrown from `client.exchange()` inside `execute()` reaches the client as a tool error, not as a JSON-RPC `-32042` response — the example payload above is the canonical shape, not what fastmcp 3.35.0 actually sends. To surface `-32042` end-to-end today, use the lower-level `@authplane/mcp` adapter (which goes through the official MCP SDK transport and propagates `McpError` as JSON-RPC). Track upstream resolution before relying on this path in production. ### Escape hatch diff --git a/packages/fastmcp/src/auth.ts b/packages/fastmcp/src/auth.ts index e169a3e..59b8878 100644 --- a/packages/fastmcp/src/auth.ts +++ b/packages/fastmcp/src/auth.ts @@ -141,9 +141,7 @@ function readBearerToken(request: IncomingMessage): string | undefined { } } -function collectDpopHeaderValues( - request: IncomingMessage, -): readonly string[] { +function collectDpopHeaderValues(request: IncomingMessage): readonly string[] { // `IncomingMessage.headers` lowercases keys in Node, but FastMCP may // wrap/transform. Scan case-insensitively and normalise to // `string | string[] | undefined` for the shared core helper, which @@ -241,11 +239,18 @@ export async function authplaneFastMcpAuth( if (options.asCredentials !== undefined) { resourceOptions.asCredentials = options.asCredentials; } + if (options.resourceMetadataUrl !== undefined) { + resourceOptions.resourceMetadataUrl = options.resourceMetadataUrl; + } const verifier = client.resource(resourceOptions); const tokenVerifier = new AuthplaneTokenVerifier(verifier); const protectedResourceMetadata = verifier.prmResponse(); - const protectedResourceMetadataUrl = verifier.prmDocumentUrl(); + // The configured override when there is one, the derived URL otherwise — + // this value is only ever advertised, in the `resource_metadata` parameter + // of the challenges built below. The document FastMCP serves from the + // `oauth` block is unaffected. + const protectedResourceMetadataUrl = verifier.resourceMetadataUrl(); const parsedResource = new URL(resource); const resourceOrigin = `${parsedResource.protocol}//${parsedResource.host}`; @@ -277,7 +282,13 @@ export async function authplaneFastMcpAuth( headers: { "WWW-Authenticate": wwwAuthenticate(error, { resourceMetadataUrl: protectedResourceMetadataUrl, - scope: defaultRequiredScopes, + // Passed only when non-empty so the error's own + // requiredScopes can fill in — an explicit array, empty + // included, wins over the fallback. Matches the Hono and + // NestJS mappings. + ...(defaultRequiredScopes.length > 0 + ? { scope: defaultRequiredScopes } + : {}), }), }, }); @@ -327,7 +338,12 @@ export async function authplaneFastMcpAuth( session.scopes.includes(scope), ); if (!hasAll) { - throw challengeResponse(new InsufficientScope("Insufficient scope")); + // Carry the scopes on the error: that is what makes the + // requiredScopes fallback in wwwAuthenticate reachable from + // here, and it matches VerifiedClaims.requireScopes. + throw challengeResponse( + new InsufficientScope("Insufficient scope", defaultRequiredScopes), + ); } } diff --git a/packages/fastmcp/tests/auth.integration.test.ts b/packages/fastmcp/tests/auth.integration.test.ts index aec4629..14b775e 100644 --- a/packages/fastmcp/tests/auth.integration.test.ts +++ b/packages/fastmcp/tests/auth.integration.test.ts @@ -92,6 +92,9 @@ describe("authplaneFastMcpAuth integration", () => { prmDocumentUrl: vi.fn( () => `${baseUrl}/.well-known/oauth-protected-resource/mcp`, ), + resourceMetadataUrl: vi.fn( + () => `${baseUrl}/.well-known/oauth-protected-resource/mcp`, + ), close: vi.fn(async () => undefined), } as unknown as AuthplaneResource; diff --git a/packages/fastmcp/tests/auth.test.ts b/packages/fastmcp/tests/auth.test.ts index 836dda1..5bd0af3 100644 --- a/packages/fastmcp/tests/auth.test.ts +++ b/packages/fastmcp/tests/auth.test.ts @@ -1,7 +1,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { AuthplaneClient, - type AuthplaneResource, + AuthplaneResource, + type AuthplaneResourceOptions, ConsentRequiredError, DPoPReplayDetected, InsufficientScope, @@ -84,6 +85,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { @@ -146,6 +150,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { @@ -185,6 +192,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { @@ -235,6 +245,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { @@ -292,6 +305,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { @@ -336,6 +352,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { @@ -372,6 +391,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { @@ -419,6 +441,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { @@ -475,6 +500,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -539,6 +567,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -592,6 +623,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -652,6 +686,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -717,6 +754,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -770,6 +810,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -799,6 +842,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -834,6 +880,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -873,6 +922,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -905,6 +957,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -938,6 +993,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -983,6 +1041,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -1050,6 +1111,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -1088,6 +1152,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -1131,6 +1198,9 @@ describe("authplaneFastMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -1195,3 +1265,237 @@ describe("authplaneFastMcpAuth", () => { }); }); +/** + * A mock client whose `resource()` calls through to the real core constructor. + * + * The stub clients elsewhere in this file cannot reject anything, so a test + * built on one would pass whether or not the RFC 8707 §2 gate exists. The + * client-owned collaborators are stubbed because the indicator gate runs first + * in the constructor and nothing here dereferences them. + */ +function realResourceClient(): AuthplaneClient { + return { + resource: (options: AuthplaneResourceOptions) => + new AuthplaneResource({ + ...options, + issuer: "https://auth.example.com", + metadataCache: {}, + fetchSettings: {}, + getJwksCache: () => ({}), + } as unknown as ConstructorParameters[0]), + exchange: vi.fn(), + close: vi.fn(async () => undefined), + } as unknown as AuthplaneClient; +} + +describe("authplaneFastMcpAuth resource indicator (RFC 8707 §2)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("rejects a fragment-bearing explicit resource at setup", async () => { + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(realResourceClient()); + + await expect( + authplaneFastMcpAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp#frag", + scopes: ["tools/add"], + }), + ).rejects.toThrow(/RFC 8707 §2/u); + }); + + it("rejects a relative explicit resource at setup through the same gate", async () => { + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(realResourceClient()); + + await expect( + authplaneFastMcpAuth({ + issuer: "https://auth.example.com", + resource: "/mcp", + scopes: ["tools/add"], + }), + ).rejects.toThrow(/absolute URL with a scheme and a host/u); + }); + + it("rejects a fragment inherited from baseUrl during derivation", async () => { + // `deriveResource` concatenates `baseUrl` and `mcpPath` as strings, so a + // fragment on `baseUrl` ends up *inside* the derived identifier + // (`https://api.example.com#frag/mcp`) rather than at the end of it. The + // gate is a raw-string check precisely so that shape is caught too. + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(realResourceClient()); + + await expect( + authplaneFastMcpAuth({ + issuer: "https://auth.example.com", + baseUrl: "https://api.example.com#frag", + mcpPath: "/mcp", + scopes: ["tools/add"], + }), + ).rejects.toThrow(/RFC 8707 §2/u); + }); + + it("builds normally for the same identifier without a fragment", async () => { + // Guards the tests above against passing vacuously on a broken harness. + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(realResourceClient()); + + const auth = await authplaneFastMcpAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add"], + }); + + expect(auth.protectedResourceMetadataUrl).toBe( + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ); + }); +}); + +describe("authplaneFastMcpAuth query-bearing resource (RFC 9728 §3)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("derives the query-bearing document URL and advertises it on the 401", async () => { + // End-to-end through the real core derivation (`realResourceClient`): the + // challenge's `resource_metadata` value must carry the query verbatim — + // that URL is the one a client round-trips against the served document's + // `resource` member (RFC 9728 §3.3). + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(realResourceClient()); + + const auth = await authplaneFastMcpAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp?tenant=a", + scopes: ["tools/add"], + }); + + expect(auth.protectedResourceMetadataUrl).toBe( + "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a", + ); + expect(auth.protectedResourceMetadata.resource).toBe( + "https://api.example.com/mcp?tenant=a", + ); + + try { + await auth.authenticate(createRequest() as never); + throw new Error("expected authenticate to throw"); + } catch (error) { + expect(error).toBeInstanceOf(Response); + const response = error as Response; + expect(response.status).toBe(401); + expect(response.headers.get("WWW-Authenticate")).toContain( + 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a"', + ); + } + }); +}); + +describe("authplaneFastMcpAuth resource_metadata override (RFC 9728 §3)", () => { + const AS_HOSTED = + "https://auth.example.com/.well-known/oauth-protected-resource/mcp"; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("advertises the configured URL on the 401 and leaves the served document alone", async () => { + // Real core resource, so the option travels the path it travels in + // production: adapter option → `client.resource()` → the constructor's + // gate → the accessor the challenge reads. + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(realResourceClient()); + + const auth = await authplaneFastMcpAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add"], + resourceMetadataUrl: AS_HOSTED, + }); + + expect(auth.protectedResourceMetadataUrl).toBe(AS_HOSTED); + // RFC 9728 §3.3 binds the document's `resource` member to the identifier + // the client used, whoever serves the document. + expect(auth.protectedResourceMetadata.resource).toBe( + "https://api.example.com/mcp", + ); + + try { + await auth.authenticate(createRequest() as never); + throw new Error("expected authenticate to throw"); + } catch (error) { + expect(error).toBeInstanceOf(Response); + expect((error as Response).status).toBe(401); + expect((error as Response).headers.get("WWW-Authenticate")).toContain( + `resource_metadata="${AS_HOSTED}"`, + ); + } + }); + + it("advertises it on the 403 insufficient_scope challenge too", async () => { + const claims = new VerifiedClaims({ + sub: "user_123", + clientId: "client_456", + scopes: ["tools/add"], + issuer: "https://auth.example.com", + audience: ["https://api.example.com/mcp"], + expiresAt: 1700000000, + issuedAt: 1699999000, + jti: "token_123", + kid: "key_1", + agentId: "", + agentChain: [], + notBefore: 0, + raw: { sub: "user_123" }, + }); + + const mockResource = { + verify: vi.fn(async () => claims), + prmResponse: vi.fn(() => ({ + resource: "https://api.example.com/mcp", + authorization_servers: ["https://auth.example.com"], + scopes_supported: ["tools/add", "tools/admin"], + bearer_methods_supported: ["header"], + resource_signing_alg_values_supported: ["RS256", "ES256"], + })), + prmDocumentUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), + resourceMetadataUrl: vi.fn(() => AS_HOSTED), + } as unknown as AuthplaneResource; + + vi.spyOn(AuthplaneClient, "create").mockResolvedValue({ + resource: vi.fn(() => mockResource), + exchange: vi.fn(), + } as unknown as AuthplaneClient); + + const auth = await authplaneFastMcpAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add", "tools/admin"], + requiredScopes: ["tools/admin"], + }); + + try { + await auth.authenticate(createRequest("Bearer valid_jwt") as never); + throw new Error("expected authenticate to throw"); + } catch (error) { + expect(error).toBeInstanceOf(Response); + const response = error as Response; + expect(response.status).toBe(403); + const wwwAuth = response.headers.get("WWW-Authenticate") ?? ""; + expect(wwwAuth).toContain('error="insufficient_scope"'); + expect(wwwAuth).toContain(`resource_metadata="${AS_HOSTED}"`); + } + }); + + it("rejects an invalid override at setup, not per request", async () => { + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(realResourceClient()); + + await expect( + authplaneFastMcpAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add"], + resourceMetadataUrl: "https://auth.example.com/prm#frag", + }), + ).rejects.toThrow(/fragment component/u); + }); +}); diff --git a/packages/hono/docs/user-guide.md b/packages/hono/docs/user-guide.md index 0e1bae2..d49c88f 100644 --- a/packages/hono/docs/user-guide.md +++ b/packages/hono/docs/user-guide.md @@ -7,6 +7,7 @@ Complete reference for the Authplane adapter for [Hono](https://hono.dev). Start - [Install](#install) - [Quickstart](#quickstart) - [`authplaneHonoAuth(options)` reference](#authplanehonoauthoptions-reference) +- [Where the PRM document lives](#where-the-prm-document-lives) - [Context shape (`c.get("auth")`)](#context-shape-cgetauth) - [Scope enforcement](#scope-enforcement) - [Per-route scope enforcement with `requireScope`](#per-route-scope-enforcement-with-requirescope) @@ -86,6 +87,7 @@ The adapter produces: | `metadataRefreshSeconds` | `number` (optional, default `3600`) | Metadata cache TTL. | | `devMode` | `boolean` (optional, default `false`) | Relaxes HTTPS and private-host restrictions. Only for local dev. | | `revocationChecker` | `RevocationChecker \| IntrospectionRevocation` (optional) | Enable real-time revocation checking. | +| `resourceMetadataUrl` | `string` (optional) | Absolute URL advertised as `resource_metadata=` on every challenge, overriding the URL derived from `resource`. See [Where the PRM document lives](#where-the-prm-document-lives). | | `replayStore` | `DPoPReplayStore` (optional) | Convenience shortcut folded into `inboundDPoP.replayStore`. Cannot be combined with `inboundDPoP.replayStore`. | | `inboundDPoP` | `InboundDPoPOptions` (optional) | Full DPoP knobs (`required`, `maxProofAgeSeconds`, `allowedProofAlgorithms`, `replayStore`). | | `dpopProvider` | `DPoPProvider` (optional) | Outbound DPoP provider for AS-facing calls (introspection, token exchange, revocation). | @@ -104,10 +106,23 @@ Plus every option from `AuthplaneResourceOptions` (`core`) not otherwise overrid | `verifier` | `AuthplaneResource` | The core resource primitive; call `verifier.verify(token)` directly to bypass the middleware. | | `bearerAuth` | `MiddlewareHandler<{ Variables: HonoAuthVariables }>` | Ready-to-use Hono middleware. Verifies token, enforces scopes, attaches `c.get("auth")`. | | `onError` | `ErrorHandler` | Preconfigured `app.onError` handler, bound with the SAME `realm` + `resource_metadata` URL as `bearerAuth`. Install with `app.onError(auth.onError)` so a handler-raised `AuthplaneError` (e.g. `requireScope` → `InsufficientScope`) emits a challenge that matches the verification path. Generic over the app's `Env` — instantiate the factory at your `Bindings` shape to attach it to a Workers-typed app without a cast. | -| `protectedResourceMetadataPath` | `string` | Hono route path where the PRM should be served (e.g. `/.well-known/oauth-protected-resource/mcp`). | +| `protectedResourceMetadataPath` | `string` | Hono route path where the PRM should be served (e.g. `/.well-known/oauth-protected-resource/mcp`). Always derived from `resource`, even when `resourceMetadataUrl` points elsewhere. | | `protectedResourceMetadata` | `ProtectedResourceMetadata` | The PRM JSON payload. | | `protectedResourceMetadataHandler` | `Handler` | Hono handler that serves the PRM. | +## Where the PRM document lives + +RFC 9728 does not say who has to host the metadata document, only what a client finds when it follows the `resource_metadata` parameter of a `WWW-Authenticate` challenge. Two topologies work. + +**(a) Resource-hosted — the default.** This server serves the document itself at the URL derived from `resource`, `/.well-known/oauth-protected-resource[/path]`, and every challenge points there. Nothing to configure. Mount `protectedResourceMetadataHandler` at `protectedResourceMetadataPath`, as the quickstart does. + +**(b) AS-hosted.** `authserver` >= 0.2.0 serves an RFC 9728 document for every registered Resource at `/.well-known/oauth-protected-resource/{ref}`, where `{ref}` is the Resource URI's path suffix (RFC 9728 §3.1) or its slug. Set `resourceMetadataUrl` to that URL and this server stops advertising its own; it only points at the AS's. Use it when the resource server cannot host well-known paths — a platform that owns `/.well-known`, a proxy that strips it, a resource mounted under a path it does not control. + +Only the advertisement moves. The PRM route stays mounted where it was, and both challenge paths — `bearerAuth`'s 401 and `auth.onError`'s 403 — pick the configured URL up from the same place, so they cannot drift. `bearerAuth` still takes its own `resourceMetadataUrl` when you wire it by hand; that per-middleware value is the more specific layer. So the two documents can be served side by side during a migration, and switching back is a config change. + +Whichever hosts it, RFC 9728 §3.3 binds the document to this server: the `resource` value **inside** the document must equal the URL clients call, byte for byte, or a conformant client discards the document — and the resource server then looks unreachable rather than misconfigured. So the Resource URI registered at the authorization server, the `resource` configured here, and this server's public URL must be the same string; a trailing slash or an `http`/`https` difference is enough to break it. + + ## Context shape (`c.get("auth")`) After `bearerAuth` runs, the verified claims are available via `c.get("auth")`. Type the app as `Hono<{ Variables: HonoAuthVariables }>` (from `@authplane/hono`) to get autocomplete: @@ -199,7 +214,7 @@ The two paths cooperate rather than double-handle. On a downstream throw, Hono d Every error funnels through core's `httpStatus()` + `wwwAuthenticate()`: `InsufficientScope` → 403 + `Bearer …`, DPoP failures → 401 + `DPoP …` (except `DPoPNotSupported`), upstream-AS failures (`JWKSFetchError`, `MetadataFetchError`) → 503 with retry semantics. -> No equivalent to MCP's URL elicitation. `@authplane/mcp` ships `wrapToolWithUrlElicitation` / `toUrlElicitationRequiredError` to translate a `ConsentRequiredError` (raised by a token exchange against `authserver`) into MCP's `-32042` response. Hono has no analogous protocol hook — if a handler performs a token exchange and catches `ConsentRequiredError`, translate it yourself inside the handler (typically to a `401` with a JSON body carrying the `consent_url`) before returning. +> No equivalent to MCP's URL elicitation. `@authplane/mcp` ships `wrapToolWithUrlElicitation` / `toUrlElicitationRequiredError` to translate a `ConsentRequiredError` (raised by a token exchange against `authserver`) into MCP's `-32042` response. Hono has no analogous protocol hook — if a handler performs a token exchange and catches `ConsentRequiredError`, translate it yourself inside the handler (typically to a `401` with a JSON body carrying the `consent_url`) before returning. An `AccessDeniedError` (`access_denied`, 403) from the same exchange is not a consent problem: the operator has not allowlisted the exchanging client on the target Resource (`PATCH /admin/resources/{id}` with `{"policy": {"exchange": {"allowed_client_ids": [""]}}}`), and re-prompting the user will not fix it; an `InvalidTargetError` (`invalid_target`, 400) means the `resource` string does not match a granted resource exactly. ## DPoP-bound tokens @@ -244,6 +259,14 @@ const auth = await authplaneHonoAuth({ `IntrospectionRevocation` is a singleton class from `@authplane/sdk/core` — obtain its instance with `IntrospectionRevocation.get()` and pass it through `revocationChecker`. Internally it's detected via `instanceof`, which flips `AuthplaneResource` into "introspect on every verify" mode: the underlying resource calls `authserver`'s introspection endpoint on each `verify()` and raises on `active: false`. This adds one round-trip per request; use only if eager revocation matters to your threat model. +The introspecting client must be **confidential** (it needs a `clientSecret`) **and** either the client that was issued the token or a runtime-client of the Resource named in the token's `aud`. Since authserver 0.1.2 every other caller — a public (secret-less) client included — receives `{"active": false}`, which the SDK reads as "revoked", so a resource server introspecting with the wrong credentials silently rejects every token. Register the resource server on its Resource with: + +```bash +authserver admin resource runtime-client add --client-id --slug +``` + +A public client cannot introspect at all. + You can also pass a custom `RevocationChecker` — an async function `(claims, rawToken) => Promise` — for database-backed revocation lists. ## Custom fetch settings diff --git a/packages/hono/src/authplaneHonoAuth.ts b/packages/hono/src/authplaneHonoAuth.ts index e350e18..ee53640 100644 --- a/packages/hono/src/authplaneHonoAuth.ts +++ b/packages/hono/src/authplaneHonoAuth.ts @@ -160,8 +160,14 @@ export interface AuthplaneHonoAuth< export async function authplaneHonoAuth< E extends { Variables: HonoAuthVariables } = { Variables: HonoAuthVariables }, >(options: AuthplaneHonoAuthOptions): Promise> { - const { requiredScopes, scopes, issuer, resource, realm, emitDownstreamChallenge } = - options; + const { + requiredScopes, + scopes, + issuer, + resource, + realm, + emitDownstreamChallenge, + } = options; if ( options.replayStore !== undefined && @@ -174,14 +180,27 @@ export async function authplaneHonoAuth< const resolvedScopes = scopes ?? []; const resolvedRequiredScopes = requiredScopes ?? resolvedScopes; - const resourceOrigin = new URL(resource).origin; const client = await buildAuthplaneClient({ issuer, options }); const verifier = client.resource( buildResourceOptions(resource, resolvedScopes, options), ); - const resourceMetadataUrl = verifier.prmDocumentUrl(); - const protectedResourceMetadataPath = new URL(resourceMetadataUrl).pathname; + // After `client.resource()`: the constructor's resource-indicator gate has + // already vouched that `resource` is an absolute URL, so this parse cannot + // throw — deriving the origin earlier would preempt the gate's + // RFC-grounded message with a bare "Invalid URL". Built from `protocol` + + // `host`, not `URL.origin`: `origin` is the literal string "null" for a + // non-special scheme, and this value anchors the DPoP `htu` — matching the + // mcp and fastmcp adapters. + const parsedResource = new URL(resource); + const resourceOrigin = `${parsedResource.protocol}//${parsedResource.host}`; + // Advertised vs. served: the challenges below carry + // `resourceMetadataUrl()` (the configured override when set), while the PRM + // handler stays mounted at the path derived from `resource` — advertising + // an AS-hosted document must not unmount the local one. + const resourceMetadataUrl = verifier.resourceMetadataUrl(); + const protectedResourceMetadataPath = new URL(verifier.prmDocumentUrl()) + .pathname; const protectedResourceMetadata = verifier.prmResponse(); // `realm` and `emitDownstreamChallenge` are optional and this package builds @@ -294,6 +313,9 @@ function buildResourceOptions( if (options.failClosed !== undefined) { resourceOptions.failClosed = options.failClosed; } + if (options.resourceMetadataUrl !== undefined) { + resourceOptions.resourceMetadataUrl = options.resourceMetadataUrl; + } if (options.inboundDPoP !== undefined || options.replayStore !== undefined) { resourceOptions.inboundDPoP = { ...(options.inboundDPoP ?? {}), diff --git a/packages/hono/src/bearerAuth.ts b/packages/hono/src/bearerAuth.ts index 18a24fa..e7380af 100644 --- a/packages/hono/src/bearerAuth.ts +++ b/packages/hono/src/bearerAuth.ts @@ -46,11 +46,14 @@ export interface BearerAuthOptions { readonly resourceMetadataUrl?: string; /** * Origin (scheme + authority) of the configured resource. Used as the - * trusted source of truth for the DPoP `htu` URL — must be - * `new URL(options.resource).origin`. The middleware never reads - * `X-Forwarded-*` or `Host` to compute `htu`: those are attacker-controlled - * inputs in many deployments and letting them steer `htu` would neuter - * RFC 9449 cross-endpoint anti-replay. + * trusted source of truth for the DPoP `htu` URL — build it as + * `` `${u.protocol}//${u.host}` `` from `new URL(options.resource)`, not + * `URL.origin`: `origin` is the literal string `"null"` for a non-special + * scheme such as `mcp:`, which the resource-indicator gate accepts, and a + * `"null"`-anchored `htu` fails verification for every DPoP-bound request. + * The middleware never reads `X-Forwarded-*` or `Host` to compute `htu`: + * those are attacker-controlled inputs in many deployments and letting + * them steer `htu` would neuter RFC 9449 cross-endpoint anti-replay. */ readonly resourceOrigin: string; /** diff --git a/packages/hono/src/errorResponse.ts b/packages/hono/src/errorResponse.ts index a5986d0..75fd4b5 100644 --- a/packages/hono/src/errorResponse.ts +++ b/packages/hono/src/errorResponse.ts @@ -1,5 +1,6 @@ import { type AuthplaneError, + errorResponseBody, httpStatus, InsufficientScope, wwwAuthenticate, @@ -57,8 +58,6 @@ export function writeAuthplaneErrorResponse< wwwOptions.scope = scope; } - const errorCode = - error instanceof InsufficientScope ? "insufficient_scope" : "invalid_token"; // Build the whole Response in one `c.json` call — the `WWW-Authenticate` // header rides its headers argument rather than a separate `c.header()` // mutation, so the challenge and the body are set at a single construction @@ -67,7 +66,10 @@ export function writeAuthplaneErrorResponse< // is that both the returned-response paths and the `c.res = …` assignment // path go through the same builder instead of a mutate-then-return sequence.) return c.json( - { error: errorCode, error_description: error.message }, + // Composed by core so the body names the same error code the challenge + // does and neither carries the exception's message to an unauthenticated + // caller. + errorResponseBody(error), httpStatus(error) as ContentfulStatusCode, { "WWW-Authenticate": wwwAuthenticate(error, wwwOptions) }, ); @@ -78,11 +80,11 @@ export function writeAuthplaneErrorResponse< * `bearerAuth` verification-path catch and the default `authplaneOnError` * fallback so an unexpected error surfaces as the SAME clean JSON 500 on both * paths instead of an unhandled rejection. The `error_description` is supplied - * explicitly by the caller and never derived from the error here — callers that - * face untrusted application errors (the `authplaneOnError` fallback) pass a - * fixed `"Internal Server Error"` so a raw error message is never echoed to the - * client, while the middleware's own verification-fault path may pass its - * message. + * explicitly by the caller and never derived from the error here, and every + * caller passes a fixed `"Internal Server Error"` — the `authplaneOnError` + * fallback because it faces untrusted application errors, and the middleware's + * own verification-fault path for the same reason a challenge no longer + * carries the message. A 500 must not echo one either. */ export function writeServerErrorResponse< E extends { Variables: HonoAuthVariables }, diff --git a/packages/hono/src/requireScope.ts b/packages/hono/src/requireScope.ts index 03525ed..3323856 100644 --- a/packages/hono/src/requireScope.ts +++ b/packages/hono/src/requireScope.ts @@ -50,7 +50,7 @@ export function requireScope( if (!auth) { // Fail-closed for a forgotten `app.use(bearerAuth)`: no claims to // delegate to, so synthesise the error directly. - throw new InsufficientScope(`Missing required scope: ${scope}`); + throw new InsufficientScope(`Missing required scope: ${scope}`, [scope]); } // Auth present but lacks the scope: delegate to the core helper so the // `InsufficientScope` message carries the missing scope and the scopes diff --git a/packages/hono/tests/auth.integration.test.ts b/packages/hono/tests/auth.integration.test.ts index 3513760..05a8c7f 100644 --- a/packages/hono/tests/auth.integration.test.ts +++ b/packages/hono/tests/auth.integration.test.ts @@ -103,6 +103,7 @@ describe("authplaneHonoAuth integration", () => { verify, prmResponse: vi.fn(() => prm), prmDocumentUrl: vi.fn(() => prmDocumentUrl), + resourceMetadataUrl: vi.fn(() => prmDocumentUrl), } as unknown as AuthplaneResource; const client = { diff --git a/packages/hono/tests/authplaneHonoAuth.test.ts b/packages/hono/tests/authplaneHonoAuth.test.ts index 75e7caf..ab01b7e 100644 --- a/packages/hono/tests/authplaneHonoAuth.test.ts +++ b/packages/hono/tests/authplaneHonoAuth.test.ts @@ -1,6 +1,7 @@ import { AuthplaneClient, - type AuthplaneResource, + AuthplaneResource, + type AuthplaneResourceOptions, type DPoPReplayStore, TokenExpired, VerifiedClaims, @@ -35,6 +36,7 @@ function buildClaims( function mockResource( overrides: Partial<{ prmDocumentUrl: string; + resourceMetadataUrl: string; prmResponse: Record; verify: ReturnType; }> = {}, @@ -42,6 +44,9 @@ function mockResource( const url = overrides.prmDocumentUrl ?? "https://api.example.com/.well-known/oauth-protected-resource/mcp"; + // Defaults to the derived URL, as the core accessor does when no override + // is configured; pass it explicitly to model a configured one. + const advertisedUrl = overrides.resourceMetadataUrl ?? url; const prm = overrides.prmResponse ?? { resource: "https://api.example.com/mcp", authorization_servers: ["https://auth.example.com"], @@ -52,6 +57,7 @@ function mockResource( verify: overrides.verify ?? vi.fn(async () => buildClaims()), prmResponse: vi.fn(() => prm), prmDocumentUrl: vi.fn(() => url), + resourceMetadataUrl: vi.fn(() => advertisedUrl), } as unknown as AuthplaneResource; } @@ -305,6 +311,8 @@ describe("authplaneHonoAuth", () => { const resource = mockResource({ prmDocumentUrl: "https://api.example.com/.well-known/oauth-protected-resource/mcp", + resourceMetadataUrl: + "https://api.example.com/.well-known/oauth-protected-resource/mcp", }); const client = mockClient(resource); vi.spyOn(AuthplaneClient, "create").mockResolvedValue(client); @@ -332,6 +340,7 @@ describe("authplaneHonoAuth", () => { "https://api.example.com/.well-known/oauth-protected-resource/mcp"; const resource = mockResource({ prmDocumentUrl: prmUrl, + resourceMetadataUrl: prmUrl, // Token clears the global scope gate (tools/read) but lacks the // per-route scope the handler demands (tools/add). verify: vi.fn(async () => buildClaims({ scopes: ["tools/read"] })), @@ -432,7 +441,7 @@ describe("authplaneHonoAuth", () => { const response = await app.request("/"); expect(response.status).toBe(401); expect(response.headers.get("WWW-Authenticate")).toBe( - 'Bearer realm="https://api.example.com/mcp", error="invalid_token", error_description="Token has expired", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"', + 'Bearer realm="https://api.example.com/mcp", error="invalid_token", error_description="The access token is missing or not valid for this resource", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp"', ); }); @@ -466,3 +475,283 @@ describe("authplaneHonoAuth", () => { ); }); }); + +/** + * A mock client whose `resource()` calls through to the real core constructor. + * + * `mockClient` above returns a stub that cannot reject anything, so a test + * built on it would pass whether or not the RFC 8707 §2 gate exists. The + * client-owned collaborators are stubbed because the indicator gate runs first + * in the constructor and nothing else in this suite dereferences them. + */ +function realResourceClient(): AuthplaneClient { + return { + resource: (options: AuthplaneResourceOptions) => + new AuthplaneResource({ + ...options, + issuer: "https://auth.example.com", + metadataCache: {}, + fetchSettings: {}, + getJwksCache: () => ({}), + } as unknown as ConstructorParameters[0]), + close: vi.fn(async () => undefined), + } as unknown as AuthplaneClient; +} + +describe("authplaneHonoAuth resource indicator (RFC 8707 §2)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("rejects a fragment-bearing resource at setup, not per request", async () => { + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + realResourceClient(), + ); + + await expect( + authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp#frag", + scopes: ["tools/add"], + }), + ).rejects.toThrow(/RFC 8707 §2/u); + }); + + it("rejects a relative resource at setup through the same gate", async () => { + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + realResourceClient(), + ); + + await expect( + authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "/mcp", + scopes: ["tools/add"], + }), + ).rejects.toThrow(/absolute URL with a scheme and a host/u); + }); + + it("builds normally for the same identifier without a fragment", async () => { + // Guards the test above against passing vacuously on a broken harness. + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + realResourceClient(), + ); + + const auth = await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add"], + }); + + expect(auth.protectedResourceMetadataPath).toBe( + "/.well-known/oauth-protected-resource/mcp", + ); + }); +}); + +describe("authplaneHonoAuth query-bearing resource (RFC 9728 §3)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("registers the query-less PRM route and advertises the query-bearing document URL on the 401", async () => { + // End-to-end through the real core derivation (`realResourceClient`): + // route registration is path-keyed, so the mount path must shed the + // query, while the challenge's `resource_metadata` value must carry it + // verbatim — that URL is the one a client round-trips against the + // served document's `resource` member (RFC 9728 §3.3). + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + realResourceClient(), + ); + + const auth = await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp?tenant=a", + scopes: ["tools/add"], + }); + + expect(auth.protectedResourceMetadataPath).toBe( + "/.well-known/oauth-protected-resource/mcp", + ); + + const { Hono } = await import("hono"); + const app = new Hono(); + app.get( + auth.protectedResourceMetadataPath, + auth.protectedResourceMetadataHandler, + ); + app.use("/mcp", auth.bearerAuth); + app.post("/mcp", (c) => c.json({ ok: true })); + + const prmResponse = await app.request(auth.protectedResourceMetadataPath); + expect(prmResponse.status).toBe(200); + const prmBody = (await prmResponse.json()) as { resource: string }; + expect(prmBody.resource).toBe("https://api.example.com/mcp?tenant=a"); + + const unauthorized = await app.request("/mcp", { method: "POST" }); + expect(unauthorized.status).toBe(401); + expect(unauthorized.headers.get("WWW-Authenticate")).toContain( + 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a"', + ); + }); +}); + +describe("authplaneHonoAuth non-special-scheme resource (RFC 8707 §2)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("derives the PRM wiring from protocol + host — never the literal 'null'", async () => { + // End-to-end through the real core derivation (`realResourceClient`): + // WHATWG `URL.origin` is "null" for a non-special scheme, so both the + // mount path and the advertised document URL must come from + // `protocol` + `host`. + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + realResourceClient(), + ); + + const auth = await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "mcp://api.example.com/mcp", + scopes: ["tools/add"], + }); + + expect(auth.protectedResourceMetadataPath).toBe( + "/.well-known/oauth-protected-resource/mcp", + ); + + const { Hono } = await import("hono"); + const app = new Hono(); + app.use("/mcp", auth.bearerAuth); + app.post("/mcp", (c) => c.json({ ok: true })); + + const unauthorized = await app.request("/mcp", { method: "POST" }); + expect(unauthorized.status).toBe(401); + expect(unauthorized.headers.get("WWW-Authenticate")).toContain( + 'resource_metadata="mcp://api.example.com/.well-known/oauth-protected-resource/mcp"', + ); + }); + + it("anchors the DPoP htu at protocol + host of the configured resource", async () => { + const resource = mockResource({ + prmDocumentUrl: + "mcp://api.example.com/.well-known/oauth-protected-resource/mcp", + resourceMetadataUrl: + "mcp://api.example.com/.well-known/oauth-protected-resource/mcp", + }); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(client); + const verifyMock = resource.verify as ReturnType; + + const { bearerAuth } = await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "mcp://api.example.com/mcp", + }); + + const { Hono } = await import("hono"); + const app = new Hono(); + app.use("/mcp", bearerAuth); + app.post("/mcp", (c) => c.json({ ok: true })); + + await app.request("/mcp", { + method: "POST", + headers: { + Authorization: "Bearer valid_jwt", + DPoP: "eyJ.proof.value", + }, + }); + + // The htu anchor is the configured resource's protocol + host — with + // an origin-keyed derivation this would have been "null/mcp". + expect(verifyMock).toHaveBeenCalledWith("valid_jwt", { + dpopRequest: expect.objectContaining({ + url: "mcp://api.example.com/mcp", + }), + }); + }); +}); + +describe("authplaneHonoAuth resource_metadata override (RFC 9728 §3)", () => { + const AS_HOSTED = + "https://auth.example.com/.well-known/oauth-protected-resource/mcp"; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("carries the configured URL on both the 401 and the 403 challenge", async () => { + // The two challenges are built in different places — the verification + // path inside `bearerAuth`, the handler-raised one inside + // `auth.onError` — and the factory binds both from the same accessor, + // so an override that reached only one of them would be a drift bug. + const resource = mockResource({ + resourceMetadataUrl: AS_HOSTED, + verify: vi.fn(async () => buildClaims({ scopes: ["tools/read"] })), + }); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + mockClient(resource), + ); + + const auth = await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/read"], + }); + + const { Hono } = await import("hono"); + const app = new Hono<{ Variables: HonoAuthVariables }>(); + app.use("/mcp", auth.bearerAuth); + app.post("/mcp", (c) => { + requireScope(c, "tools/add"); + return c.json({ ok: true }); + }); + app.onError(auth.onError); + + const unauthenticated = await app.request("/mcp", { method: "POST" }); + expect(unauthenticated.status).toBe(401); + const forbidden = await app.request("/mcp", { + method: "POST", + headers: { Authorization: "Bearer valid_jwt" }, + }); + expect(forbidden.status).toBe(403); + + for (const response of [unauthenticated, forbidden]) { + expect(response.headers.get("WWW-Authenticate")).toContain( + `resource_metadata="${AS_HOSTED}"`, + ); + } + // The local PRM route does not move with the advertisement. + expect(auth.protectedResourceMetadataPath).toBe( + "/.well-known/oauth-protected-resource/mcp", + ); + }); + + it("forwards the option to the core resource and rejects an invalid one at setup", async () => { + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + realResourceClient(), + ); + + const auth = await authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/read"], + resourceMetadataUrl: AS_HOSTED, + }); + expect(auth.verifier.resourceMetadataUrl()).toBe(AS_HOSTED); + expect(auth.verifier.prmDocumentUrl()).toBe( + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ); + + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + realResourceClient(), + ); + await expect( + authplaneHonoAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/read"], + resourceMetadataUrl: "//auth.example.com/prm", + }), + ).rejects.toThrow(/absolute URL with a scheme and a host/u); + }); +}); diff --git a/packages/hono/tests/authplaneOnError.test.ts b/packages/hono/tests/authplaneOnError.test.ts index 3e906d7..cb0be99 100644 --- a/packages/hono/tests/authplaneOnError.test.ts +++ b/packages/hono/tests/authplaneOnError.test.ts @@ -51,12 +51,12 @@ describe("authplaneOnError", () => { expect(res.status).toBe(403); expect(res.headers.get("WWW-Authenticate")).toBe( - 'Bearer error="insufficient_scope", error_description="Token missing required scope \'tools/add\'. Token has scopes: tools/echo", scope="tools/add"', + 'Bearer error="insufficient_scope", error_description="The access token does not carry the scope this operation requires", scope="tools/add"', ); await expect(res.json()).resolves.toEqual({ error: "insufficient_scope", error_description: - "Token missing required scope 'tools/add'. Token has scopes: tools/echo", + "The access token does not carry the scope this operation requires", }); }); @@ -71,7 +71,7 @@ describe("authplaneOnError", () => { expect(res.status).toBe(403); expect(res.headers.get("WWW-Authenticate")).toBe( - 'Bearer error="insufficient_scope", error_description="Insufficient scope", scope="tools/read"', + 'Bearer error="insufficient_scope", error_description="The access token does not carry the scope this operation requires", scope="tools/read"', ); }); @@ -106,7 +106,7 @@ describe("authplaneOnError", () => { expect(res.status).toBe(401); expect(res.headers.get("WWW-Authenticate")).toBe( - 'Bearer error="invalid_token", error_description="Token has expired", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"', + 'Bearer error="invalid_token", error_description="The access token is missing or not valid for this resource", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"', ); }); @@ -238,7 +238,7 @@ describe("authplaneOnError", () => { expect(res.status).toBe(401); expect(res.headers.get("WWW-Authenticate")).toBe( - 'Bearer error="invalid_token", error_description="Token has expired"', + 'Bearer error="invalid_token", error_description="The access token is missing or not valid for this resource"', ); }); }); diff --git a/packages/hono/tests/bearerAuth.test.ts b/packages/hono/tests/bearerAuth.test.ts index 61d17b9..8a3ae89 100644 --- a/packages/hono/tests/bearerAuth.test.ts +++ b/packages/hono/tests/bearerAuth.test.ts @@ -115,6 +115,46 @@ describe("bearerAuth — happy path", () => { }); }); +describe("bearerAuth — documented resourceOrigin recipe", () => { + it("anchors the DPoP htu at the resource for a hand-wired non-special scheme", async () => { + // `BearerAuthOptions.resourceOrigin` documents the recipe + // `${u.protocol}//${u.host}` from `new URL(options.resource)` — + // deliberately NOT `URL.origin`, which is the literal string "null" + // for a non-special scheme such as `mcp:` (an identifier the + // resource-indicator gate accepts) and would have the guard reject + // every DPoP-bound request. The factory computes this value itself; + // this test wires `bearerAuth` by hand the way an integrator following + // the JSDoc would, so the documented recipe is pinned, not just the + // adapter-computed one. + const verifyMock = vi.fn(async () => buildClaims()); + const verifier = { verify: verifyMock } as unknown as AuthplaneResource; + const u = new URL("mcp://api.example.com/mcp"); + expect(u.origin).toBe("null"); // the trap the recipe avoids + + const app = new Hono<{ Variables: HonoAuthVariables }>(); + app.use( + "/mcp", + bearerAuth({ verifier, resourceOrigin: `${u.protocol}//${u.host}` }), + ); + app.post("/mcp", (c) => c.json({ ok: true })); + + const response = await app.request("/mcp", { + method: "POST", + headers: { + Authorization: "Bearer valid_jwt", + DPoP: "eyJ.proof.value", + }, + }); + + expect(response.status).toBe(200); + expect(verifyMock).toHaveBeenCalledWith("valid_jwt", { + dpopRequest: expect.objectContaining({ + url: "mcp://api.example.com/mcp", + }), + }); + }); +}); + describe("bearerAuth — error paths", () => { function buildApp( overrides: { @@ -151,11 +191,11 @@ describe("bearerAuth — error paths", () => { expect(response.status).toBe(401); expect(response.headers.get("WWW-Authenticate")).toBe( - 'Bearer error="invalid_token", error_description="Missing Authorization header"', + 'Bearer error="invalid_token", error_description="The access token is missing or not valid for this resource"', ); await expect(response.json()).resolves.toEqual({ error: "invalid_token", - error_description: "Missing Authorization header", + error_description: "The access token is missing or not valid for this resource", }); }); @@ -169,7 +209,7 @@ describe("bearerAuth — error paths", () => { expect(response.status).toBe(401); expect(response.headers.get("WWW-Authenticate")).toBe( - 'Bearer error="invalid_token", error_description="Invalid Authorization header format, expected \'Bearer TOKEN\' or \'DPoP TOKEN\'"', + 'Bearer error="invalid_token", error_description="The access token is missing or not valid for this resource"', ); }); @@ -187,7 +227,7 @@ describe("bearerAuth — error paths", () => { expect(response.status).toBe(401); expect(response.headers.get("WWW-Authenticate")).toBe( - 'Bearer error="invalid_token", error_description="bad signature"', + 'Bearer error="invalid_token", error_description="The access token is missing or not valid for this resource"', ); }); @@ -206,7 +246,7 @@ describe("bearerAuth — error paths", () => { expect(response.status).toBe(401); await expect(response.json()).resolves.toEqual({ error: "invalid_token", - error_description: "Token has expired", + error_description: "The access token is missing or not valid for this resource", }); }); @@ -225,7 +265,7 @@ describe("bearerAuth — error paths", () => { expect(response.status).toBe(401); await expect(response.json()).resolves.toEqual({ error: "invalid_token", - error_description: "Token has no expiration time", + error_description: "The access token is missing or not valid for this resource", }); }); @@ -244,7 +284,7 @@ describe("bearerAuth — error paths", () => { expect(response.status).toBe(503); await expect(response.json()).resolves.toEqual({ error: "invalid_token", - error_description: "JWKS endpoint unreachable", + error_description: "The access token is missing or not valid for this resource", }); }); @@ -263,7 +303,7 @@ describe("bearerAuth — error paths", () => { expect(response.status).toBe(503); await expect(response.json()).resolves.toEqual({ error: "invalid_token", - error_description: "AS metadata endpoint unreachable", + error_description: "The access token is missing or not valid for this resource", }); }); @@ -280,15 +320,16 @@ describe("bearerAuth — error paths", () => { expect(response.status).toBe(403); // bearerAuth delegates to core claims.requireScopes, whose message names // the missing scope (`tools/delete`) and the scopes the token does - // carry — verbatim into both the JSON body and the WWW-Authenticate - // challenge's `error_description=`. + // carry. That message reaches the JSON body; the challenge carries the + // fixed description instead, because it answers a caller who has not + // authenticated. `scope=` is what tells the client what to step up to. expect(response.headers.get("WWW-Authenticate")).toBe( - `Bearer error="insufficient_scope", error_description="Token missing required scope 'tools/delete'. Token has scopes: tools/add, tools/echo", scope="tools/add tools/delete"`, + `Bearer error="insufficient_scope", error_description="The access token does not carry the scope this operation requires", scope="tools/add tools/delete"`, ); await expect(response.json()).resolves.toEqual({ error: "insufficient_scope", error_description: - "Token missing required scope 'tools/delete'. Token has scopes: tools/add, tools/echo", + "The access token does not carry the scope this operation requires", }); }); @@ -315,7 +356,7 @@ describe("bearerAuth — error paths", () => { expect(response.status).toBe(401); expect(response.headers.get("WWW-Authenticate")).toBe( - 'Bearer error="invalid_token", error_description="Missing Authorization header", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"', + 'Bearer error="invalid_token", error_description="The access token is missing or not valid for this resource", resource_metadata="https://api.example.com/.well-known/oauth-protected-resource"', ); }); @@ -517,7 +558,7 @@ describe("bearerAuth — downstream requireScope challenge (zero app wiring)", ( expect(response.status).toBe(403); expect(response.headers.get("WWW-Authenticate")).toBe( - 'Bearer error="insufficient_scope", error_description="Token missing required scope \'tools/delete_thing\'. Token has scopes: tools/add", scope="tools/delete_thing"', + 'Bearer error="insufficient_scope", error_description="The access token does not carry the scope this operation requires", scope="tools/delete_thing"', ); // The zero-config rewrite must still emit a JSON body, not inherit the // content-type of whatever the guarded handler was about to return. @@ -525,7 +566,7 @@ describe("bearerAuth — downstream requireScope challenge (zero app wiring)", ( await expect(response.json()).resolves.toEqual({ error: "insufficient_scope", error_description: - "Token missing required scope 'tools/delete_thing'. Token has scopes: tools/add", + "The access token does not carry the scope this operation requires", }); }); @@ -629,12 +670,12 @@ describe("bearerAuth — emitDownstreamChallenge", () => { expect(response.status).toBe(403); expect(response.headers.get("WWW-Authenticate")).toBe( - 'Bearer error="insufficient_scope", error_description="Token missing required scope \'tools/delete_thing\'. Token has scopes: tools/add", scope="tools/delete_thing"', + 'Bearer error="insufficient_scope", error_description="The access token does not carry the scope this operation requires", scope="tools/delete_thing"', ); await expect(response.json()).resolves.toEqual({ error: "insufficient_scope", error_description: - "Token missing required scope 'tools/delete_thing'. Token has scopes: tools/add", + "The access token does not carry the scope this operation requires", }); }); @@ -735,7 +776,7 @@ describe("bearerAuth — downstream error that rejects next() (app onError re-th expect(response.status).toBe(403); expect(response.headers.get("WWW-Authenticate")).toBe( - 'Bearer error="insufficient_scope", error_description="Token missing required scope \'tools/delete_thing\'. Token has scopes: tools/add", scope="tools/delete_thing"', + 'Bearer error="insufficient_scope", error_description="The access token does not carry the scope this operation requires", scope="tools/delete_thing"', ); }); @@ -810,12 +851,12 @@ describe("bearerAuth — WWW-Authenticate guard against double-emit", () => { // comma-joined value; exact equality to the single expected challenge // proves the guard suppressed the middleware's second write. expect(response.headers.get("WWW-Authenticate")).toBe( - 'Bearer error="insufficient_scope", error_description="Token missing required scope \'tools/delete_thing\'. Token has scopes: tools/add", scope="tools/delete_thing"', + 'Bearer error="insufficient_scope", error_description="The access token does not carry the scope this operation requires", scope="tools/delete_thing"', ); await expect(response.json()).resolves.toEqual({ error: "insufficient_scope", error_description: - "Token missing required scope 'tools/delete_thing'. Token has scopes: tools/add", + "The access token does not carry the scope this operation requires", }); }); diff --git a/packages/mcp/README.md b/packages/mcp/README.md index 5081a26..2ae1527 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -44,7 +44,7 @@ app.listen(3000); Two `requireScope` symbols exist; use the right one: -- `requireScope(scope, extra.authInfo)` from `@authplane/mcp` — call inside an MCP tool handler. Throws if the bound bearer token does not carry `scope`. +- `requireScope(scope, extra.authInfo)` from `@authplane/mcp` — call inside an MCP tool handler. Throws core `InsufficientScope` carrying the missing scope if the bound bearer token does not carry `scope`. On the streamable-HTTP transport the status code is already committed by the time a handler runs, so this is a defence-in-depth backstop, not the 403 step-up path — see the [user guide](docs/user-guide.md#where-the-check-runs-decides-whether-the-client-gets-a-403). - `claims.requireScope(scope)` method on `VerifiedClaims` from `@authplane/sdk/core` — call when you are doing manual JWT validation outside the MCP request flow and you already hold a `VerifiedClaims`. In a normal MCP server you only need the first one; the bearer middleware already populated `extra.authInfo` for you. diff --git a/packages/mcp/demo/.env.example b/packages/mcp/demo/.env.example index f6862ec..33471be 100644 --- a/packages/mcp/demo/.env.example +++ b/packages/mcp/demo/.env.example @@ -4,9 +4,13 @@ # Authorization server URL AUTHPLANE_ISSUER=http://localhost:9000 -# This server's resource URL (also used as client_id for introspection) +# This server's resource URL (also used as client_id for introspection). +# The introspecting client must be confidential and either the issuing client +# or a runtime-client of this resource; authserver >= 0.1.2 answers anyone +# else with active:false, which rejects every token. AUTHPLANE_RESOURCE=http://localhost:8080/mcp -# Client secret for introspection (registered with the authorization server) +# Client secret for introspection (registered with the authorization server). +# Required: an empty secret makes introspection unauthenticated. # Set this from your local authserver provisioning. AUTHPLANE_CLIENT_SECRET= diff --git a/packages/mcp/demo/README.md b/packages/mcp/demo/README.md index ed1a2ec..cad5616 100644 --- a/packages/mcp/demo/README.md +++ b/packages/mcp/demo/README.md @@ -31,6 +31,7 @@ Tokens must carry the scope for the specific tool being called. A token with onl ``` `AUTHPLANE_RESOURCE` is used both as the JWT `aud` claim and as the `client_id` for token introspection. + That client must be confidential (it has `AUTHPLANE_CLIENT_SECRET`) and either the issuing client or a runtime-client of the resource — since authserver 0.1.2 any other caller gets `{"active": false}` and every token is rejected as revoked. The demo provisioner registers it; on your own setup run `authserver admin resource runtime-client add --client-id --slug `. Legacy env names (`RESOURCE_URL`, `ISSUER_URL`, `CLIENT_SECRET`) are still accepted for compatibility. 2. Run the MCP server: @@ -76,4 +77,6 @@ MCP Client ──Bearer JWT──► mcpserver.ts (port 8080) **`IntrospectionRevocation`** — enables RFC 7662 token introspection on every `verify()` call using the adapter-supplied `asCredentials`. When `active: false` is returned, `TokenRevoked` is thrown (mapped to MCP's `InvalidTokenError`). +**`debug_exchange_token`** — a same-resource exchange (subject token and `resource` both belong to this server). authserver 0.2.0 answers `access_denied` (403) unless the demo client is in the resource's `runtime.client_ids` (or its `policy.exchange.allowed_client_ids`); that is an operator allowlist, not a consent prompt, so re-running the tool with a fresh user token does not clear it. + **`consent_demo`** — exchanges the inbound user token for a Google Calendar token via RFC 8693. The demo authserver registers `google-calendar` as a Broker resource with fake upstream credentials, so the AS consistently returns `consent_required` + a `consent_url`; the adapter's wrapped `client.exchange()` translates that into MCP `-32042` automatically — no `try/catch` in the tool handler. diff --git a/packages/mcp/docs/user-guide.md b/packages/mcp/docs/user-guide.md index 2b3f688..56bd271 100644 --- a/packages/mcp/docs/user-guide.md +++ b/packages/mcp/docs/user-guide.md @@ -7,6 +7,7 @@ Complete reference for the Authplane adapter for the official MCP TypeScript SDK - [Install](#install) - [Quickstart](#quickstart) - [`authplaneMcpAuth(options)` reference](#authplanemcpauthoptions-reference) +- [Where the PRM document lives](#where-the-prm-document-lives) - [Scope enforcement](#scope-enforcement) - [Per-tool scope enforcement with `requireScope`](#per-tool-scope-enforcement-with-requirescope) - [URL elicitation for consent-required flows](#url-elicitation-for-consent-required-flows) @@ -99,6 +100,7 @@ The adapter produces: | `revocationChecker` | `RevocationChecker \| IntrospectionRevocation` (optional) | Enable real-time revocation checking. See [Introspection and revocation](#introspection-and-revocation). | | `inboundDPoP` | `InboundDPoPOptions` (optional) | Per-resource inbound DPoP policy (RFC 9449 §7.1 + RFC 9728 §2). Presence is the on/off switch for advertising DPoP support in PRM and for accepting DPoP-bound tokens. See [DPoP-bound tokens](#dpop-bound-tokens). | | `failClosed` | `boolean` (optional, default `false`) | When `true`, revocation-checker errors reject the token (`TokenRevoked`) instead of accepting it. | +| `resourceMetadataUrl` | `string` (optional) | Absolute URL advertised as `resource_metadata=` on every challenge, overriding the URL derived from `resource`. See [Where the PRM document lives](#where-the-prm-document-lives). | | `allowedAlgorithms` | `string[]` (optional) | Allowed JWT `alg` values. Dangerous algorithms (`none`, `HS*`) are always rejected. Defaults to the SDK allow-list. | | `clockSkewSeconds` | `number` (optional) | Applied to `exp`/`nbf`/`iat` checks. DPoP proof age uses `inboundDPoP.clockSkewSeconds` independently. | @@ -112,10 +114,24 @@ The adapter produces: | `verifier` | `AuthplaneResource` | The resource primitive; call `verifier.verify(token)` directly if you need to bypass the middleware. | | `tokenVerifier` | `AuthplaneTokenVerifier` | MCP SDK `OAuthTokenVerifier` implementation — use it if you're wiring middleware manually with `requireBearerAuth({ verifier: tokenVerifier, requiredScopes: [...], resourceMetadataUrl })`, or handing a verifier to another MCP host framework. Set `resourceMetadataUrl` — without it the stock middleware omits the `resource_metadata` hint from 401 challenges and clients can't start discovery. Failures surface as MCP SDK error classes; see [Error handling](#error-handling). | | `bearerAuth` | `RequestHandler` | Ready-to-use Express middleware. Verifies token, enforces scopes, attaches `req.auth`. | -| `protectedResourceMetadataPath` | `string` | Express route path where the PRM should be served (e.g. `/.well-known/oauth-protected-resource/mcp`). | +| `protectedResourceMetadataPath` | `string` | Express route path where the PRM should be served (e.g. `/.well-known/oauth-protected-resource/mcp`). Always derived from `resource`, even when `resourceMetadataUrl` points elsewhere. | +| `protectedResourceMetadataUrl` | `string` | URL advertised as `resource_metadata=`. Pass it to `requireBearerAuth({ ..., resourceMetadataUrl })` when wiring the stock MCP SDK middleware, so both paths advertise the same document. | | `protectedResourceMetadata` | `ProtectedResourceMetadata` | The PRM JSON payload. | | `protectedResourceMetadataHandler` | `RequestHandler` | Express handler that serves the PRM. | +## Where the PRM document lives + +RFC 9728 does not say who has to host the metadata document, only what a client finds when it follows the `resource_metadata` parameter of a `WWW-Authenticate` challenge. Two topologies work. + +**(a) Resource-hosted — the default.** This server serves the document itself at the URL derived from `resource`, `/.well-known/oauth-protected-resource[/path]`, and every challenge points there. Nothing to configure. Mount `protectedResourceMetadataHandler` at `protectedResourceMetadataPath`, as the quickstart does. + +**(b) AS-hosted.** `authserver` >= 0.2.0 serves an RFC 9728 document for every registered Resource at `/.well-known/oauth-protected-resource/{ref}`, where `{ref}` is the Resource URI's path suffix (RFC 9728 §3.1) or its slug. Set `resourceMetadataUrl` to that URL and this server stops advertising its own; it only points at the AS's. Use it when the resource server cannot host well-known paths — a platform that owns `/.well-known`, a proxy that strips it, a resource mounted under a path it does not control. + +Only the advertisement moves. `protectedResourceMetadataPath` and `protectedResourceMetadataHandler` are unchanged, so the local document keeps being served, and `protectedResourceMetadata.resource` still names this server's identifier. So the two documents can be served side by side during a migration, and switching back is a config change. + +Whichever hosts it, RFC 9728 §3.3 binds the document to this server: the `resource` value **inside** the document must equal the URL clients call, byte for byte, or a conformant client discards the document — and the resource server then looks unreachable rather than misconfigured. So the Resource URI registered at the authorization server, the `resource` configured here, and this server's public URL must be the same string; a trailing slash or an `http`/`https` difference is enough to break it. + + ## Scope enforcement By default, `bearerAuth` requires every scope in `options.scopes`. Override with `requiredScopes`: @@ -150,7 +166,20 @@ server.tool( ); ``` -`requireScope` throws if the scope is absent from `extra.authInfo?.scopes`. +`requireScope` throws core `InsufficientScope` (from `@authplane/sdk/core`) if the scope is absent from `extra.authInfo?.scopes`, carrying the missing scope so a host that maps `AuthplaneError` answers `403` with `WWW-Authenticate: Bearer error="insufficient_scope", scope="tools/delete_thing"`. + +### Where the check runs decides whether the client gets a 403 + +**An in-handler `requireScope` cannot produce a 403 on the streamable-HTTP transport.** By the time a tool handler runs, the response has started and its status code is committed; the failure can only come back as a JSON-RPC error inside an HTTP 200. Nothing downstream can re-open the status line. + +So there are two places to enforce a scope, and they are not interchangeable: + +| Where | What the client sees | Use it for | +|---|---|---| +| Pre-dispatch — `bearerAuth`'s `requiredScopes`, or your own middleware ahead of the transport | `403` + `WWW-Authenticate: … error="insufficient_scope", scope="…"` | Any scope whose absence should make the client step up and retry | +| In a tool handler — `requireScope(scope, extra.authInfo)` | JSON-RPC error on HTTP 200 | Defence in depth: the call fails closed and the error names the scope | + +If a per-tool scope is meant to trigger step-up, it has to be enforced pre-dispatch. The in-handler helper is a backstop for the case where middleware was misconfigured or a tool was added without its route-level gate — it is worth keeping, but it is not the step-up path. ## URL elicitation for consent-required flows @@ -196,6 +225,20 @@ The MCP client receives: Consent errors without a `consentUrl` pass through unchanged; non-consent errors are re-thrown as-is. +**Operator step.** For each MCP server that exchanges for a downstream resource it does not itself act as, the operator must allowlist the exchanging client on the target Resource: + +```http +PATCH /admin/resources/{id} +{"policy": {"exchange": {"allowed_client_ids": [""]}}} +``` + +A client exchanging a token issued to itself, a fronted exchange and a Broker resource need nothing. + +Two failure answers from the AS are policy, not outages, and neither counts toward the circuit breaker: + +- `access_denied` (HTTP 403, `AccessDeniedError`) on a cross-client exchange means the operator has not allowlisted the exchanging client on the target Resource. Unlike `consent_required`, re-prompting the user will not fix it. +- `invalid_target` (HTTP 400, `InvalidTargetError`, RFC 8707 §2.2) means the `resource` string does not match a granted resource exactly — the comparison is byte for byte, so a trailing slash counts. + ### Escape hatch For custom consent flows outside `client.exchange()`, `toUrlElicitationRequiredError` is exported as a low-level primitive: @@ -227,6 +270,14 @@ const auth = await authplaneMcpAuth({ `IntrospectionRevocation.get()` returns the marker singleton; the underlying `AuthplaneResource` calls `authserver`'s introspection endpoint on each `verify()`, and throws `TokenRevoked` (mapped to MCP's `InvalidTokenError`) when `active: false` is returned. This adds one round-trip per request; use only if eager revocation matters to your threat model. +The introspecting client must be **confidential** (it needs a `clientSecret`) **and** either the client that was issued the token or a runtime-client of the Resource named in the token's `aud`. Since authserver 0.1.2 every other caller — a public (secret-less) client included — receives `{"active": false}`, which the SDK reads as "revoked", so a resource server introspecting with the wrong credentials silently rejects every token. Register the resource server on its Resource with: + +```bash +authserver admin resource runtime-client add --client-id --slug +``` + +A public client cannot introspect at all. + You can also pass a custom `RevocationChecker` — an async function `(claims, rawToken) => Promise` — for database-backed revocation lists. ## DPoP-bound tokens @@ -326,13 +377,15 @@ The middleware emits a JSON body alongside the `WWW-Authenticate` header: ```json { - "error": "invalid_token", // or "insufficient_scope" - "error_description": "" + "error": "invalid_token", // or "insufficient_scope", "invalid_dpop_proof" + "error_description": "The access token is missing or not valid for this resource" } ``` `resource_metadata="…"` is always included so clients can discover the AS; `scope="…"` is included when `requiredScopes` is configured. Non-Authplane errors fall through to a generic 500 (`error: "server_error"`). +Body and challenge are built by the same core helpers, so they name one `error` code and carry one `error_description` — a fixed sentence chosen by that code, never the exception's message. Both halves reach a caller who by definition has not authenticated, and the SDK's messages name the failing detail: the unknown `kid`, the claim that did not validate, the `aud` the resource expects. The message stays on the exception for you to log. There is nothing left to strip in a wrapping middleware. + ### `tokenVerifier` — a host framework owns the response `tokenVerifier.verifyAccessToken(token)` is the `OAuthTokenVerifier` seam. Hosts that consume it — the MCP SDK's `requireBearerAuth`, and framework integrations built on the same interface — classify failures strictly by `instanceof` against the MCP SDK's own error classes, so this method rethrows in that taxonomy: diff --git a/packages/mcp/src/auth.ts b/packages/mcp/src/auth.ts index 42717f8..1afe796 100644 --- a/packages/mcp/src/auth.ts +++ b/packages/mcp/src/auth.ts @@ -6,6 +6,7 @@ import { buildDPoPRequestContext, type DPoPProvider, extractBearerToken, + errorResponseBody, extractDpopHeaderValues, type FetchSettings, InsufficientScope, @@ -29,13 +30,30 @@ import { AuthplaneTokenVerifier } from "./verifier.js"; * return { content: [{ type: "text", text: String(params.a + params.b) }] }; * }); * ``` + * + * Raises core {@link InsufficientScope}, carrying the missing scope, so a host + * that maps `AuthplaneError` before dispatch — `authplaneOnError` in the Hono + * adapter, the NestJS exception filter — answers with `403` and + * `WWW-Authenticate: Bearer error="insufficient_scope", scope=""`, + * which is the challenge a client steps up from. A plain `Error` reached none + * of those mappings and surfaced as a JSON-RPC internal error or a generic + * 500. `bearerAuth` is not one of those hosts for this throw — see below. + * + * **Where this runs matters.** Called inside a tool handler on the + * streamable-HTTP transport, the response has already begun and its status + * code is committed, so the failure can only come back as a JSON-RPC error on + * an HTTP 200 — no 403, no challenge, no step-up. Enforcement that must + * trigger step-up belongs pre-dispatch, at `bearerAuth`'s `requiredScopes` + * (or your own middleware) where the status line has not been written yet. + * In-handler, treat this as a defence-in-depth backstop: it fails the call + * closed and names the scope in the error, but it cannot produce the 403. */ export function requireScope( scope: string, authInfo: AuthInfo | undefined, ): void { if (!authInfo?.scopes?.includes(scope)) { - throw new Error(`Missing required scope: ${scope}`); + throw new InsufficientScope(`Missing required scope: ${scope}`, [scope]); } } @@ -94,6 +112,13 @@ export interface AuthplaneMcpAuth { tokenVerifier: AuthplaneTokenVerifier; bearerAuth: RequestHandler; protectedResourceMetadataPath: string; + /** + * URL advertised as `resource_metadata` on every challenge this adapter + * emits. Pass it to the MCP SDK's own `requireBearerAuth({ verifier, + * requiredScopes, resourceMetadataUrl })` when wiring that middleware + * instead of `bearerAuth`, so both paths advertise the same document. + */ + protectedResourceMetadataUrl: string; protectedResourceMetadata: ProtectedResourceMetadata; protectedResourceMetadataHandler: RequestHandler; } @@ -165,11 +190,20 @@ export async function authplaneMcpAuth( if (options.asCredentials !== undefined) { resourceOptions.asCredentials = options.asCredentials; } + if (options.resourceMetadataUrl !== undefined) { + resourceOptions.resourceMetadataUrl = options.resourceMetadataUrl; + } const verifier = client.resource(resourceOptions); const tokenVerifier = new AuthplaneTokenVerifier(verifier); - const resourceMetadataUrl = verifier.prmDocumentUrl(); - const protectedResourceMetadataPath = new URL(resourceMetadataUrl).pathname; + // Two different URLs on purpose. The challenge advertises whatever the + // resource is configured to advertise (`resourceMetadataUrl()`: the + // override when set, the derived URL otherwise); the route this adapter + // mounts the document at is always the derived one, since that is where a + // client that follows the *default* advertisement looks. + const resourceMetadataUrl = verifier.resourceMetadataUrl(); + const protectedResourceMetadataPath = new URL(verifier.prmDocumentUrl()) + .pathname; const protectedResourceMetadata = verifier.prmResponse(); // DPoP `htu` (RFC 9449 §4.2) is the request target URI — origin + path. @@ -252,7 +286,13 @@ export async function authplaneMcpAuth( authInfo.scopes.includes(scope), ); if (!hasAllScopes) { - throw new InsufficientScope("Insufficient scope"); + // Carry the scopes on the error: that is what makes the + // requiredScopes fallback in wwwAuthenticate reachable from + // here, and it matches VerifiedClaims.requireScopes. + throw new InsufficientScope( + "Insufficient scope", + effectiveRequiredScopes, + ); } } @@ -276,23 +316,28 @@ export async function authplaneMcpAuth( "WWW-Authenticate", wwwAuthenticate(error, { resourceMetadataUrl, - scope: effectiveRequiredScopes, + // Passed only when non-empty so the error's own + // requiredScopes can fill in — an explicit array, + // empty included, wins over the fallback. The throw + // site above now carries them too, so the two agree + // whichever way the error arrives. Matches the Hono + // and NestJS mappings. + ...(effectiveRequiredScopes.length > 0 + ? { scope: effectiveRequiredScopes } + : {}), }), ); - const errorCode = - error instanceof InsufficientScope - ? "insufficient_scope" - : "invalid_token"; - res.status(httpStatus(error)).json({ - error: errorCode, - error_description: error.message, - }); + // Body and challenge are composed from the same core helpers, so + // the code they name agrees and neither carries the exception's + // own message to a caller who has not authenticated. + res.status(httpStatus(error)).json(errorResponseBody(error)); } else { - // Fallback to a generic 500. + // Fallback to a generic 500. The description is a fixed string: + // this branch catches whatever the surrounding application threw, + // so the message is not the SDK's to vouch for. res.status(500).json({ error: "server_error", - error_description: - error instanceof Error ? error.message : "Internal Server Error", + error_description: "Internal Server Error", }); } } @@ -304,6 +349,7 @@ export async function authplaneMcpAuth( tokenVerifier, bearerAuth, protectedResourceMetadataPath, + protectedResourceMetadataUrl: resourceMetadataUrl, protectedResourceMetadata, protectedResourceMetadataHandler, }; diff --git a/packages/mcp/src/verifier.ts b/packages/mcp/src/verifier.ts index 22576a7..f29c06a 100644 --- a/packages/mcp/src/verifier.ts +++ b/packages/mcp/src/verifier.ts @@ -2,9 +2,9 @@ import { AuthplaneError, type AuthplaneResource, type DPoPRequestContext, + errorResponseBody, httpStatus, InvalidClaims, - sanitiseHeaderValue, } from "@authplane/sdk/core"; // Imported for their *runtime* identity, not just their shape: the SDK's // `requireBearerAuth` classifies failures with `instanceof` against these @@ -38,15 +38,18 @@ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; * `MetadataFetchError`) land on `ServerError`/500 — the closest faithful * mapping through this seam. * - * The 401/403 messages are core's, run through `sanitiseHeaderValue`: the - * SDK's header builder splices `error.message` into the quoted-string - * `error_description="…"` unsanitised, and core messages can carry quotes - * (jose's `"exp" claim timestamp check failed`) that would truncate the - * `resource_metadata` hint clients need to start discovery. The 500 message - * is a fixed generic string instead — the SDK renders `ServerError.message` - * verbatim in the unauthenticated response body, and core's 5xx messages can + * Every message handed to the MCP SDK is a fixed sentence, never core's own. + * The host splices `error.message` straight into `error_description="…"`, so + * whatever is put here reaches a caller who has not authenticated: core's + * messages name the unknown `kid`, the claim that did not validate, the `typ` + * that was rejected, and an `aud` mismatch would hand over the exact audience + * the resource expects. The 401/403 sentences come from core's per-error-code + * table — the same text this SDK's own challenge builder emits, so the two + * hosts answer alike — and the 500 is generic because core's 5xx messages can * embed infrastructure detail (fetch failures name the host they couldn't - * reach). The original error stays on `.cause` either way. + * reach). Sanitising is no longer part of it: the fixed sentences carry no + * quotes to truncate the `resource_metadata` hint with. The original error + * stays on `.cause` either way, for the host to log. * * Non-`AuthplaneError` values pass through untouched: the SDK already turns * anything unrecognised into a 500, and keeping the original preserves the @@ -58,12 +61,16 @@ function toMcpAuthError(error: unknown): unknown { } const status = httpStatus(error); - const message = sanitiseHeaderValue(error.message); + // Bearer: this seam answers every failure as Bearer (see the class doc), so + // the description is the one a Bearer challenge would carry. + const { error_description: description } = errorResponseBody(error, { + scheme: "Bearer", + }); const mapped = status === 403 - ? new InsufficientScopeError(message) + ? new InsufficientScopeError(description) : status === 401 - ? new InvalidTokenError(message) + ? new InvalidTokenError(description) : new ServerError("Authorization server temporarily unavailable"); // The wire response only carries the sanitised (or generic) message; the diff --git a/packages/mcp/tests/auth.integration.test.ts b/packages/mcp/tests/auth.integration.test.ts index 12e50c2..72043ba 100644 --- a/packages/mcp/tests/auth.integration.test.ts +++ b/packages/mcp/tests/auth.integration.test.ts @@ -38,6 +38,9 @@ describe("authplaneMcpAuth integration", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { @@ -101,6 +104,9 @@ describe("authplaneMcpAuth integration", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { diff --git a/packages/mcp/tests/auth.middleware.test.ts b/packages/mcp/tests/auth.middleware.test.ts index 3871fad..1ebd40f 100644 --- a/packages/mcp/tests/auth.middleware.test.ts +++ b/packages/mcp/tests/auth.middleware.test.ts @@ -65,6 +65,9 @@ describe("authplaneMcpAuth bearerAuth middleware", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { @@ -235,7 +238,10 @@ describe("authplaneMcpAuth bearerAuth middleware", () => { ); }); - it("returns 401 with 'Bearer TOKEN' message on unknown scheme", async () => { + it("answers an unknown scheme with the fixed description, not core's message", async () => { + // The body travels to the same unauthenticated caller as the challenge, + // so it carries the per-error-code sentence rather than core's own + // "expected 'Bearer TOKEN'", which names SDK internals. const auth = await buildAuth(); const req: MockReq = { headers: { authorization: "Basic abc" } }; const res = createRes(); @@ -244,9 +250,12 @@ describe("authplaneMcpAuth bearerAuth middleware", () => { await auth.bearerAuth(req as never, res as never, next); expect(res.statusCode).toBe(401); - expect((res.body as { error_description: string }).error_description).toMatch( - /expected 'Bearer TOKEN'/, + const body = res.body as { error: string; error_description: string }; + expect(body.error).toBe("invalid_token"); + expect(body.error_description).toBe( + "The access token is missing or not valid for this resource", ); + expect(body.error_description).not.toMatch(/Bearer TOKEN/); }); it("returns 401 when authorization header is an array (multiple values)", async () => { @@ -293,9 +302,15 @@ describe("authplaneMcpAuth bearerAuth middleware", () => { const challenge = res.headers["WWW-Authenticate"] ?? ""; expect(challenge).toMatch(/^Bearer /); expect(challenge).toContain('error="invalid_token"'); - expect((res.body as { error_description: string }).error_description).toMatch( - /expired/, + // Neither half of the response repeats core's message: "expired" would + // tell an unauthenticated caller which check rejected the token. + const body = res.body as { error: string; error_description: string }; + expect(body.error).toBe("invalid_token"); + expect(body.error_description).toBe( + "The access token is missing or not valid for this resource", ); + expect(body.error_description).not.toMatch(/expired/i); + expect(challenge).not.toMatch(/expired/i); }); it("rejects requests carrying two DPoP headers delivered as a string[] (raw-headers shape)", async () => { diff --git a/packages/mcp/tests/auth.test.ts b/packages/mcp/tests/auth.test.ts index 6b79bc2..5389ff8 100644 --- a/packages/mcp/tests/auth.test.ts +++ b/packages/mcp/tests/auth.test.ts @@ -3,8 +3,12 @@ import type { AuthInfo } from "@modelcontextprotocol/sdk/server/auth/types.js"; import { UrlElicitationRequiredError } from "@modelcontextprotocol/sdk/types.js"; import { AuthplaneClient, - type AuthplaneResource, + AuthplaneResource, + type AuthplaneResourceOptions, ConsentRequiredError, + httpStatus, + InsufficientScope, + wwwAuthenticate, } from "@authplane/sdk/core"; import { AuthplaneTokenVerifier } from "../src/verifier.js"; @@ -27,6 +31,9 @@ describe("authplaneMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { @@ -80,6 +87,9 @@ describe("authplaneMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { @@ -121,6 +131,10 @@ describe("authplaneMcpAuth", () => { () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), })) as unknown, exchange: vi.fn(), } as unknown as AuthplaneClient; @@ -161,6 +175,9 @@ describe("authplaneMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { @@ -201,6 +218,9 @@ describe("authplaneMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { @@ -247,6 +267,9 @@ describe("authplaneMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -281,6 +304,9 @@ describe("authplaneMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -318,6 +344,9 @@ describe("authplaneMcpAuth", () => { prmDocumentUrl: vi.fn( () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; const mockClient = { resource: vi.fn(() => mockResource), @@ -368,4 +397,289 @@ describe("requireScope", () => { /Missing required scope/ ); }); + + // A plain Error reached neither `httpStatus` nor `wwwAuthenticate` — only + // AuthplaneError instances are mapped — so an insufficient scope surfaced as + // a JSON-RPC internal error or the generic 500 fallback, and the client + // never saw the challenge it would step up from. + it("throws core InsufficientScope so a host can answer 403 + challenge", () => { + const error = (() => { + try { + requireScope("tools/delete_thing", undefined); + } catch (caught) { + return caught; + } + return undefined; + })(); + + expect(error).toBeInstanceOf(InsufficientScope); + expect(httpStatus(error)).toBe(403); + expect(wwwAuthenticate(error as InsufficientScope)).toContain( + 'error="insufficient_scope"' + ); + }); + + it("carries the missing scope into the challenge so the client knows what to ask for", () => { + const authInfo = { + token: "t", + clientId: "c", + scopes: ["tools/add"], + expiresAt: 0, + } as AuthInfo; + + try { + requireScope("tools/delete_thing", authInfo); + expect.unreachable("requireScope should have thrown"); + } catch (error) { + expect((error as InsufficientScope).requiredScopes).toEqual([ + "tools/delete_thing", + ]); + expect(wwwAuthenticate(error as InsufficientScope)).toContain( + 'scope="tools/delete_thing"' + ); + } + }); +}); + +/** + * A mock client whose `resource()` calls through to the real core constructor. + * + * The stub clients elsewhere in this file cannot reject anything, so a test + * built on one would pass whether or not the RFC 8707 §2 gate exists. The + * client-owned collaborators are stubbed because the indicator gate runs first + * in the constructor and nothing here dereferences them. + */ +function realResourceClient(): AuthplaneClient { + return { + resource: (options: AuthplaneResourceOptions) => + new AuthplaneResource({ + ...options, + issuer: "https://auth.example.com", + metadataCache: {}, + fetchSettings: {}, + getJwksCache: () => ({}), + } as unknown as ConstructorParameters[0]), + exchange: vi.fn(), + close: vi.fn(async () => undefined), + } as unknown as AuthplaneClient; +} + +describe("authplaneMcpAuth resource indicator (RFC 8707 §2)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("rejects a fragment-bearing resource at setup, not per request", async () => { + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(realResourceClient()); + + await expect( + authplaneMcpAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp#frag", + scopes: ["tools/add_numbers"], + }), + ).rejects.toThrow(/RFC 8707 §2/u); + }); + + it("rejects a relative resource at setup through the same gate", async () => { + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(realResourceClient()); + + await expect( + authplaneMcpAuth({ + issuer: "https://auth.example.com", + resource: "/mcp", + scopes: ["tools/add_numbers"], + }), + ).rejects.toThrow(/absolute URL with a scheme and a host/u); + }); + + it("builds normally for the same identifier without a fragment", async () => { + // Guards the test above against passing vacuously on a broken harness. + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(realResourceClient()); + + const auth = await authplaneMcpAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add_numbers"], + }); + + expect(auth.protectedResourceMetadataPath).toBe( + "/.well-known/oauth-protected-resource/mcp", + ); + }); +}); + +describe("authplaneMcpAuth query-bearing resource (RFC 9728 §3)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("registers the query-less PRM path and advertises the query-bearing document URL on the 401", async () => { + // End-to-end through the real core derivation (`realResourceClient`): + // route registration is path-keyed, so the mount path must shed the + // query, while the challenge's `resource_metadata` value must carry it + // verbatim — that URL is the one a client round-trips against the served + // document's `resource` member (RFC 9728 §3.3). + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(realResourceClient()); + + const auth = await authplaneMcpAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp?tenant=a", + scopes: ["tools/add_numbers"], + }); + + expect(auth.protectedResourceMetadataPath).toBe( + "/.well-known/oauth-protected-resource/mcp", + ); + expect(auth.protectedResourceMetadata.resource).toBe( + "https://api.example.com/mcp?tenant=a", + ); + + const res = { + statusCode: 200, + headers: {} as Record, + body: undefined as unknown, + set(name: string, value: string) { + this.headers[name] = value; + return this; + }, + status(code: number) { + this.statusCode = code; + return this; + }, + json(payload: unknown) { + this.body = payload; + return this; + }, + }; + const next = vi.fn(); + await auth.bearerAuth({ headers: {} } as never, res as never, next); + + expect(res.statusCode).toBe(401); + expect(res.headers["WWW-Authenticate"]).toContain( + 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a"', + ); + expect(next).not.toHaveBeenCalled(); + }); +}); + +describe("authplaneMcpAuth resource_metadata override (RFC 9728 §3)", () => { + const AS_HOSTED = + "https://auth.example.com/.well-known/oauth-protected-resource/mcp"; + + afterEach(() => { + vi.restoreAllMocks(); + }); + + function createRes() { + return { + statusCode: 200, + headers: {} as Record, + set(name: string, value: string) { + this.headers[name] = value; + return this; + }, + status(code: number) { + this.statusCode = code; + return this; + }, + json() { + return this; + }, + }; + } + + async function buildOverridden() { + // Real core resource, so the option travels the path it travels in + // production: adapter option → `client.resource()` → the constructor's + // gate → the accessor the challenge reads. + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(realResourceClient()); + return authplaneMcpAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add_numbers"], + requiredScopes: ["tools/add_numbers"], + resourceMetadataUrl: AS_HOSTED, + }); + } + + it("advertises the configured URL on the 401 while the PRM route stays derived", async () => { + const auth = await buildOverridden(); + + // The document the AS hosts is what clients are sent to; the route this + // adapter mounts is still the local, derived one. + expect(auth.protectedResourceMetadataUrl).toBe(AS_HOSTED); + expect(auth.protectedResourceMetadataPath).toBe( + "/.well-known/oauth-protected-resource/mcp", + ); + + const res = createRes(); + const next = vi.fn(); + await auth.bearerAuth({ headers: {} } as never, res as never, next); + + expect(res.statusCode).toBe(401); + expect(res.headers["WWW-Authenticate"]).toContain( + `resource_metadata="${AS_HOSTED}"`, + ); + expect(next).not.toHaveBeenCalled(); + }); + + it("advertises it on the 403 insufficient_scope challenge too", async () => { + const auth = await buildOverridden(); + vi.spyOn( + AuthplaneTokenVerifier.prototype, + "verifyAccessTokenWithDpop", + ).mockResolvedValue({ + token: "jwt", + clientId: "client_1", + scopes: [], + expiresAt: Math.floor(Date.now() / 1000) + 3600, + }); + + const res = createRes(); + const next = vi.fn(); + await auth.bearerAuth( + { + headers: { authorization: "Bearer token-1" }, + method: "POST", + originalUrl: "/mcp", + } as never, + res as never, + next, + ); + + expect(res.statusCode).toBe(403); + const challenge = res.headers["WWW-Authenticate"] ?? ""; + expect(challenge).toContain('error="insufficient_scope"'); + expect(challenge).toContain(`resource_metadata="${AS_HOSTED}"`); + }); + + it("falls back to the derived URL when no override is configured", async () => { + // Guards the tests above against passing vacuously: the same wiring with + // the option omitted must advertise the resource-hosted document. + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(realResourceClient()); + + const auth = await authplaneMcpAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add_numbers"], + }); + + expect(auth.protectedResourceMetadataUrl).toBe( + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ); + }); + + it("rejects an invalid override at setup, not per request", async () => { + vi.spyOn(AuthplaneClient, "create").mockResolvedValue(realResourceClient()); + + await expect( + authplaneMcpAuth({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp", + scopes: ["tools/add_numbers"], + resourceMetadataUrl: "/.well-known/oauth-protected-resource/mcp", + }), + ).rejects.toThrow(/absolute URL with a scheme and a host/u); + }); }); diff --git a/packages/mcp/tests/verifier.test.ts b/packages/mcp/tests/verifier.test.ts index 5c3ec02..cfe5262 100644 --- a/packages/mcp/tests/verifier.test.ts +++ b/packages/mcp/tests/verifier.test.ts @@ -265,16 +265,28 @@ describe("AuthplaneTokenVerifier.verifyAccessToken error taxonomy", () => { expect((rejection as Error).cause).toBe(original); }); - it("carries core's sanitised message through, stripping header-breaking characters", async () => { - const rejection = await adapterThrowing( - new TokenExpired('Token has expired: "exp" claim check\r\nInjected: yes'), - ) + it("replaces core's message with the fixed description the host will publish", async () => { + // The MCP SDK's `requireBearerAuth` splices this message straight into + // `error_description="…"`, so whatever lands here reaches a caller who + // has not authenticated. It gets the per-error-code sentence, never + // core's — which names the claim that failed and, on an audience + // mismatch, the exact `aud` the resource expects. + const original = new TokenExpired( + 'Token has expired: "exp" claim check\r\nInjected: yes', + ); + const rejection = await adapterThrowing(original) .verifyAccessToken("t") .catch((e: unknown) => e); const message = (rejection as Error).message; + expect(message).toBe( + "The access token is missing or not valid for this resource", + ); + expect(message).not.toMatch(/exp|Injected/); + // Nothing to sanitise either: the fixed sentences carry no character + // that could terminate the quoted-string or fold the header. expect(message).not.toMatch(/["\\\r\n]/); - expect(message).toContain("Token has expired"); - expect(message).toContain("exp"); + // The detail is not lost — it stays on `.cause` for the host to log. + expect((rejection as Error).cause).toBe(original); }); }); diff --git a/packages/nestjs/docs/user-guide.md b/packages/nestjs/docs/user-guide.md index 7bb2617..3fb2df9 100644 --- a/packages/nestjs/docs/user-guide.md +++ b/packages/nestjs/docs/user-guide.md @@ -109,6 +109,7 @@ AuthplaneModule.forRootAsync({ |---|---|---| | `issuer` | `string` (required) | Authplane issuer URL (your `authserver`). | | `resource` | `string` (required) | Resource URI tokens must be audience-bound to (`aud` claim). | +| `resourceMetadataUrl` | `string` (optional) | Absolute URL advertised as `resource_metadata=` on every challenge the exception filter emits, overriding the URL derived from `resource`. See [Where the PRM document lives](#where-the-prm-document-lives). | | `scopes` | `string[]` (optional) | All scopes this server supports. Used for PRM and, by default, as `requiredScopes`. | | `requiredScopes` | `string[]` (optional) | Module-level scopes enforced by the guard. Defaults to `scopes` when absent. Layers with per-route `@RequireScopes(...)`. | | `auth` | `AuthProvider \| ASCredentials` (optional) | AS-facing credentials for outbound calls. Accepts a full `AuthProvider` (e.g. `private_key_jwt`, mTLS, custom) or the `{ clientId, clientSecret }` shortcut (wrapped in `ClientCredentialsProvider` by core). Required when introspection/revocation is enabled. Mirrors `auth` on `AuthplaneClient.create`. | @@ -257,6 +258,19 @@ class PrmController { } ``` +### Where the PRM document lives + +RFC 9728 does not say who has to host the metadata document, only what a client finds when it follows the `resource_metadata` parameter of a `WWW-Authenticate` challenge. Two topologies work. + +**(a) Resource-hosted — the default.** This server serves the document itself at the URL derived from `resource`, `/.well-known/oauth-protected-resource[/path]`, and every challenge points there. Nothing to configure. That is the controller described above. + +**(b) AS-hosted.** `authserver` >= 0.2.0 serves an RFC 9728 document for every registered Resource at `/.well-known/oauth-protected-resource/{ref}`, where `{ref}` is the Resource URI's path suffix (RFC 9728 §3.1) or its slug. Set `resourceMetadataUrl` to that URL and this server stops advertising its own; it only points at the AS's. Use it when the resource server cannot host well-known paths — a platform that owns `/.well-known`, a proxy that strips it, a resource mounted under a path it does not control. + +Only the advertisement moves. The controller stays mounted at the derived path and keeps serving this server's own document, whose `resource` member still names this server's identifier. So the two documents can be served side by side during a migration, and switching back is a config change. + +Whichever hosts it, RFC 9728 §3.3 binds the document to this server: the `resource` value **inside** the document must equal the URL clients call, byte for byte, or a conformant client discards the document — and the resource server then looks unreachable rather than misconfigured. So the Resource URI registered at the authorization server, the `resource` configured here, and this server's public URL must be the same string; a trailing slash or an `http`/`https` difference is enough to break it. + + ## Introspection and revocation By default the adapter trusts signature + `exp`/`nbf`. To enable RFC 7662 introspection on every request (catches tokens revoked before expiry): @@ -275,6 +289,14 @@ AuthplaneModule.forRoot({ `IntrospectionRevocation` is a singleton class from `@authplane/sdk/core` — obtain its instance with `IntrospectionRevocation.get()` and pass it through `revocationChecker`. Internally it's detected via `instanceof`, which flips `AuthplaneResource` into "introspect on every verify" mode: the underlying resource calls `authserver`'s introspection endpoint on each `verify()` and raises on `active: false`. This adds one round-trip per request; use only if eager revocation matters to your threat model. +The introspecting client must be **confidential** (it needs a `clientSecret`) **and** either the client that was issued the token or a runtime-client of the Resource named in the token's `aud`. Since authserver 0.1.2 every other caller — a public (secret-less) client included — receives `{"active": false}`, which the SDK reads as "revoked", so a resource server introspecting with the wrong credentials silently rejects every token. Register the resource server on its Resource with: + +```bash +authserver admin resource runtime-client add --client-id --slug +``` + +A public client cannot introspect at all. + You can also pass a custom `RevocationChecker` — an async function `(claims, rawToken) => Promise` — for database-backed revocation lists. ## DPoP-bound tokens @@ -383,6 +405,8 @@ class ChattyAuthFilter extends AuthplaneExceptionFilter { > throw err; > } > ``` +> +> An `AccessDeniedError` (`access_denied`, 403) from the same exchange is not a consent problem: the operator has not allowlisted the exchanging client on the target Resource (`PATCH /admin/resources/{id}` with `{"policy": {"exchange": {"allowed_client_ids": [""]}}}`), and re-prompting the user will not fix it; an `InvalidTargetError` (`invalid_target`, 400) means the `resource` string does not match a granted resource exactly. ## Express vs. Fastify diff --git a/packages/nestjs/src/application/authplane.exception-filter.ts b/packages/nestjs/src/application/authplane.exception-filter.ts index cc1b099..9c9a74e 100644 --- a/packages/nestjs/src/application/authplane.exception-filter.ts +++ b/packages/nestjs/src/application/authplane.exception-filter.ts @@ -10,6 +10,7 @@ import { import { AuthplaneError, type AuthplaneResource, + errorResponseBody, httpStatus, InsufficientScope, wwwAuthenticate, @@ -76,16 +77,11 @@ export class AuthplaneExceptionFilter implements ExceptionFilter { } const header = wwwAuthenticate(exception, wwwAuthenticateOptions); - const errorCode = - exception instanceof InsufficientScope - ? "insufficient_scope" - : "invalid_token"; - setHeader(reply, "WWW-Authenticate", header); - sendJson(reply, httpStatus(exception), { - error: errorCode, - error_description: exception.message, - }); + // Body from the same core helper that built the header, so the two name + // one error code and neither hands the exception's own message to a + // caller who has not authenticated. + sendJson(reply, httpStatus(exception), errorResponseBody(exception)); } /** @@ -106,8 +102,9 @@ export class AuthplaneExceptionFilter implements ExceptionFilter { /** * Best-effort PRM document URL for the `resource_metadata=` challenge - * parameter. Falls back to omitting the parameter if the resource cannot - * compute one — the challenge itself stays well-formed (RFC 9728 §5.1 + * parameter: the configured `resourceMetadataUrl` when the module sets one, + * the URL derived from `resource` otherwise. Falls back to omitting the + * parameter if the resource cannot compute one — the challenge itself stays well-formed (RFC 9728 §5.1 * makes `resource_metadata` optional). The first failure is logged at * `warn` because it almost always means a misconfigured `resource` URL * and the operator would otherwise only discover it when a client fails @@ -116,12 +113,12 @@ export class AuthplaneExceptionFilter implements ExceptionFilter { */ private safePrmUrl(): string | undefined { try { - return this.resource.prmDocumentUrl(); + return this.resource.resourceMetadataUrl(); } catch (err) { if (!this.prmUrlWarned) { this.prmUrlWarned = true; this.logger.warn( - `prmDocumentUrl() threw — omitting resource_metadata from WWW-Authenticate. Check the configured 'resource' URL: ${err instanceof Error ? err.message : String(err)}`, + `resourceMetadataUrl() threw — omitting resource_metadata from WWW-Authenticate. Check the configured 'resource' URL: ${err instanceof Error ? err.message : String(err)}`, ); } return undefined; diff --git a/packages/nestjs/src/application/authplane.guard.ts b/packages/nestjs/src/application/authplane.guard.ts index fbbfb3b..e6860c4 100644 --- a/packages/nestjs/src/application/authplane.guard.ts +++ b/packages/nestjs/src/application/authplane.guard.ts @@ -69,12 +69,19 @@ export class AuthplaneAuthGuard implements CanActivate { private readonly reflector: Reflector, ) { try { - this.resourceOrigin = new URL(this.options.resource).origin; + // `protocol` + `host`, not `URL.origin`: `origin` is the literal + // string "null" for a non-special scheme, and this value is the + // trusted anchor for the DPoP `htu` — matching the mcp and fastmcp + // adapters. This parse cannot throw for a module-registered + // resource: `AuthplaneModule` derives the PRM path from it at + // registration and the `AuthplaneResource` constructor runs + // `validateResourceIndicator` before this guard is built. The + // catch is a backstop for a guard constructed directly around an + // unvalidated `resource`; it surfaces a `TypeError` with the inner + // `URL` failure preserved on `.cause` instead of a bare `Error`. + const parsed = new URL(this.options.resource); + this.resourceOrigin = `${parsed.protocol}//${parsed.host}`; } catch (cause) { - // Mirror core `parseResourceUrl` in `@authplane/sdk/core/prm.ts`: - // a non-URL `resource` is a programmer-supplied-type violation, so - // surface it as `TypeError` with the inner `URL` failure preserved - // on `.cause` instead of a bare `Error`. throw new TypeError( `AuthplaneModule: 'resource' must be an absolute URL (got ${JSON.stringify(this.options.resource)})`, { cause }, diff --git a/packages/nestjs/src/module/authplane.module.ts b/packages/nestjs/src/module/authplane.module.ts index 5c46f6e..ef187fc 100644 --- a/packages/nestjs/src/module/authplane.module.ts +++ b/packages/nestjs/src/module/authplane.module.ts @@ -155,13 +155,18 @@ export class AuthplaneModule { // the resource URL is not knowable synchronously. const prmPath = hints.prmPath ?? syncCapture?.prmPath; const controllers = prmPath ? [buildPrmController(prmPath)] : []; - if (controllers.length === 0) { + if (controllers.length === 0 && syncCapture?.resourceRejected !== true) { // RFC 9728 discovery is required for many OAuth client flows; if // we drop the controller silently the operator only finds out // when a client fails to bootstrap. Route through Nest's Logger // so the message lands in the application's configured log sink // — `console.warn` would be suppressed by some PaaS log // pipelines. + // + // Suppressed when the resource itself was rejected: the advice + // below is a dead end there — supplying `hints.prmPath` registers + // the controller and the resource provider still throws — and + // `compile()` is about to report the real reason. new Logger("AuthplaneModule").warn( "PRM controller not registered — RFC 9728 discovery is disabled. " + "Pass `hints.prmPath` to `forRootAsync` (or use `forRoot` with a synchronous factory) to expose the metadata document.", @@ -231,6 +236,13 @@ function buildOptionsProvider( interface SyncFactoryCapture { readonly options: AuthplaneModuleOptions; readonly prmPath: string | undefined; + /** + * `resource` was configured but the RFC 8707 §2 gate rejected it, so no + * path could be derived. Distinct from `prmPath === undefined` for want of + * a resource, and the registration block reads it to stay quiet: the + * resource provider will throw the real error through Nest's bootstrap. + */ + readonly resourceRejected: boolean; } /** @@ -263,15 +275,17 @@ function inspectSyncFactory( if (result instanceof Promise) return undefined; const options = result as AuthplaneModuleOptions; let prmPath: string | undefined; + let resourceRejected = false; const resource = options.resource; if (typeof resource === "string" && resource.length > 0) { try { prmPath = oauthProtectedResourceMetadataPath(resource); } catch { prmPath = undefined; + resourceRejected = true; } } - return { options, prmPath }; + return { options, prmPath, resourceRejected }; } async function buildAuthplaneClient( @@ -348,6 +362,9 @@ function buildResourceOptions( if (options.failClosed !== undefined) { resourceOptions.failClosed = options.failClosed; } + if (options.resourceMetadataUrl !== undefined) { + resourceOptions.resourceMetadataUrl = options.resourceMetadataUrl; + } if (options.inboundDPoP !== undefined) { resourceOptions.inboundDPoP = options.inboundDPoP; } diff --git a/packages/nestjs/src/module/authplane.options.ts b/packages/nestjs/src/module/authplane.options.ts index 07241ee..a93f1e4 100644 --- a/packages/nestjs/src/module/authplane.options.ts +++ b/packages/nestjs/src/module/authplane.options.ts @@ -30,7 +30,7 @@ export interface AuthplaneModuleOptions * Scopes the guard must enforce at the module level — every request is * checked against this set. Per-route `@RequireScopes(...)` metadata is * merged on top. Defaults to `scopes` when not provided, matching the - * Python + Hono + MCP adapters. + * other Authplane adapters. */ requiredScopes?: string[]; /** diff --git a/packages/nestjs/tests/application/authplane.exception-filter.test.ts b/packages/nestjs/tests/application/authplane.exception-filter.test.ts index ec224d7..f9409c8 100644 --- a/packages/nestjs/tests/application/authplane.exception-filter.test.ts +++ b/packages/nestjs/tests/application/authplane.exception-filter.test.ts @@ -72,6 +72,8 @@ const OPTIONS: AuthplaneModuleOptions = { const RESOURCE = { prmDocumentUrl: () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + resourceMetadataUrl: () => + "https://api.example.com/.well-known/oauth-protected-resource/mcp", } as const; function newFilter(options: AuthplaneModuleOptions = OPTIONS) { @@ -91,7 +93,8 @@ describe("AuthplaneExceptionFilter — TokenMissing (401)", () => { expect(chain.status).toBe(401); expect(chain.body).toEqual({ error: "invalid_token", - error_description: "nope", + error_description: + "The access token is missing or not valid for this resource", }); expect(reply.setHeader).toHaveBeenCalledWith( "WWW-Authenticate", @@ -116,7 +119,8 @@ describe("AuthplaneExceptionFilter — TokenMissing (401)", () => { expect(chain.status).toBe(401); expect(chain.body).toEqual({ error: "invalid_token", - error_description: "nope", + error_description: + "The access token is missing or not valid for this resource", }); expect(reply.header).toHaveBeenCalledWith( "WWW-Authenticate", @@ -147,7 +151,8 @@ describe("AuthplaneExceptionFilter — InsufficientScope (403)", () => { expect(chain.status).toBe(403); expect(chain.body).toEqual({ error: "insufficient_scope", - error_description: "need more", + error_description: + "The access token does not carry the scope this operation requires", }); expect(reply.setHeader).toHaveBeenCalledWith( "WWW-Authenticate", @@ -229,7 +234,15 @@ describe("AuthplaneExceptionFilter — upstream-failure mapping", () => { }); describe("AuthplaneExceptionFilter — header sanitisation", () => { - it("strips quote / CR / LF / backslash from interpolated values", () => { + // The message no longer reaches `error_description` at all, so the + // not-to-contain assertions below can no longer fail on this path and would + // pass against any implementation. What this test proves now is the + // suppression itself: assert the fixed sentence, and keep the payload + // assertions as a regression guard on the whole header rather than as the + // point. The sanitiser is still exercised on this path through `realm`, in + // the test below, and on the message through `verboseDescription: true` in + // `packages/sdk/tests/core/errors.test.ts`. + it("emits the fixed description, not the exception message", () => { const { reply } = expressReply(); newFilter().catch( new TokenMissing('bad "quotes"\r\nInjected: x\\path'), @@ -240,12 +253,14 @@ describe("AuthplaneExceptionFilter — header sanitisation", () => { ); expect(headerCall).toBeDefined(); const value = headerCall?.[1] as string; + const matched = value.match(/error_description="([^"]*)"/u); + expect(matched?.[1]).toBe( + "The access token is missing or not valid for this resource", + ); expect(value).not.toContain("\r"); expect(value).not.toContain("\n"); expect(value).not.toContain('"quotes"'); expect(value).not.toContain("x\\path"); - const matched = value.match(/error_description="([^"]*)"/u); - expect(matched?.[1]).toBeDefined(); }); it("also sanitises the realm value", () => { @@ -278,6 +293,9 @@ describe("AuthplaneExceptionFilter — PRM URL handling", () => { prmDocumentUrl: () => { throw new Error("not configured"); }, + resourceMetadataUrl: () => { + throw new Error("not configured"); + }, } as const; return new AuthplaneExceptionFilter( OPTIONS, @@ -287,7 +305,7 @@ describe("AuthplaneExceptionFilter — PRM URL handling", () => { ); } - it("omits resource_metadata when prmDocumentUrl() throws", () => { + it("omits resource_metadata when resourceMetadataUrl() throws", () => { const { reply } = expressReply(); buildFailingFilter().catch(new TokenMissing("nope"), makeHost(reply)); const headerCall = reply.setHeader.mock.calls.find( @@ -305,7 +323,7 @@ describe("AuthplaneExceptionFilter — PRM URL handling", () => { filter.catch(new TokenMissing("nope"), makeHost(reply2)); filter.catch(new TokenMissing("nope"), makeHost(reply3)); expect(warnSpy).toHaveBeenCalledTimes(1); - expect(warnSpy.mock.calls[0]?.[0]).toContain("prmDocumentUrl() threw"); + expect(warnSpy.mock.calls[0]?.[0]).toContain("resourceMetadataUrl() threw"); expect(warnSpy.mock.calls[0]?.[0]).toContain("not configured"); }); }); @@ -373,3 +391,55 @@ describe("AuthplaneExceptionFilter — @Catch contract", () => { expect(catchMetadata).not.toContain(Error); }); }); + +describe("AuthplaneExceptionFilter — configured resource_metadata URL", () => { + const AS_HOSTED = + "https://auth.example.com/.well-known/oauth-protected-resource/mcp"; + + // Models a resource constructed with `resourceMetadataUrl`: the accessor + // returns the override while the derived URL — what the PRM controller is + // mounted at — stays put. + const OVERRIDDEN = { + prmDocumentUrl: () => + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + resourceMetadataUrl: () => AS_HOSTED, + } as const; + + function overriddenFilter() { + return new AuthplaneExceptionFilter( + OPTIONS, + OVERRIDDEN as unknown as ConstructorParameters< + typeof AuthplaneExceptionFilter + >[1], + ); + } + + it("advertises it on the 401 challenge", () => { + const { reply, chain } = expressReply(); + overriddenFilter().catch(new TokenMissing("nope"), makeHost(reply)); + + expect(chain.status).toBe(401); + expect(reply.setHeader).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringContaining(`resource_metadata="${AS_HOSTED}"`), + ); + }); + + it("advertises it on the 403 insufficient_scope challenge", () => { + const { reply, chain } = expressReply(); + overriddenFilter().catch( + new InsufficientScope("Insufficient scope"), + makeHost(reply), + ); + + expect(chain.status).toBe(403); + expect(reply.setHeader).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringContaining(`resource_metadata="${AS_HOSTED}"`), + ); + expect(reply.setHeader).toHaveBeenCalledWith( + "WWW-Authenticate", + expect.stringContaining('scope="tools/add"'), + ); + }); +}); diff --git a/packages/nestjs/tests/application/authplane.guard.test.ts b/packages/nestjs/tests/application/authplane.guard.test.ts index a51cd3b..faa37bb 100644 --- a/packages/nestjs/tests/application/authplane.guard.test.ts +++ b/packages/nestjs/tests/application/authplane.guard.test.ts @@ -236,6 +236,39 @@ describe("AuthplaneAuthGuard", () => { }); }); + it("anchors the DPoP htu at protocol + host for a non-special scheme", async () => { + // WHATWG `URL.origin` is the literal "null" for a scheme outside its + // special set, while the indicator gate deliberately admits any scheme + // with a host — an origin-keyed anchor would have produced + // `null/mcp` and failed every DPoP-bound request. + const claims = buildClaims(); + const verifier = { + verify: vi.fn(async () => claims), + } as unknown as AuthplaneResource; + const guard = buildGuard({ + verifier, + options: { + issuer: "https://auth.example.com", + resource: "mcp://api.example.com/mcp", + }, + }); + const req = expressReq({ + headers: { + host: "api.example.com", + authorization: "Bearer abc", + dpop: "proof-value", + }, + }); + + await guard.canActivate(makeContext(req)); + + expect(verifier.verify).toHaveBeenCalledWith("abc", { + dpopRequest: expect.objectContaining({ + url: "mcp://api.example.com/mcp", + }), + }); + }); + it("pins the DPoP htu to the configured resource — ignores Host / X-Forwarded-* spoofing", async () => { const claims = buildClaims(); const verifier = { diff --git a/packages/nestjs/tests/integration/auth.integration.test.ts b/packages/nestjs/tests/integration/auth.integration.test.ts index 007df68..3c646f5 100644 --- a/packages/nestjs/tests/integration/auth.integration.test.ts +++ b/packages/nestjs/tests/integration/auth.integration.test.ts @@ -14,7 +14,8 @@ import { import { Test } from "@nestjs/testing"; import { AuthplaneClient, - type AuthplaneResource, + AuthplaneResource, + type AuthplaneResourceOptions, InvalidSignature, TokenExpired, VerifiedClaims, @@ -75,6 +76,47 @@ class MathController { }) class TestAppModule {} +@Controller("tenant-mcp") +@UseGuards(AuthplaneAuthGuard) +class QueryMathController { + @Post("add") + public add(): { readonly ok: boolean } { + return { ok: true }; + } +} + +@Module({ + imports: [ + AuthplaneModule.forRoot({ + issuer: "https://auth.example.com", + resource: "https://api.example.com/mcp?tenant=a", + scopes: ["tools/add"], + }), + ], + controllers: [QueryMathController], +}) +class QueryResourceAppModule {} + +/** + * A client whose `resource()` calls through to the real core constructor, so + * the PRM document URL below is genuinely derived rather than stubbed. The + * client-owned collaborators are stubbed because nothing on the exercised + * paths (route registration, unauthenticated 401) dereferences them. + */ +function realResourceClient() { + return { + resource: (options: AuthplaneResourceOptions) => + new AuthplaneResource({ + ...options, + issuer: "https://auth.example.com", + metadataCache: {}, + fetchSettings: {}, + getJwksCache: () => ({}), + } as unknown as ConstructorParameters[0]), + close: vi.fn(async () => undefined), + }; +} + function mockResource(): AuthplaneResource { return { verify: vi.fn(), @@ -88,6 +130,10 @@ function mockResource(): AuthplaneResource { () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; } @@ -271,3 +317,49 @@ describe.each(platforms)( }); }, ); + +describe("AuthplaneModule — query-bearing resource (RFC 9728 §3)", () => { + let app: INestApplication; + + beforeAll(async () => { + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + realResourceClient() as unknown as AuthplaneClient, + ); + const moduleRef = await Test.createTestingModule({ + imports: [QueryResourceAppModule], + }).compile(); + app = moduleRef.createNestApplication(); + app.useGlobalFilters(app.get(AuthplaneExceptionFilter)); + await app.init(); + }); + + afterAll(async () => { + await app.close(); + vi.restoreAllMocks(); + }); + + it("registers the PRM route at the query-less well-known path", async () => { + // Route registration is path-keyed: the derived mount path sheds the + // query, and the served document still carries the full identifier in + // its `resource` member. + const response = await supertest(app.getHttpServer()).get( + "/.well-known/oauth-protected-resource/mcp", + ); + expect(response.status).toBe(200); + expect(response.body.resource).toBe("https://api.example.com/mcp?tenant=a"); + }); + + it("advertises the query-bearing document URL in the 401 challenge", async () => { + // End-to-end through the real core derivation (`realResourceClient`): + // the `resource_metadata` value must carry the query verbatim — that + // URL is the one a client round-trips against the served document's + // `resource` member (RFC 9728 §3.3). + const response = await supertest(app.getHttpServer()) + .post("/tenant-mcp/add") + .send({}); + expect(response.status).toBe(401); + expect(response.headers["www-authenticate"]).toContain( + 'resource_metadata="https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a"', + ); + }); +}); diff --git a/packages/nestjs/tests/module/authplane.module.test.ts b/packages/nestjs/tests/module/authplane.module.test.ts index 240c3ea..af4a296 100644 --- a/packages/nestjs/tests/module/authplane.module.test.ts +++ b/packages/nestjs/tests/module/authplane.module.test.ts @@ -1,12 +1,13 @@ import { AuthplaneClient, - type AuthplaneResource, + AuthplaneResource, + type AuthplaneResourceOptions, FetchSettings, } from "@authplane/sdk/core"; import { Test } from "@nestjs/testing"; import { afterEach, assert, describe, expect, it, vi } from "vitest"; -import { Module } from "@nestjs/common"; +import { Logger, Module } from "@nestjs/common"; import { AuthplaneAuthGuard } from "../../src/application/authplane.guard.js"; import { AuthplaneExceptionFilter } from "../../src/application/authplane.exception-filter.js"; @@ -32,6 +33,10 @@ function mockResource(): AuthplaneResource { () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", ), + resourceMetadataUrl: vi.fn( + () => + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ), } as unknown as AuthplaneResource; } @@ -534,3 +539,161 @@ describe("AuthplaneModule option passthrough", () => { await moduleRef.close(); }); }); + +/** + * A mock client whose `resource()` calls through to the real core constructor. + * + * `mockClient` above returns a stub that cannot reject anything, so a test + * built on it would pass whether or not the RFC 8707 §2 gate exists. The + * client-owned collaborators are stubbed because the indicator gate runs first + * in the constructor and nothing here dereferences them. + */ +function realResourceClient() { + return { + resource: (options: AuthplaneResourceOptions) => + new AuthplaneResource({ + ...options, + issuer: "https://auth.example.com", + metadataCache: {}, + fetchSettings: {}, + getJwksCache: () => ({}), + } as unknown as ConstructorParameters[0]), + close: vi.fn(async () => undefined), + }; +} + +describe("AuthplaneModule resource indicator (RFC 8707 §2)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("fails module initialisation on a fragment-bearing resource", async () => { + // `forRoot` itself survives: `inspectSyncFactory` swallows the throw + // from `oauthProtectedResourceMetadataPath` and simply skips the PRM + // controller. The rejection has to land when the resource provider is + // instantiated, which is what `compile()` drives here — otherwise the + // app would boot and only fail when a client tried to discover it. + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + realResourceClient() as unknown as AuthplaneClient, + ); + + await expect( + Test.createTestingModule({ + imports: [ + AuthplaneModule.forRoot({ + ...BASE_OPTIONS, + resource: "https://api.example.com/mcp#frag", + }), + ], + }).compile(), + ).rejects.toThrow(/RFC 8707 §2/u); + }); + + it("does not advise `hints.prmPath` when the resource itself was rejected", () => { + // Ordering: `compile()` reports the RFC 8707 §2 error, but registration + // runs first, and the generic "pass `hints.prmPath`" advice is a dead + // end for this failure — supplying a path registers the controller and + // the resource provider still throws. So the operator must not read it + // ahead of the real reason. + const warn = vi + .spyOn(Logger.prototype, "warn") + .mockImplementation(() => undefined); + + AuthplaneModule.forRoot({ + ...BASE_OPTIONS, + resource: "https://api.example.com/mcp#frag", + }); + + expect(warn).not.toHaveBeenCalled(); + }); + + it("still advises `hints.prmPath` when no path is derivable at all", () => { + // Control for the above: the advice is suppressed for a rejected + // identifier, not switched off. An async factory is unreachable at + // registration, so the hint is the only way to mount the controller + // and the warning is the operator's one signal. + const warn = vi + .spyOn(Logger.prototype, "warn") + .mockImplementation(() => undefined); + + AuthplaneModule.forRootAsync({ + useFactory: () => Promise.resolve(BASE_OPTIONS), + }); + + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("PRM controller not registered"), + ); + }); + + it("fails module initialisation on a relative resource through the same gate", async () => { + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + realResourceClient() as unknown as AuthplaneClient, + ); + + await expect( + Test.createTestingModule({ + imports: [ + AuthplaneModule.forRoot({ + ...BASE_OPTIONS, + resource: "/mcp", + }), + ], + }).compile(), + ).rejects.toThrow(/absolute URL with a scheme and a host/u); + }); + + it("initialises normally for the same identifier without a fragment", async () => { + // Guards the test above against passing vacuously on a broken harness. + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + realResourceClient() as unknown as AuthplaneClient, + ); + + const moduleRef = await Test.createTestingModule({ + imports: [AuthplaneModule.forRoot(BASE_OPTIONS)], + }).compile(); + + const resource = moduleRef.get(AUTHPLANE_RESOURCE); + expect(resource.prmDocumentUrl()).toBe( + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ); + + await moduleRef.close(); + }); +}); + +describe("AuthplaneModule resource_metadata override (RFC 9728 §3)", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("forwards resourceMetadataUrl to client.resource() and keeps the PRM route derived", async () => { + const resource = mockResource(); + const client = mockClient(resource); + vi.spyOn(AuthplaneClient, "create").mockResolvedValue( + client as unknown as AuthplaneClient, + ); + + const moduleRef = await Test.createTestingModule({ + imports: [ + AuthplaneModule.forRoot({ + ...BASE_OPTIONS, + resourceMetadataUrl: + "https://auth.example.com/.well-known/oauth-protected-resource/mcp", + }), + ], + }).compile(); + + // The exception filter reads the option off the core resource, so + // reaching the constructor is the whole of the plumbing; the route the + // PRM controller is mounted at is still derived from `resource`. + expect(client.resource).toHaveBeenCalledWith( + expect.objectContaining({ + resource: "https://api.example.com/mcp", + resourceMetadataUrl: + "https://auth.example.com/.well-known/oauth-protected-resource/mcp", + }), + ); + + await moduleRef.close(); + }); +}); diff --git a/packages/nestjs/tests/presentation/prm.controller.test.ts b/packages/nestjs/tests/presentation/prm.controller.test.ts index 10317bb..1c0fb73 100644 --- a/packages/nestjs/tests/presentation/prm.controller.test.ts +++ b/packages/nestjs/tests/presentation/prm.controller.test.ts @@ -17,6 +17,8 @@ describe("buildPrmController", () => { prmResponse: () => body, prmDocumentUrl: () => "https://api.example.com/.well-known/oauth-protected-resource/mcp", + resourceMetadataUrl: () => + "https://api.example.com/.well-known/oauth-protected-resource/mcp", }; } diff --git a/packages/sdk/conformance-tests/README.md b/packages/sdk/conformance-tests/README.md index 6096ffc..d41c9d3 100644 --- a/packages/sdk/conformance-tests/README.md +++ b/packages/sdk/conformance-tests/README.md @@ -25,9 +25,16 @@ The catalog case ID lives on the test itself (in the `conformanceCase(...)` call ## Catalog Alignment (Meta-test) -`catalogAlignment.test.ts` loads the shared OAuth SDK Conformance Catalog YAML (auto-discovered from the sibling [`AuthPlane/conformance`](https://github.com/AuthPlane/conformance) checkout at `../conformance/oauth-sdk-conformance-catalog.yaml`, or via the `CONFORMANCE_CATALOG_PATH` env override), extracts all `case-id`s, and verifies that every `case-id` appears in this package's conformance suite by scanning conformance test files for `conformanceCase("", ...)`. +`catalogAlignment.test.ts` loads the shared OAuth SDK Conformance Catalog YAML (auto-discovered from the sibling [`AuthPlane/conformance`](https://github.com/AuthPlane/conformance) checkout at `../conformance/oauth-sdk-conformance-catalog.yaml`, or via the `CONFORMANCE_CATALOG_PATH` env override), extracts all `case-id`s, and compares them against the declarations it finds by scanning collected test files for `conformanceCase("", ...)`. -If any catalog case is missing, the alignment test fails. +The two must agree in **both** directions, and either mismatch fails the test: + +- every catalog case is declared somewhere — otherwise bumping the pin without adding coverage would merge green and publish a report full of silent `not_run` entries; +- every declaration names a case the catalog carries — otherwise a typo, a renamed case or a case dropped from the pin leaves the marker claiming coverage that maps to nothing. The report ignores declarations it cannot match, so nothing else would go red: the run stays green while the catalog case it was meant to cover has no coverage at all. + +A failure lists every offending id with the direction it broke, so a typo shows up as both the case that lost its coverage and the misspelling that took it. + +The one sanctioned exception is `HARNESS_SELF_TEST_IDS`, which names — individually, not by prefix — the ids `tests/core/conformanceCaseCoverage.test.ts` declares to drive `conformanceCase`'s own `catch` path. Those describe the harness rather than a protocol requirement, so no catalog case will ever carry them. A companion test asserts each one is still declared and still absent from the catalog, so the exemption cannot outlive its reason. CI pins the catalog to a fixed revision, single-sourced in [`.conformance-catalog-ref`](../../../.conformance-catalog-ref) at the repo @@ -90,7 +97,7 @@ The report is written only when the run actually covered a catalog case, so a un The alignment verdict is separate. "A missing case is treated as `not_run` and fails alignment" (catalog README), so `run.alignment_ok` is `false` when a complete run left cases `not_run`. It is **omitted** under `complete: false` — there the `not_run` cases are what the filter left out and say nothing about alignment, which is the distinction `complete` exists to draw. `run` is an additive extension, which the catalog explicitly permits; redefining a Field Contract field would not be. -Note that `summary.skipped` is structurally always `0` here: this SDK has no path that emits `skipped` for a case, because nothing calls `conformanceCase` with a deferral. It is not that the SDK skips nothing by coincidence — the status is currently unreachable. go-sdk sets it from `t.Skipped()`. +Note that `summary.skipped` is structurally always `0` here: this SDK has no path that emits `skipped` for a case, because nothing calls `conformanceCase` with a deferral. It is not that the SDK skips nothing by coincidence — the status is currently unreachable. A harness that can defer a case sets it from its runner's skip signal. ### Where a case may be declared @@ -119,7 +126,7 @@ See [CONTRIBUTING.md](../../../CONTRIBUTING.md#local-verification) for the full ## Test Files -- `test_rfc8414_conformance.test.ts`: RFC 8414 +- `test_rfc8414_conformance.test.ts`: RFC 8414, plus the RFC 9728 / RFC 8707 resource-identifier and PRM-derivation cases (`sdk-resource-metadata`) - `test_oauth_protocol_conformance.test.ts`: RFC 6749, RFC 7009, RFC 7662, RFC 8693, RFC 8707 - `test_jwt_and_dpop_conformance.test.ts`: RFC 9068, RFC 8725, RFC 9449, RFC 9728 diff --git a/packages/sdk/conformance-tests/catalogAlignment.test.ts b/packages/sdk/conformance-tests/catalogAlignment.test.ts index 052c07c..bf27d2e 100644 --- a/packages/sdk/conformance-tests/catalogAlignment.test.ts +++ b/packages/sdk/conformance-tests/catalogAlignment.test.ts @@ -1,14 +1,10 @@ -import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; import { describe, expect, it } from "vitest"; -import yaml from "yaml"; -import { declarationScanDirs, scanConformanceDeclarations } from "./report.js"; - -type CatalogCase = { id: string }; - -type Catalog = { - cases: CatalogCase[]; -}; +import { + declarationScanDirs, + loadCatalogIds, + scanConformanceDeclarations, +} from "./report.js"; /** * The declared-id set, from the scanner `run.complete`'s denominator uses. @@ -21,73 +17,162 @@ type Catalog = { * structural rather than asserted. */ function extractConformanceIdsFromDirs(dirs: string[]): Set { - return new Set( - scanConformanceDeclarations(resolve(__dirname, ".."), dirs).keys(), + return new Set(extractConformanceDeclarations(dirs).keys()); +} + +/** The same scan, keeping which module declared each id. */ +function extractConformanceDeclarations(dirs: string[]): Map { + return scanConformanceDeclarations(resolve(__dirname, ".."), dirs); +} + +/** + * The module the harness self-test ids are allowed to be declared in. + * + * Keying the exemption on the id alone made the carve-out "these two ids, + * anywhere in the collected tree" — copying one of those declarations into a + * real conformance module left it exempt, and both directions stayed green. + * That is the open namespace the prefix form had, narrowed to two names rather + * than closed. The scanner already returns id → modules, so pinning the + * declaring module costs nothing and makes the exemption say what it means. + */ +const HARNESS_SELF_TEST_MODULE = "tests/core/conformanceCaseCoverage.test.ts"; + +/** Whether `id` is exempt: named, and declared only in the harness self-test. */ +function isHarnessSelfTest(id: string, declaringModules: string[]): boolean { + return ( + HARNESS_SELF_TEST_IDS.includes(id) && + declaringModules.length > 0 && + declaringModules.every((mod) => mod === HARNESS_SELF_TEST_MODULE) ); } +/** + * Declared ids that belong to the harness's own tests, not to the catalog. + * + * `tests/core/conformanceCaseCoverage.test.ts` drives `conformanceCase`'s + * `catch` path — a non-Error throwable and an Error instance — through the real + * production function, so those declarations go through the same call site a + * catalog case does. That is the point of them, and it is also why the scanner + * sees them. They describe the harness rather than a protocol requirement, so + * no catalog case will ever carry these ids. + * + * Named one by one, deliberately, rather than matched on a `cov-` prefix. A + * prefix is an open namespace: every future id starting with `cov-` leaves the + * invariant without anyone deciding that it should, including one written in a + * real conformance module. That was not hypothetical — a declaration of the id + * `cov-rfc8414-this-case-does-not-exist`, added to + * test_rfc8414_conformance.test.ts, passed green under the prefix form. Two + * names cost one line each to extend, and extending them is then a visible act + * in the diff. + * + * The id above is named, not written as a call: the scanner reads this + * directory, and a marker-shaped call in a comment is a declaration as far as + * the regex is concerned. The assertion below caught exactly that in this + * docstring on its first run. + * + * The suite below also holds this list to its claim, so it cannot quietly + * outlive the tests it exists for. + */ +const HARNESS_SELF_TEST_IDS: readonly string[] = [ + // conformanceCase records a non-Error throwable and does not rethrow it. + "cov-conformanceCase-throw-string", + // conformanceCase records an Error instance; the published record is then + // asserted to carry its message. + "cov-conformanceCase-throw-error", +]; + const skipCatalog = process.env.AUTHPLANE_CONFORMANCE_SKIP_CATALOG === "1"; describe.skipIf(skipCatalog)("conformance catalog alignment", () => { - it("all catalog case ids are covered by conformance tests", () => { - const envPath = process.env.CONFORMANCE_CATALOG_PATH; - - const candidates = [ - envPath, - // oss-repo/conformance/ — sibling of ts-sdk, from test file location - resolve( - __dirname, - "..", - "..", - "..", - "..", - "conformance", - "oauth-sdk-conformance-catalog.yaml", - ), - // oss-repo/conformance/ — when cwd is packages/sdk (npm -w invocation) - resolve( - process.cwd(), - "..", - "..", - "..", - "conformance", - "oauth-sdk-conformance-catalog.yaml", + it("catalog cases and conformanceCase declarations agree in both directions", () => { + const catalogIds = loadCatalogIds(__dirname); + + // Same directories the denominator scans, from the same helper. + const declarations = extractConformanceDeclarations( + declarationScanDirs(__dirname), + ); + const conformanceIds = new Set(declarations.keys()); + + // Direction one: a catalog case nothing declares. Bumping the pin + // without adding coverage lands here. + const uncovered = [...catalogIds] + .filter((id) => !conformanceIds.has(id)) + .sort(); + + // Direction two: a declaration the catalog does not carry — a typo, a + // renamed case, a case dropped from the pin. report.ts silently ignores + // declarations it cannot match, so without this the marker claims + // coverage that maps to nothing and the run stays green while the + // catalog case it was meant to cover has none. + const orphaned = [...conformanceIds] + .filter( + (id) => + !catalogIds.has(id) && + !isHarnessSelfTest(id, declarations.get(id) ?? []), + ) + .sort(); + + // Both directions in one verdict, rather than two sequential + // expectations. A typo'd id breaks both at once — the real case goes + // uncovered and the misspelling is an orphan — and short-circuiting on + // the first named only the case that lost its coverage, never the + // misspelling that took it. That reads as "add coverage for a case that + // already has some" and sends the reader looking in the wrong place. + const problems = [ + ...uncovered.map( + (id) => + `catalog case "${id}" has no conformanceCase(...) declaration — add coverage, or drop the case from the pinned catalog`, ), - // oss-repo/conformance/ — when cwd is the ts-sdk repo root - resolve( - process.cwd(), - "..", - "conformance", - "oauth-sdk-conformance-catalog.yaml", + ...orphaned.map( + (id) => + `conformanceCase("${id}") names a case the pinned catalog does not carry — correct the id, or bump .conformance-catalog-ref to a catalog that has it`, ), ]; - const catalogPath = - candidates - .filter((p): p is string => typeof p === "string") - .find((p) => existsSync(p)) ?? - candidates.find((p): p is string => typeof p === "string") ?? - candidates[0]; - - if (!catalogPath || !existsSync(catalogPath)) { - throw new Error( - "Missing conformance catalog. Set CONFORMANCE_CATALOG_PATH or ensure oauth-sdk-conformance-catalog.yaml exists.", - ); - } - - const text = readFileSync(catalogPath, "utf-8"); - const doc = yaml.parse(text) as Catalog; - const catalogIds = new Set(doc.cases.map((c) => c.id)); + expect(problems).toEqual([]); + }); - // Same directories the denominator scans, from the same helper. + it("every harness self-test exception is still earning its exemption", () => { + const catalogIds = loadCatalogIds(__dirname); const conformanceIds = extractConformanceIdsFromDirs( declarationScanDirs(__dirname), ); - const missing = Array.from(catalogIds).filter( + // An exemption for an id nothing declares any more is dead weight that + // reads as a live carve-out, and it is exactly what a future reader + // would copy when adding the next one. + const stale = HARNESS_SELF_TEST_IDS.filter( (id) => !conformanceIds.has(id), + ).map( + (id) => + `"${id}" is exempted but no longer declared anywhere — drop it from HARNESS_SELF_TEST_IDS`, + ); + + // An exemption that shadows a real catalog case is worse than none: the + // case would be exempted from the coverage direction it is supposed to + // be held to. + const shadowing = HARNESS_SELF_TEST_IDS.filter((id) => + catalogIds.has(id), + ).map( + (id) => + `"${id}" is exempted but the catalog now carries it — remove the exemption so the case is held to the coverage assertion`, + ); + + // An exemption only holds where it was granted. A declaration of an + // exempt id in a real conformance module is the open namespace again, + // one name at a time. + const declarations = extractConformanceDeclarations( + declarationScanDirs(__dirname), + ); + const misplaced = HARNESS_SELF_TEST_IDS.flatMap((id) => + (declarations.get(id) ?? []) + .filter((mod) => mod !== HARNESS_SELF_TEST_MODULE) + .map( + (mod) => + `"${id}" is exempted but declared in ${mod} — the exemption covers ${HARNESS_SELF_TEST_MODULE} only`, + ), ); - expect(missing).toEqual([]); + expect([...stale, ...shadowing, ...misplaced]).toEqual([]); }); }); diff --git a/packages/sdk/conformance-tests/conformanceCase.ts b/packages/sdk/conformance-tests/conformanceCase.ts index 5f20c41..486d6d7 100644 --- a/packages/sdk/conformance-tests/conformanceCase.ts +++ b/packages/sdk/conformance-tests/conformanceCase.ts @@ -92,12 +92,21 @@ export function maybeRethrowConformanceError( * * - `catalogAlignment.test.ts` extracts IDs statically from call sites. * - This helper also records runtime pass/fail info for report generation. + * + * `timeoutMs` is forwarded to `it`. A case that waits out a real refresh + * interval needs one: nothing in this package overrides Vitest's 5 s default, + * and an overrun does not degrade to a skip. It does not reach the `catch` + * below either — Vitest stops awaiting `fn`, so no record is appended at all. + * The run's exit status goes non-zero while the case reads `not_run`, or keeps + * whatever a second declaring module recorded, so the case table can still + * show it green. Left unset, `it`'s own default stands. */ export function conformanceCase( id: string, testName: string, fn: () => unknown | Promise, coverage: ConformanceCoverage = {}, + timeoutMs?: number, ): void { const resolvedCoverage = { level: coverage.level ?? "full", @@ -105,36 +114,40 @@ export function conformanceCase( note: coverage.note ?? "", }; - it(testName, async () => { - try { - await fn(); - const resolved = { - caseId: id, - testName, - status: "passed", - coverage: resolvedCoverage, - } satisfies ConformanceResult; - appendResultRecord({ ...resolved, writtenAt: Date.now() }); - } catch (err) { - const e = normalizeConformanceError(err); - const resolved = { - caseId: id, - testName, - status: "failed", - coverage: resolvedCoverage, - failure: { - message: e.message, - // exactOptionalPropertyTypes: `stack?: string` means absent, not - // `undefined`. Error.stack is optional, so omit it when missing — - // which is also what JSON.stringify does to the record downstream. - ...(e.stack === undefined ? {} : { stack: e.stack }), - }, - } satisfies ConformanceResult; - appendResultRecord({ ...resolved, writtenAt: Date.now() }); - maybeRethrowConformanceError( - err, - globalThis.__AUTHPLANE_CONFORMANCE_RETHROW_ERRORS__ !== false, - ); - } - }); + it( + testName, + async () => { + try { + await fn(); + const resolved = { + caseId: id, + testName, + status: "passed", + coverage: resolvedCoverage, + } satisfies ConformanceResult; + appendResultRecord({ ...resolved, writtenAt: Date.now() }); + } catch (err) { + const e = normalizeConformanceError(err); + const resolved = { + caseId: id, + testName, + status: "failed", + coverage: resolvedCoverage, + failure: { + message: e.message, + // exactOptionalPropertyTypes: `stack?: string` means absent, not + // `undefined`. Error.stack is optional, so omit it when missing — + // which is also what JSON.stringify does to the record downstream. + ...(e.stack === undefined ? {} : { stack: e.stack }), + }, + } satisfies ConformanceResult; + appendResultRecord({ ...resolved, writtenAt: Date.now() }); + maybeRethrowConformanceError( + err, + globalThis.__AUTHPLANE_CONFORMANCE_RETHROW_ERRORS__ !== false, + ); + } + }, + timeoutMs, + ); } diff --git a/packages/sdk/conformance-tests/report.ts b/packages/sdk/conformance-tests/report.ts index 05a3160..d17335e 100644 --- a/packages/sdk/conformance-tests/report.ts +++ b/packages/sdk/conformance-tests/report.ts @@ -315,9 +315,58 @@ export type RunFacts = { generatedAt?: string; }; -/** Parse catalog YAML. For callers that hold the text rather than the document. */ +/** + * Parse catalog YAML into a document with a usable `cases` array. + * + * The shape check is here rather than at each call site because every caller + * needs it and none of them can do anything useful without it: an empty + * document parses to `null`, one without a `cases` key gives `undefined`, and a + * `cases:` that is a scalar or a mapping is not an array — all of which make + * `.map` throw a bare `TypeError` before whatever guard the caller wrote could + * name the real problem. Entries without a string `id` are dropped for the same + * reason: one would otherwise put `undefined` in an id set and surface as + * `catalog case "undefined" has no declaration`. + * + * Callers get an empty `cases` array on all of those, which is a state they + * already have to handle — the catalog legitimately having no cases is + * indistinguishable from a wrong file, and both are a harness fault, not drift. + */ export function parseCatalog(catalogText: string): Catalog { - return yaml.parse(catalogText) as Catalog; + const doc = yaml.parse(catalogText) as Catalog | null | undefined; + const cases = Array.isArray(doc?.cases) + ? doc.cases.filter((c): c is CatalogCase => typeof c?.id === "string") + : []; + return { ...(doc ?? {}), cases } as Catalog; +} + +/** + * The case ids in the catalog resolved from `fromDir`. + * + * Lives here rather than in the alignment test so it is reachable from a test + * of its own: as a module-private function of a test file, whose input is + * process env plus the filesystem, its empty-catalog guard could not be + * exercised — and that guard shipped unreachable once already, for the two + * shapes it names, with nothing in the suite noticing. + * + * Throws rather than returning empty: a catalog that parsed to nothing passes + * the coverage direction vacuously and reports every declaration as an orphan, + * which is drift-shaped output from a harness fault. The drift workflow reads a + * red alignment step as "the catalog has cases the SDK does not cover", so this + * has to say which it is. + */ +export function loadCatalogIds(fromDir: string): Set { + const catalogPath = resolveCatalogPath(fromDir); + const ids = new Set( + parseCatalog(readFileSync(catalogPath, "utf-8")).cases.map((c) => c.id), + ); + if (ids.size === 0) { + throw new Error( + `The resolved conformance catalog (${catalogPath}) contains no cases. ` + + "It failed to parse, or the wrong file was resolved — this is a " + + "harness problem, not catalog drift.", + ); + } + return ids; } /** @@ -432,9 +481,9 @@ export function buildReportPayload( return { catalog_id: doc.catalog_id ?? "oauth-sdk-conformance-catalog", catalog_version: doc.catalog_version ?? "", - // The catalog's usage_guidance asks for an execution timestamp, and - // go-sdk emits generated_at. Without it a stale report is indistinguishable - // from a fresh one — which matters now that generation can be skipped. + // The catalog's usage_guidance asks for an execution timestamp. Without + // it a stale report is indistinguishable from a fresh one — which + // matters now that generation can be skipped. ...(facts.generatedAt ? { generated_at: facts.generatedAt } : {}), ...(Object.keys(run).length > 0 ? { run } : {}), implementation: { @@ -445,10 +494,9 @@ export function buildReportPayload( // "Non-zero on any failure" (conformance README, runner.exit_status). A // count of failed *cases* misses a module that never got to report one — // an import-time throw failed twelve cases into not_run and still wrote - // exit_status 0. go-sdk threads the real runner status through; this now - // does the same, falling back to the case count when the caller has no - // runner state to give (buildReportPayload is also called directly by - // tests). + // exit_status 0. The real runner status is threaded through instead, + // falling back to the case count when the caller has no runner state to + // give (buildReportPayload is also called directly by tests). runner: { tool: "vitest", // The runner's status, and only that. An earlier revision folded "a @@ -522,6 +570,8 @@ export function writeConformanceReport( // text from the string, so a catalog of this size was walked twice per run // for one list of ids. const catalog = parseCatalog(catalogText); + // `parseCatalog` guarantees an array of entries with string ids, so this + // cannot throw out of the vitest teardown on a wrong-file resolution. const catalogIds = new Set(catalog.cases.map((c) => c.id)); // "Did this run cover the catalog", not "did it record anything". Those // differ: tests/core/conformanceCaseCoverage.test.ts records ids that are diff --git a/packages/sdk/conformance-tests/reportWrite.test.ts b/packages/sdk/conformance-tests/reportWrite.test.ts index cefc4cf..795d4b0 100644 --- a/packages/sdk/conformance-tests/reportWrite.test.ts +++ b/packages/sdk/conformance-tests/reportWrite.test.ts @@ -11,6 +11,7 @@ import { resolve } from "node:path"; import { afterEach, beforeEach, describe, expect, it } from "vitest"; import { listDeclaringModules, + loadCatalogIds, loadResultsFromJsonlFiles, writeConformanceReport, } from "./report.js"; @@ -562,10 +563,10 @@ describe("listDeclaringModules", () => { mkdirSync(resolve(path, ".."), { recursive: true }); // Built by concatenation, not by a template literal. Spelling the call // out inline made *this file* match the declaration regex, so the scanner - // read `${id}` as a declared case id out of its own source. Inert only by - // accident today — listDeclaringModules drops it because it is not in the - // catalog, and catalogAlignment only checks catalog -> declared — but the - // reverse drift check is the natural next assertion and would turn red. + // read `${id}` as a declared case id out of its own source. This is now + // load-bearing rather than tidy: catalogAlignment asserts declared -> + // catalog as well, so an inline call would make this file declare + // `${id}`, which no catalog carries, and turn the alignment suite red. const call = 'conformanceCase("'; writeFileSync( path, @@ -635,3 +636,32 @@ describe("listDeclaringModules", () => { ]); }); }); + +describe("loadCatalogIds", () => { + // The guard this exercises shipped unreachable once, for the two shapes it + // names, and nothing in the suite noticed — which is why it lives in + // report.ts rather than as a module-private of the alignment test. + it("reads the ids of the resolved catalog", () => { + expect([...loadCatalogIds(fromDir)].sort()).toEqual(["case-a", "case-b"]); + }); + + it.each([ + ["an empty document", ""], + ["a document with no cases key", 'catalog_version: "9"\n'], + ["a cases key that is not a list", 'catalog_version: "9"\ncases: nope\n'], + ])("reports a harness problem, not drift, for %s", (_label, text) => { + writeFileSync(resolve(dir, "catalog.yaml"), text, "utf-8"); + expect(() => loadCatalogIds(fromDir)).toThrow( + /harness problem, not catalog drift/, + ); + }); + + it("drops entries with no string id rather than emitting undefined", () => { + writeFileSync( + resolve(dir, "catalog.yaml"), + 'catalog_version: "9"\ncases:\n - id: "real-case"\n - notes: "no id here"\n', + "utf-8", + ); + expect([...loadCatalogIds(fromDir)]).toEqual(["real-case"]); + }); +}); diff --git a/packages/sdk/conformance-tests/test_jwt_and_dpop_conformance.test.ts b/packages/sdk/conformance-tests/test_jwt_and_dpop_conformance.test.ts index 1c98c13..fa1676a 100644 --- a/packages/sdk/conformance-tests/test_jwt_and_dpop_conformance.test.ts +++ b/packages/sdk/conformance-tests/test_jwt_and_dpop_conformance.test.ts @@ -1489,15 +1489,43 @@ conformanceCase( }, ); +/** + * Explicit timeout for the jwks_uri rotation cases. + * + * The case waits out a real 3 s refresh interval, so its wall time is ~3.2 s + * against Vitest's 5 s default — and nothing in this package overrides that + * default. Measured at 3.23 s and 3.21 s for the two registrations in a full + * suite run, which leaves under 1.8 s of headroom for two ES256 keygens, six + * verifications and eight loopback round trips on a loaded machine. An + * overrun does not degrade to a skip — it fails the run, and because Vitest + * stops awaiting the case rather than throwing into it, no result record is + * written: the report pairs a non-zero exit status with a case table that + * reads not_run, or green off the other module's record. Hence room, not a + * margin. + */ +const ROTATION_CASE_TIMEOUT_MS = 15_000; + conformanceCase( "rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache", "RFC8414: jwks_uri rotation reconfigures JWKS cache", async () => { // Thin re-run of the RFC 8414 file's test so this duplicate registration - // records the same outcome in the conformance report. + // records the same outcome in the conformance report. Kept in step with it: + // a real configured refresh interval the test waits out, ordinary verify() + // traffic only, and the withdrawn URI answering 410 after the rotation. + const METADATA_REFRESH_SECONDS = 3; + const METADATA_PATH = "/.well-known/oauth-authorization-server"; + const JWKS_V1_PATH = "/jwks-v1.json"; + const JWKS_V2_PATH = "/jwks-v2.json"; + const v1 = await generateEs256Keypair("key-v1"); const v2 = await generateEs256Keypair("key-v2"); - let currentJwksUriPath = "/jwks-v1.json"; + + let currentJwksUriPath = JWKS_V1_PATH; + const requests: string[] = []; + const countOf = (path: string): number => + requests.filter((seen) => seen === path).length; + const { createServer } = await import("node:http"); const server = createServer(); await new Promise((resolve) => @@ -1505,8 +1533,10 @@ conformanceCase( ); const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; server.on("request", (req, res) => { + const url = req.url ?? ""; + requests.push(url); res.setHeader("content-type", "application/json"); - if (req.url === "/.well-known/oauth-authorization-server") { + if (url === METADATA_PATH) { res.end( JSON.stringify({ issuer: origin, @@ -1515,11 +1545,16 @@ conformanceCase( ); return; } - if (req.url === "/jwks-v1.json") { + if (url === JWKS_V1_PATH) { + if (currentJwksUriPath !== JWKS_V1_PATH) { + res.statusCode = 410; + res.end(); + return; + } res.end(JSON.stringify(v1.jwks)); return; } - if (req.url === "/jwks-v2.json") { + if (url === JWKS_V2_PATH) { res.end(JSON.stringify(v2.jwks)); return; } @@ -1530,24 +1565,75 @@ conformanceCase( const client = await AuthplaneClient.create({ issuer: origin, fetchSettings: NO_SSRF, - metadataRefreshSeconds: 0, + metadataRefreshSeconds: METADATA_REFRESH_SECONDS, + jwksRefreshSeconds: 300, }); try { - currentJwksUriPath = "/jwks-v2.json"; - const privateAccess = client as unknown as { - metadataCache: { get(force?: boolean): Promise }; - }; - await privateAccess.metadataCache.get(true); - await new Promise((resolve) => setTimeout(resolve, 50)); - const token = await createTokenFactory(v2)({ - iss: origin, - aud: `${origin}/api`, - }); const resource = client.resource({ resource: `${origin}/api`, scopes: ["read:data"], }); - await resource.verify(token); + const before = await resource.verify( + await createTokenFactory(v1)({ + iss: origin, + aud: `${origin}/api`, + }), + ); + expect(before.kid).toBe("key-v1"); + expect(countOf(JWKS_V1_PATH)).toBeGreaterThan(0); + + currentJwksUriPath = JWKS_V2_PATH; + + const metadataReadsBefore = countOf(METADATA_PATH); + await resource.verify( + await createTokenFactory(v1)({ + iss: origin, + aud: `${origin}/api`, + }), + ); + expect(countOf(METADATA_PATH)).toBe(metadataReadsBefore); + + await new Promise((resolve) => + setTimeout(resolve, METADATA_REFRESH_SECONDS * 1000 + 200), + ); + + // The interval has elapsed. This verification cannot miss its `kid` + // — key-v1 is still in the cached JWKS document, which carries its own + // 300 s interval — so nothing here can force a JWKS fetch, and the only + // thing that can re-read metadata is the verification path itself. That + // read is what the requirement is about: an SDK that reads metadata only + // at construction, or only when a `kid` lookup misses, fails here. + const metadataReadsBeforeElapse = countOf(METADATA_PATH); + const v2FetchesBeforeElapse = countOf(JWKS_V2_PATH); + const stillCached = await resource.verify( + await createTokenFactory(v1)({ + iss: origin, + aud: `${origin}/api`, + }), + ); + expect(stillCached.kid).toBe("key-v1"); + expect(countOf(METADATA_PATH)).toBeGreaterThan( + metadataReadsBeforeElapse, + ); + expect(countOf(JWKS_V2_PATH)).toBe(v2FetchesBeforeElapse); + + const rotated = await resource.verify( + await createTokenFactory(v2)({ + iss: origin, + aud: `${origin}/api`, + }), + ); + expect(rotated.kid).toBe("key-v2"); + expect(countOf(JWKS_V2_PATH)).toBeGreaterThan(0); + + const withdrawnReads = countOf(JWKS_V1_PATH); + await resource.verify( + await createTokenFactory(v2)({ + iss: origin, + aud: `${origin}/api`, + }), + ); + expect(countOf(JWKS_V1_PATH)).toBe(withdrawnReads); } finally { await client.close(); } @@ -1555,6 +1641,36 @@ conformanceCase( await new Promise((resolve) => server.close(() => resolve())); } }, + { + level: "partial", + gaps: [ + "A same-'kid' rotation is not demonstrated here, and the reason is " + + "this case's interval ratio rather than the 'kid'. The JWKS URI is " + + "re-resolved from the metadata document on every JWKS fetch, so a " + + "rotation is followed under an unchanged 'kid' as well, once the " + + "JWKS cache's own interval expires: worst case " + + "metadataRefreshSeconds + jwksRefreshSeconds, which stays inside " + + "the requirement's bound of two metadata refresh intervals whenever " + + "jwksRefreshSeconds <= metadataRefreshSeconds — as the SDK defaults " + + "(300 / 3600) do. This case configures the inverse (300 / 3), so " + + "the cached JWKS document outlives the wait and a lookup its " + + "unchanged 'kid' satisfies never re-drives key retrieval. Under " + + "that ratio, and only under it, a same-'kid' rotation falls outside " + + "the bound.", + ], + note: + "Ordinary verification traffic does drive the rotation: metadata is " + + "re-read on the verification path (core/resource.ts refreshMetadata, " + + "called unconditionally from verify) and the JWKS URI is resolved from " + + "the validated metadata document on every JWKS fetch " + + "(core/client.ts initializeCaches), with no force-refresh argument, " + + "hook or reflection involved. The interval-driven read is asserted on a " + + "verification that cannot miss its 'kid', so it is pinned independently " + + "of the forced re-read a 'kid' miss performs — removing the read from " + + "the verification path fails this case. What is not demonstrated is a " + + "rotation under an unchanged 'kid' — see the gap.", + }, + ROTATION_CASE_TIMEOUT_MS, ); conformanceCase( diff --git a/packages/sdk/conformance-tests/test_rfc8414_conformance.test.ts b/packages/sdk/conformance-tests/test_rfc8414_conformance.test.ts index 7e5d3ee..679ecb5 100644 --- a/packages/sdk/conformance-tests/test_rfc8414_conformance.test.ts +++ b/packages/sdk/conformance-tests/test_rfc8414_conformance.test.ts @@ -15,6 +15,7 @@ import { FetchSettings } from "../src/auth/fetchSettings.js"; import { conformanceCase } from "./conformanceCase.js"; import { createMockAsServer, + createTestFixture, generateEs256Keypair, staticMetadataFetcher, } from "./helpers.js"; @@ -187,23 +188,49 @@ conformanceCase( }, ); +/** + * Explicit timeout for the jwks_uri rotation cases. + * + * The case waits out a real 3 s refresh interval, so its wall time is ~3.2 s + * against Vitest's 5 s default — and nothing in this package overrides that + * default. Measured at 3.23 s and 3.21 s for the two registrations in a full + * suite run, which leaves under 1.8 s of headroom for two ES256 keygens, six + * verifications and eight loopback round trips on a loaded machine. An + * overrun does not degrade to a skip — it fails the run, and because Vitest + * stops awaiting the case rather than throwing into it, no result record is + * written: the report pairs a non-zero exit status with a case table that + * reads not_run, or green off the other module's record. Hence room, not a + * margin. + */ +const ROTATION_CASE_TIMEOUT_MS = 15_000; + conformanceCase( "rfc8414-jwks-uri-rotation-must-reconfigure-jwks-cache", "RFC8414: jwks_uri rotation reconfigures JWKS cache", async () => { - // Catalog stimulus: `client._on_metadata_changed` side-effect must rebind - // JWKS fetching when metadata.jwks_uri changes. + // Driven entirely through `verify()`: no force-refresh argument, no + // test-only hook, no reflective access to cache internals, and nothing + // asserted against a locally built metadata object. // - // TS has no equivalent public method, so this test reaches into private - // state to invoke `_on_metadata_changed` and force a metadata refetch, - // then exercises the end-to-end side effect: a token signed with the v2 - // key must verify, which is only possible if the JWKS cache now points - // at /jwks-v2.json. + // The refresh interval is a real configured value the test waits out, + // rather than zero. A zero interval re-reads metadata on every single + // verification and opts out of both retry floors, so it demonstrates none + // of "re-read metadata on its configured refresh interval" — it is a + // different code path from the one a deployment runs. + const METADATA_REFRESH_SECONDS = 3; const v1 = await generateEs256Keypair("key-v1"); const v2 = await generateEs256Keypair("key-v2"); - let currentJwksUriPath = "/jwks-v1.json"; + const METADATA_PATH = "/.well-known/oauth-authorization-server"; + const JWKS_V1_PATH = "/jwks-v1.json"; + const JWKS_V2_PATH = "/jwks-v2.json"; + + let currentJwksUriPath = JWKS_V1_PATH; + const requests: string[] = []; + const countOf = (path: string): number => + requests.filter((seen) => seen === path).length; + const { createServer } = await import("node:http"); const server = createServer(); await new Promise((resolve) => @@ -214,11 +241,9 @@ conformanceCase( server.on("request", (req, res) => { const url = req.url ?? ""; + requests.push(url); res.setHeader("content-type", "application/json"); - if ( - req.method === "GET" && - url === "/.well-known/oauth-authorization-server" - ) { + if (req.method === "GET" && url === METADATA_PATH) { res.end( JSON.stringify({ issuer: origin, @@ -227,11 +252,18 @@ conformanceCase( ); return; } - if (req.method === "GET" && url === "/jwks-v1.json") { + if (req.method === "GET" && url === JWKS_V1_PATH) { + // Withdrawn once the rotation is published, so a cache still bound to + // this URI cannot quietly keep working. + if (currentJwksUriPath !== JWKS_V1_PATH) { + res.statusCode = 410; + res.end(); + return; + } res.end(JSON.stringify(v1.jwks)); return; } - if (req.method === "GET" && url === "/jwks-v2.json") { + if (req.method === "GET" && url === JWKS_V2_PATH) { res.end(JSON.stringify(v2.jwks)); return; } @@ -243,35 +275,124 @@ conformanceCase( const client = await AuthplaneClient.create({ issuer: origin, fetchSettings: NO_SSRF, - metadataRefreshSeconds: 0, + metadataRefreshSeconds: METADATA_REFRESH_SECONDS, + jwksRefreshSeconds: 300, }); - - currentJwksUriPath = "/jwks-v2.json"; - - // Force metadata refetch; onChange should rebind the JWKS cache. - const privateAccess = client as unknown as { - metadataCache: { get(force?: boolean): Promise }; - }; - await privateAccess.metadataCache.get(true); - // onChange is fired via `void` — wait for the rebind to settle. - await new Promise((resolve) => setTimeout(resolve, 50)); - const { createTokenFactory } = await import("./helpers.js"); - const token = await createTokenFactory(v2)({ - iss: origin, - aud: `${origin}/api`, - }); - const resource = client.resource({ resource: `${origin}/api`, scopes: ["read:data"], }); - await resource.verify(token); - await client.close(); + try { + // Baseline: keys come from the originally published URI. + const before = await resource.verify( + await createTokenFactory(v1)({ + iss: origin, + aud: `${origin}/api`, + }), + ); + expect(before.kid).toBe("key-v1"); + expect(countOf(JWKS_V1_PATH)).toBeGreaterThan(0); + + // The AS rotates: the key set moves and the old URI is withdrawn. + // Nothing notifies the SDK. + currentJwksUriPath = JWKS_V2_PATH; + + // Still inside the interval: the configured cadence is respected, so + // verification keeps using the document it already holds instead of + // re-reading on every request. + const metadataReadsBefore = countOf(METADATA_PATH); + await resource.verify( + await createTokenFactory(v1)({ + iss: origin, + aud: `${origin}/api`, + }), + ); + expect(countOf(METADATA_PATH)).toBe(metadataReadsBefore); + + await new Promise((resolve) => + setTimeout(resolve, METADATA_REFRESH_SECONDS * 1000 + 200), + ); + + // The interval has elapsed. This verification cannot miss its `kid` + // — key-v1 is still in the cached JWKS document, which carries its own + // 300 s interval — so nothing here can force a JWKS fetch, and the only + // thing that can re-read metadata is the verification path itself. That + // read is what the requirement is about: an SDK that reads metadata only + // at construction, or only when a `kid` lookup misses, fails here. + const metadataReadsBeforeElapse = countOf(METADATA_PATH); + const v2FetchesBeforeElapse = countOf(JWKS_V2_PATH); + const stillCached = await resource.verify( + await createTokenFactory(v1)({ + iss: origin, + aud: `${origin}/api`, + }), + ); + expect(stillCached.kid).toBe("key-v1"); + expect(countOf(METADATA_PATH)).toBeGreaterThan( + metadataReadsBeforeElapse, + ); + expect(countOf(JWKS_V2_PATH)).toBe(v2FetchesBeforeElapse); + + // One ordinary verification past the interval is all it takes: the + // metadata read that verification performs picks up the new jwks_uri + // and key retrieval follows it. + const rotated = await resource.verify( + await createTokenFactory(v2)({ + iss: origin, + aud: `${origin}/api`, + }), + ); + expect(rotated.kid).toBe("key-v2"); + expect(countOf(JWKS_V2_PATH)).toBeGreaterThan(0); + + // The withdrawn URI is out of the picture: later verifications must + // not go back to it. + const withdrawnReads = countOf(JWKS_V1_PATH); + await resource.verify( + await createTokenFactory(v2)({ + iss: origin, + aud: `${origin}/api`, + }), + ); + expect(countOf(JWKS_V1_PATH)).toBe(withdrawnReads); + } finally { + await client.close(); + } } finally { await new Promise((resolve) => server.close(() => resolve())); } }, + { + level: "partial", + gaps: [ + "A same-'kid' rotation is not demonstrated here, and the reason is " + + "this case's interval ratio rather than the 'kid'. The JWKS URI is " + + "re-resolved from the metadata document on every JWKS fetch, so a " + + "rotation is followed under an unchanged 'kid' as well, once the " + + "JWKS cache's own interval expires: worst case " + + "metadataRefreshSeconds + jwksRefreshSeconds, which stays inside " + + "the requirement's bound of two metadata refresh intervals whenever " + + "jwksRefreshSeconds <= metadataRefreshSeconds — as the SDK defaults " + + "(300 / 3600) do. This case configures the inverse (300 / 3), so " + + "the cached JWKS document outlives the wait and a lookup its " + + "unchanged 'kid' satisfies never re-drives key retrieval. Under " + + "that ratio, and only under it, a same-'kid' rotation falls outside " + + "the bound.", + ], + note: + "Ordinary verification traffic does drive the rotation: metadata is " + + "re-read on the verification path (core/resource.ts refreshMetadata, " + + "called unconditionally from verify) and the JWKS URI is resolved from " + + "the validated metadata document on every JWKS fetch " + + "(core/client.ts initializeCaches), with no force-refresh argument, " + + "hook or reflection involved. The interval-driven read is asserted on a " + + "verification that cannot miss its 'kid', so it is pinned independently " + + "of the forced re-read a 'kid' miss performs — removing the read from " + + "the verification path fails this case. What is not demonstrated is a " + + "rotation under an unchanged 'kid' — see the gap.", + }, + ROTATION_CASE_TIMEOUT_MS, ); conformanceCase( @@ -391,3 +512,95 @@ conformanceCase( } }, ); + +conformanceCase( + "rfc9728-well-known-url-must-preserve-the-resource-query-component", + "RFC9728: the derived well-known PRM URL preserves the resource query", + async () => { + // The stimulus is the full derived URL, not the path: a path-only + // accessor cannot express a query, which is why the sibling case + // rfc9728-well-known-path-must-derive-from-resource-uri stays path-only. + const cases: Array<[string, string]> = [ + [ + "https://api.example.com/mcp?tenant=a", + "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a", + ], + [ + "https://api.example.com/mcp?tenant=b", + "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=b", + ], + // No path and no terminating slash, so RFC 9728 §3.1 has no slash to + // remove: the suffix lands directly after the host and the query + // follows it. + [ + "https://api.example.com?x=1", + "https://api.example.com/.well-known/oauth-protected-resource?x=1", + ], + ]; + const derived = cases.map(([resource, expectedUrl]) => { + const url = oauthProtectedResourceMetadataDocumentUrl(resource); + expect(url).toBe(expectedUrl); + return url; + }); + // Two identifiers differing only by their query must not collapse onto + // one metadata document URL — that is the multi-tenant misroute the + // case exists to forbid, and it fails with a 200 and no server signal. + expect(new Set(derived).size).toBe(derived.length); + }, +); + +conformanceCase( + "rfc8707-resource-indicator-must-not-contain-a-fragment", + "RFC8707: a resource indicator carrying a fragment is rejected at construction", + async () => { + const fixture = await createTestFixture(); + try { + // Rejection has to be observable from the construction call itself. + // An SDK that hands back a resource object and only fails later — or + // never, because the fragment was dropped while deriving the + // well-known URL — does not satisfy this case. + const construct = (): unknown => + fixture.client.resource({ + resource: "https://api.example.com/mcp#section", + scopes: ["read:data"], + }); + expect(construct).toThrow(TypeError); + expect(construct).toThrow(/must not contain a fragment component/u); + } finally { + await fixture.close(); + } + }, +); + +conformanceCase( + "rfc9728-resource-identifier-must-be-an-absolute-url-with-scheme-and-host", + "RFC9728: a resource identifier without a scheme and a host is rejected at construction", + async () => { + const fixture = await createTestFixture(); + try { + // Each value must reject on its own, and the two are not redundant: + // `//api.example.com/mcp` parses with a non-empty authority, so a + // guard phrased as "opaque or authority-less" admits it while + // correctly rejecting `/mcp`. The scheme is what both lack. + for (const resource of ["/mcp", "//api.example.com/mcp"]) { + const construct = (): unknown => + fixture.client.resource({ resource, scopes: ["read:data"] }); + expect(construct).toThrow(TypeError); + expect(construct).toThrow( + /must be an absolute URL with a scheme and a host/u, + ); + } + // Scheme-and-host, not https-only. The case explicitly keeps a + // loopback `http` identifier acceptable — local development loops + // depend on it — so a gate narrowed to https would fail here. + expect(() => + fixture.client.resource({ + resource: "http://localhost:8080/mcp", + scopes: ["read:data"], + }), + ).not.toThrow(); + } finally { + await fixture.close(); + } + }, +); diff --git a/packages/sdk/docs/user-guide.md b/packages/sdk/docs/user-guide.md index 55470c1..97b0312 100644 --- a/packages/sdk/docs/user-guide.md +++ b/packages/sdk/docs/user-guide.md @@ -118,7 +118,7 @@ Methods and accessors on `VerifiedClaims`: | `hasScope(scope)` | method | Non-throwing equivalent of `requireScope` — returns `boolean`. | | `hasClaim(key, value?)` | method | Presence check on `raw[key]`; with `value` also requires strict equality. | | `act` | getter | RFC 8693 §4.1 immediate actor (`act` claim) when obtained via token exchange, or `undefined`. | -| `mayAct` | getter | RFC 8693 §4.4 `may_act` — parties permitted to act on behalf of the subject, or `undefined`. | +| `mayAct` | getter | **Deprecated** — authserver 0.2.0 no longer issues `may_act`; removed in the next minor. Always `undefined` against 0.2.0. | ## Protected Resource Metadata (RFC 9728) @@ -141,6 +141,27 @@ app.get("/.well-known/oauth-protected-resource", (_req, res) => { }); ``` +### Where the PRM document lives + +RFC 9728 does not say who has to host the metadata document, only what a client finds when it follows the `resource_metadata` parameter of a `WWW-Authenticate` challenge. Two topologies work. + +**(a) Resource-hosted — the default.** This server serves the document itself at the URL derived from `resource`, `/.well-known/oauth-protected-resource[/path]`, and every challenge points there. Nothing to configure. Serve `resource.prmResponse()` at `resource.prmDocumentUrl()` (or at `oauthProtectedResourceMetadataPath(resource)`); every adapter does this for you. + +**(b) AS-hosted.** `authserver` >= 0.2.0 serves an RFC 9728 document for every registered Resource at `/.well-known/oauth-protected-resource/{ref}`, where `{ref}` is the Resource URI's path suffix (RFC 9728 §3.1) or its slug. Set `resourceMetadataUrl` to that URL and this server stops advertising its own; it only points at the AS's. Use it when the resource server cannot host well-known paths — a platform that owns `/.well-known`, a proxy that strips it, a resource mounted under a path it does not control. + +```ts +const resource = client.resource({ + resource: "https://api.example.com/mcp", + scopes: ["read"], + resourceMetadataUrl: + "https://auth.example.com/.well-known/oauth-protected-resource/mcp", +}); +``` + +Only the advertisement moves. `prmDocumentUrl()` keeps returning the derived URL — it is what the PRM route is mounted at — while `resource.resourceMetadataUrl()`, the accessor every challenge reads, returns the configured one. `prmResponse()` is unchanged. So the two documents can be served side by side during a migration, and switching back is a config change. + +Whichever hosts it, RFC 9728 §3.3 binds the document to this server: the `resource` value **inside** the document must equal the URL clients call, byte for byte, or a conformant client discards the document — and the resource server then looks unreachable rather than misconfigured. So the Resource URI registered at the authorization server, the `resource` configured here, and this server's public URL must be the same string; a trailing slash or an `http`/`https` difference is enough to break it. + ## Introspection and revocation By default `AuthplaneResource.verify()` relies solely on the JWT signature + claims + `exp`/`nbf` to decide validity. For stricter scenarios where the AS may revoke tokens before expiry, combine verification with RFC 7662 introspection. @@ -162,6 +183,16 @@ const resource = client.resource({ `IntrospectionRevocation.get()` returns the marker singleton that tells `AuthplaneResource.verify()` to call the AS's introspection endpoint on each token; if `active: false` comes back, `TokenRevoked` is thrown. The introspection request is authenticated with `asCredentials` configured on the resource itself — `auth` on `AuthplaneClient.create()` only powers token-acquisition flows (`clientCredentials`, `exchange`). +The introspecting client must be **confidential** (it needs a `clientSecret`) **and** either the client that was issued the token or a runtime-client of the Resource named in the token's `aud`. Since authserver 0.1.2 every other caller — a public (secret-less) client included — receives `{"active": false}`, which the SDK reads as "revoked", so a resource server introspecting with the wrong credentials silently rejects every token. Register the resource server on its Resource with: + +```bash +authserver admin resource runtime-client add --client-id --slug +``` + +A public client cannot introspect at all. + +Constructing the resource without complete `asCredentials` logs a warning saying so; the first `active: false` on a token that passed local JWT verification logs a second one pointing at the runtime-client requirement (once per resource). + You can also pass a custom `RevocationChecker` function: `(claims, rawToken) => Promise` — return `true` to reject. ### `failClosed` — availability vs. security for revocation errors @@ -345,6 +376,20 @@ const exchanged = await client.exchange({ Useful for service-to-service calls where a frontend API needs a narrowed or re-targeted token to call a downstream service. +**Operator step.** For each MCP server that exchanges for a downstream resource it does not itself act as, the operator must allowlist the exchanging client on the target Resource: + +```http +PATCH /admin/resources/{id} +{"policy": {"exchange": {"allowed_client_ids": [""]}}} +``` + +A client exchanging a token issued to itself, a fronted exchange and a Broker resource need nothing. + +Two failure answers from the AS are policy, not outages, and neither counts toward the circuit breaker: + +- `access_denied` (HTTP 403, `AccessDeniedError`) on a cross-client exchange means the operator has not allowlisted the exchanging client on the target Resource. Unlike `consent_required`, re-prompting the user will not fix it. +- `invalid_target` (HTTP 400, `InvalidTargetError`, RFC 8707 §2.2) means the `resource` string does not match a granted resource exactly — the comparison is byte for byte, so a trailing slash counts. + ### Introspection and revocation from the client ```ts @@ -433,7 +478,9 @@ All SDK errors extend `AuthplaneError`. Catch at the appropriate level and map t **OAuth client errors** (thrown by `AuthplaneClient` token methods): -- `InvalidClientError`, `InvalidGrantError`, `InvalidRequestError`, `InvalidScopeError`, `UnauthorizedClientError`, `UnsupportedGrantTypeError`, `ConsentRequiredError`, `ServerError`. +- `InvalidClientError`, `InvalidGrantError`, `InvalidRequestError`, `InvalidScopeError`, `UnauthorizedClientError`, `UnsupportedGrantTypeError`, `ConsentRequiredError`, `AccessDeniedError`, `InvalidTargetError`, `ServerError`. +- `AccessDeniedError` — `access_denied` (403) on a cross-client token exchange: the exchanging client is not allowlisted on the target Resource. An operator fix, not a user prompt (contrast `ConsentRequiredError`). Excluded from the circuit breaker. +- `InvalidTargetError` — `invalid_target` (400, RFC 8707 §2.2): the `resource` sent does not match a granted resource byte for byte. Excluded from the circuit breaker. - `InvalidGrant` — top-level catch surface for token-exchange failures (subject/actor token rejected by the AS). Distinct from the `InvalidGrantError` OAuth-error subclass: `InvalidGrant` extends `AuthplaneError` directly and carries no OAuth `code` / `statusCode`. Maps to HTTP 401 via `httpStatus`. - `CircuitOpenError` — circuit breaker is open (too many AS failures in a row). @@ -472,8 +519,21 @@ Builds an RFC 6750 §3 `WWW-Authenticate` header value. Picks the right scheme ( - `realm?: string` — appended as `realm="…"`. - `resourceMetadataUrl?: string` — appended as `resource_metadata="…"` (RFC 9728 §5.1) so clients can discover the AS. - `scope?: readonly string[]` — when non-empty, appended as `scope="…"` (RFC 6750), commonly paired with `insufficient_scope`. +- `verboseDescription?: boolean` — see below. Default `false`. + +**`error_description` is fixed, not the exception message.** The challenge and the JSON error body both answer a caller who by definition has not authenticated, so the description is chosen by the `error=` code: -**Sanitisation.** All interpolated values (`error.message`, `realm`, `resourceMetadataUrl`, joined `scope`) have CR / LF / `"` / `\` stripped before being spliced into the quoted-string parameter (RFC 9110 §11.4), so a crafted error message cannot terminate the parameter or inject a new header field. The rule is exported as `sanitiseHeaderValue(value)` for code that splices values into a challenge through a header builder outside this SDK. +| `error=` | `error_description=` | +|---|---| +| `invalid_token` | `The access token is missing or not valid for this resource` | +| `insufficient_scope` | `The access token does not carry the scope this operation requires` | +| `invalid_dpop_proof` | `The DPoP proof is missing or not valid for this request` | + +The SDK's own messages name the failing detail — the unknown `kid`, the claim that did not validate, the `typ` that was rejected — and an `aud` mismatch would hand the caller the exact audience string the resource expects, which is the value they would need in order to request a token for it. RFC 6750 §3 does not require `error_description` to be diagnostic; the `error=` code already carries what a conforming client acts on. The message stays on the exception, so log it server-side. `verboseDescription: true` restores the old behaviour for local debugging — it discloses SDK-internal detail to unauthenticated callers, so do not enable it in production. + +This covers both halves of the response. `errorResponseBody(error, options?)` builds the JSON body from the same two decisions — the `error=` code above and the sentence it selects — so the body an adapter serves and the challenge beside it can never disagree, and neither carries the message. `@authplane/mcp`, `@authplane/hono` and `@authplane/nestjs` all serve it; write it yourself only if you are building an adapter, and pass `scheme` when you emit a multi-scheme challenge set so the one body names the half you want. `verboseDescription: true` restores the message there too, under the same warning. + +**Sanitisation.** All interpolated values (`realm`, `resourceMetadataUrl`, joined `scope`, and a `verboseDescription` message) have CR / LF / `"` / `\` stripped before being spliced into the quoted-string parameter (RFC 9110 §11.4), so a crafted value cannot terminate the parameter or inject a new header field. Sanitisation is not a defence against disclosure, which is what the fixed descriptions above are for. The rule is exported as `sanitiseHeaderValue(value)` for code that splices values into a challenge through a header builder outside this SDK. ```ts import { httpStatus, wwwAuthenticate, TokenExpired } from "@authplane/sdk/core"; @@ -495,9 +555,36 @@ try { } ``` +### `wwwAuthenticateChallenges(error, options)` + +`wwwAuthenticate` picks the scheme from the error's type, so it can only ever name one. A resource running inbound DPoP in optional mode accepts both `Bearer` and `DPoP` and should advertise both, so a DPoP-capable client can discover that sender-constrained tokens are taken here (RFC 9449 §7.1; §7.2 covers running the two side by side). + +Two challenges cannot be comma-joined — the comma also separates parameters *inside* a challenge — so this returns one header value per scheme and the caller emits one header field per element: + +```ts +import { wwwAuthenticateChallenges, httpStatus } from "@authplane/sdk/core"; + +res.status(httpStatus(error)); +for (const challenge of wwwAuthenticateChallenges(error, { + schemes: ["Bearer", "DPoP"], + algs: inboundDPoP.allowedProofAlgorithms, + resourceMetadataUrl: resource.prmDocumentUrl(), +})) { + res.append("WWW-Authenticate", challenge); +} +``` + +It takes every `wwwAuthenticate` option plus two of its own: + +- `schemes?: readonly string[]` — the schemes to advertise, in order. `Bearer` and `DPoP` are recognised case-insensitively and duplicates collapse. Omit it to derive the single scheme from the error, which returns exactly what `wwwAuthenticate` would, in a one-element array. An empty array or an unrecognised scheme throws a `TypeError` — the scheme is a bare RFC 7235 token, so an unusable value is refused rather than sanitised onto the wire. +- `algs?: readonly string[]` — the RFC 9449 §7.1 `algs` parameter, emitted on the `DPoP` challenge only and ignored when `DPoP` is not among `schemes`. Omitting the property omits the parameter; passing `undefined` means "the default set", the same meaning `InboundDPoPOptions.allowedProofAlgorithms` gives it, so `algs: options.allowedProofAlgorithms` is correct on an options object built from defaults. Values are validated against the supported set rather than escaped: escaping lets a comma through, and a comma is what a lenient client-side parser splits a joined challenge on. + +The error selects the `error=` code per scheme the same way `wwwAuthenticate` does: `invalid_dpop_proof` is DPoP-specific, so a `Bearer` challenge emitted alongside a DPoP one keeps `invalid_token`. + ## Caching, circuit breaker, and cleanup - **Token cache.** `AuthplaneClient` caches client-credentials tokens (keyed by scope/resources). Configure TTL via `cacheTtlBufferSeconds` / `defaultTtlSeconds`. -- **JWKS / metadata cache.** `AuthplaneClient` refreshes JWKS every `jwksRefreshSeconds` (default 300) and metadata every `metadataRefreshSeconds` (default 3600). Both can be overridden. +- **JWKS / metadata cache.** `AuthplaneClient` refreshes JWKS every `jwksRefreshSeconds` (default 300) and metadata every `metadataRefreshSeconds` (default 3600). Both can be overridden. Refreshes are driven by traffic, not by a background timer: the first `verify()` (or AS-facing call) after the interval elapses pays for the refetch. A resource server that only verifies tokens therefore still tracks the AS. `jwks_uri` is read from the metadata document on every JWKS fetch rather than captured at construction, so a rotation takes effect on the next fetch with no window in which keys are still being pulled from the withdrawn URI. A token whose `kid` is absent from the cached JWKS re-reads metadata as well as the JWKS, so a rotation is followed on the request that first needs the new key rather than at the next interval boundary. That re-read is floored at one per `min(metadataRefreshSeconds, 60)` seconds, because the `kid` on an unverified token is attacker-controlled and the re-read bypasses the interval by design: without the floor, invalid tokens would drive discovery traffic at your AS one-for-one. Misses inside the floor fall back to an ordinary cache read, and the first miss after it re-reads immediately. Set `metadataRefreshSeconds: 0` to opt out. +- **Fetch-failure retry floor.** After a metadata or JWKS fetch fails, neither document is re-attempted for `fetchFailureBackoffSeconds` (default 30) — clamped per cache to `max(1, min(fetchFailureBackoffSeconds, refreshSeconds))`, so it never exceeds the cache's own refresh interval and never collapses to zero. In between, verifications are served from the last known good documents (an unreachable AS costs latency, not correctness); when nothing is cached yet, the retained failure surfaces immediately instead of stalling each caller on another doomed fetch. Without the floor, an AS outage amplifies into per-request latency on the resource server: once the refresh interval elapses, every wave of traffic pays the fetch timeout again — in-flight deduplication collapses concurrent callers, not the waves that follow. Any successful fetch closes the floor, and the first attempt it suppresses logs a `console.warn` naming the backing-off document (`metadata` or `JWKS`; once per floor window) so a backoff is distinguishable from a live outage. Raise the value if your AS is slow to come back and you would rather serve cached documents longer between probes; the floor is deliberately not fully disableable — except that a cache whose refresh interval is `0` (`metadataRefreshSeconds: 0` / `jwksRefreshSeconds: 0`, "re-read every time") opts out of the failure floor as well as the forced-read floor. - **Circuit breaker.** `AuthplaneClient` opens a circuit after consecutive AS failures (default threshold 5, cooldown 30 s). While open, AS calls fail fast with `CircuitOpenError`. Successful calls after cooldown close the circuit. - **Cleanup.** Call `await client.close()` on shutdown to stop timers and release resources. Adapters expose a `client` reference on their auth helper result so you can call `close()` from your server's shutdown hook. diff --git a/packages/sdk/src/auth/errors.ts b/packages/sdk/src/auth/errors.ts index db59a64..8225212 100644 --- a/packages/sdk/src/auth/errors.ts +++ b/packages/sdk/src/auth/errors.ts @@ -62,6 +62,35 @@ export class InvalidRequestError extends AuthError { } } +/** + * `access_denied` (RFC 6749 §4.1.2.1, returned here by the token endpoint on a + * token exchange) — the AS refused the request. Against authserver this means a + * cross-client token exchange whose exchanging client is not allowlisted on the + * target Resource (`policy.exchange.allowed_client_ids` / + * `policy.runtime.client_ids`); that is an operator-side fix, and re-prompting + * the user, as for `consent_required`, will not clear it. Another AS may return + * it for a resource-owner or policy denial instead. Never counts toward the + * circuit breaker. + */ +export class AccessDeniedError extends AuthError { + public constructor(message: string, statusCode: number | null = null) { + super(message, { code: "access_denied", statusCode }); + this.name = "AccessDeniedError"; + } +} + +/** + * `invalid_target` (RFC 8707 §2.2) — the `resource` sent to the token + * endpoint does not match a granted resource byte for byte (a trailing slash + * counts). Never counts toward the circuit breaker. + */ +export class InvalidTargetError extends AuthError { + public constructor(message: string, statusCode: number | null = null) { + super(message, { code: "invalid_target", statusCode }); + this.name = "InvalidTargetError"; + } +} + export class ConsentRequiredError extends AuthError { public readonly serviceId: string; public readonly causeDetail: string; @@ -135,6 +164,8 @@ export function mapOAuthError( invalid_grant: (m, s) => new InvalidGrantError(m, s), unsupported_grant_type: (m, s) => new UnsupportedGrantTypeError(m, s), invalid_request: (m, s) => new InvalidRequestError(m, s), + access_denied: (m, s) => new AccessDeniedError(m, s), + invalid_target: (m, s) => new InvalidTargetError(m, s), }; if (statusCode >= 500) { diff --git a/packages/sdk/src/auth/index.ts b/packages/sdk/src/auth/index.ts index c40e074..579b92c 100644 --- a/packages/sdk/src/auth/index.ts +++ b/packages/sdk/src/auth/index.ts @@ -12,6 +12,7 @@ export { sha256Base64Url, } from "./dpop.js"; export { + AccessDeniedError, AuthError, ConsentRequiredError, DPoPNonceRequiredError, @@ -19,6 +20,7 @@ export { InvalidGrantError, InvalidRequestError, InvalidScopeError, + InvalidTargetError, mapOAuthError, ProtocolError, ServerError, diff --git a/packages/sdk/src/core/circuitPolicy.ts b/packages/sdk/src/core/circuitPolicy.ts index 063a30f..399563b 100644 --- a/packages/sdk/src/core/circuitPolicy.ts +++ b/packages/sdk/src/core/circuitPolicy.ts @@ -5,12 +5,20 @@ import { } from "../auth/errors.js"; import { SSRFError } from "./fetching/ssrf.js"; -/** OAuth `error` codes where the AS responded correctly — do not trip the breaker. */ +/** + * OAuth `error` codes where the AS responded correctly — do not trip the breaker. + * + * `access_denied` (403, exchanging client not allowlisted on the target + * Resource) and `invalid_target` (400, `resource` does not match a granted + * resource byte for byte) are policy answers, not outages. + */ const OAUTH_ERRORS_NO_CIRCUIT = new Set([ + "access_denied", "consent_required", "interaction_required", "invalid_grant", "invalid_scope", + "invalid_target", "invalid_dpop_proof", "invalid_request", "unsupported_grant_type", diff --git a/packages/sdk/src/core/claims.ts b/packages/sdk/src/core/claims.ts index 5ea7b90..1fe8cc1 100644 --- a/packages/sdk/src/core/claims.ts +++ b/packages/sdk/src/core/claims.ts @@ -95,6 +95,7 @@ export class VerifiedClaims { if (!this.hasScope(scope)) { throw new InsufficientScope( `Token missing required scope '${scope}'. Token has scopes: ${this.scopes.join(", ")}`, + [scope], ); } } @@ -107,9 +108,13 @@ export class VerifiedClaims { * Adapter middleware (`@authplane/hono` `bearerAuth`, `@authplane/nestjs` * `AuthplaneAuthGuard`) calls this so the union check has one canonical * implementation across the SDK. The thrown error names the missing - * scope(s) and the scopes the token does carry — adapters surface this - * verbatim in `error_description`, so a client can see why the request - * was rejected without a separate log lookup. + * scope(s) and the scopes the token does carry — for the resource server's + * log and for a JSON body it chooses to emit. It does not reach the + * `WWW-Authenticate` challenge, whose `error_description` is a fixed + * sentence: naming the token's actual scopes to an unauthenticated caller + * is disclosure. `scope="…"` is what tells the client what to step up to, + * and the thrown error carries `required` so a host with no configured + * scopes of its own can still emit it. */ public requireScopes(required: readonly string[]): void { if (required.length === 0) return; @@ -119,6 +124,10 @@ export class VerifiedClaims { const present = this.scopes.length > 0 ? this.scopes.join(", ") : "(none)"; throw new InsufficientScope( `Token missing required scope${missing.length > 1 ? "s" : ""} ${quoted}. Token has scopes: ${present}`, + // The full requested set, not just the missing ones: `scope="…"` + // names what the client should step up to, and a client holding a + // partial set needs the whole list to ask for it again. + required, ); } @@ -145,6 +154,9 @@ export class VerifiedClaims { /** * RFC 8693 §4.4 `may_act` claim — identifies parties permitted to act on * behalf of the token subject. Returns `undefined` when absent. + * + * @deprecated authserver 0.2.0 no longer issues `may_act`; removed in the + * next minor. */ public get mayAct(): Readonly> | undefined { const value = this.raw.may_act; diff --git a/packages/sdk/src/core/client.ts b/packages/sdk/src/core/client.ts index 8f2c7c6..3e79843 100644 --- a/packages/sdk/src/core/client.ts +++ b/packages/sdk/src/core/client.ts @@ -40,6 +40,7 @@ export class AuthplaneClient { private fetchSettings: FetchSettings = new FetchSettings(); private jwksRefreshSeconds = 300; private metadataRefreshSeconds = 3600; + private fetchFailureBackoffSeconds: number | undefined; private metadataCache: MetadataCache | undefined; private jwksCache: JWKSCache | undefined; @@ -69,6 +70,20 @@ export class AuthplaneClient { fetchSettings?: FetchSettings | undefined; jwksRefreshSeconds?: number | undefined; metadataRefreshSeconds?: number | undefined; + /** + * Minimum interval between metadata/JWKS fetch attempts after a failed + * one (default `30`). While it holds, the last known good document keeps + * being served — or the retained failure surfaces immediately when there + * is none — instead of every caller re-paying the fetch timeout against + * an unreachable AS. The effective floor per cache is + * `max(1, min(this, refreshSeconds))`, so it never exceeds the cache's + * own refresh interval and never collapses to zero — except that a cache + * whose refresh interval is `0` ("re-read every time") opts out of the + * floor entirely, and a non-finite value here falls back to the default. + * One knob governs both documents for the same reason `fetchSettings` + * does: they share the fetch path and the failure mode. + */ + fetchFailureBackoffSeconds?: number | undefined; cacheTtlBufferSeconds?: number | undefined; defaultTtlSeconds?: number | undefined; /** @@ -99,6 +114,7 @@ export class AuthplaneClient { client.jwksRefreshSeconds = options.jwksRefreshSeconds ?? 300; client.metadataRefreshSeconds = options.metadataRefreshSeconds ?? 3600; + client.fetchFailureBackoffSeconds = options.fetchFailureBackoffSeconds; client.tokenCache = new TokenCache( options.cacheTtlBufferSeconds ?? 30, @@ -124,44 +140,43 @@ export class AuthplaneClient { maxSize: 131_072, }, ); - const buildJwks = (jwksUri: string): JWKSCache => { - const jwksFetcher = new DocumentFetcher(jwksUri, { - settings: this.fetchSettings, - maxSize: 65_536, - }); - return new JWKSCache(() => jwksFetcher.fetch(), this.jwksRefreshSeconds); - }; - - this.metadataCache = new MetadataCache(() => metadataFetcher.fetch(), { + const metadataCache = new MetadataCache(() => metadataFetcher.fetch(), { refreshSeconds: this.metadataRefreshSeconds, + failureBackoffSeconds: this.fetchFailureBackoffSeconds, expectedIssuer: this.issuer, allowHttp: this.fetchSettings.allowHttp, - onChange: async (oldMetadata, newMetadata) => { - const newJwksUri = newMetadata.jwks_uri; - if (oldMetadata.jwks_uri === newJwksUri) return; - if (typeof newJwksUri !== "string" || newJwksUri.length === 0) return; - - // Probe the candidate before swapping; on failure, keep the existing - // cache in place so verification stays available. The old cache may - // serve stale keys if the AS actually rotated signing material — that - // is a louder failure mode than silently leaving `jwksCache` undefined. - const candidate = buildJwks(newJwksUri); - try { - await candidate.get(); - } catch (error) { - console.warn( - `[authplane] JWKS URI rotated to '${newJwksUri}' but initial fetch failed; continuing with existing JWKS cache: ${String(error)}`, - ); - return; - } - const previous = this.jwksCache; - this.jwksCache = candidate; - await previous?.close().catch(() => {}); - }, }); + this.metadataCache = metadataCache; + + // The JWKS URI is resolved from the metadata cache on every JWKS fetch, + // rather than captured once and rebound when the document changes. That + // leaves no window in which the cache holds keys fetched from a URI the + // current metadata no longer names, and no second cache object to swap in: + // a rotation takes effect on the next JWKS fetch, whichever path reaches + // it first. `getJwksUri()` reads the validated document, so a metadata + // response that fails validation can never redirect key retrieval. + // Read the metadata document once here so a malformed or unreachable one + // fails `create()` as a metadata error, rather than reaching the operator + // wrapped in whatever the first JWKS fetch happened to raise. + await metadataCache.get(); - const jwksUri = await this.metadataCache.getJwksUri(); - this.jwksCache = buildJwks(jwksUri); + const settings = this.fetchSettings; + this.jwksCache = new JWKSCache( + async (forceUpstream) => { + // A forced JWKS fetch means the caller already missed the `kid` it + // needed, so the cached metadata is not trustworthy about where keys + // live either — re-read it rather than resolving against a document + // that may name the withdrawn URI. + const jwksUri = await metadataCache.getJwksUri(forceUpstream); + const jwksFetcher = new DocumentFetcher(jwksUri, { + settings, + maxSize: 65_536, + }); + return jwksFetcher.fetch(); + }, + this.jwksRefreshSeconds, + this.fetchFailureBackoffSeconds, + ); await this.jwksCache.get(); } diff --git a/packages/sdk/src/core/dpop.ts b/packages/sdk/src/core/dpop.ts index f2ff90b..ff0c3c9 100644 --- a/packages/sdk/src/core/dpop.ts +++ b/packages/sdk/src/core/dpop.ts @@ -336,15 +336,20 @@ export async function verifyDpopProof(options: { throw new InvalidDPoPProof("DPoP proof htu mismatch."); } - // RFC 9449 §8: resource servers MAY issue their own DPoP-Nonce challenges. - // When a nonce policy is configured, proofs carrying a different or - // missing `nonce` claim MUST be rejected. + // RFC 9449 §9: resource servers MAY issue their own DPoP-Nonce challenges + // (§8 is the nonce the authorization server supplies). When a nonce policy + // is configured, proofs carrying a different or missing `nonce` claim MUST + // be rejected. + // + // The message names neither nonce. `expectedNonce` is the value the + // resource server just issued, and this message reaches the caller inside + // the `WWW-Authenticate` challenge on the 401 path — handing it back would + // let whoever provoked the mismatch mint an accepted proof without ever + // making the round trip the §9 nonce exists to prove. if (options.expectedNonce) { const actualNonce = typeof payload.nonce === "string" ? payload.nonce : ""; if (actualNonce !== options.expectedNonce) { - throw new InvalidDPoPProof( - `DPoP proof nonce mismatch: expected '${options.expectedNonce}', got '${actualNonce}'`, - ); + throw new InvalidDPoPProof("DPoP proof nonce mismatch"); } } diff --git a/packages/sdk/src/core/errors.ts b/packages/sdk/src/core/errors.ts index 3dbeb27..99313cf 100644 --- a/packages/sdk/src/core/errors.ts +++ b/packages/sdk/src/core/errors.ts @@ -1,3 +1,4 @@ +import { SUPPORTED_DPOP_ALGORITHMS } from "../auth/dpop.js"; import { ERROR_MESSAGES } from "./constants.js"; export class AuthplaneError extends Error { @@ -43,9 +44,25 @@ export class InvalidClaims extends AuthplaneError { } export class InsufficientScope extends AuthplaneError { - public constructor(message = ERROR_MESSAGES.insufficientScope) { + /** + * The scope(s) whose absence caused the rejection, when the thrower knew + * them. `wwwAuthenticate()` falls back to this for the RFC 6750 §3 + * `scope="…"` parameter: an `insufficient_scope` challenge that names no + * scope tells the client it was refused but not what to step up to, and + * the error message is no longer on the wire to imply it. + * + * Empty when the thrower had no specific scope to name — a middleware + * union check that passes its own configured scopes explicitly, say. + */ + public readonly requiredScopes: readonly string[]; + + public constructor( + message = ERROR_MESSAGES.insufficientScope, + requiredScopes: readonly string[] = [], + ) { super(message); this.name = "InsufficientScope"; + this.requiredScopes = Object.freeze([...requiredScopes]); } } @@ -230,54 +247,227 @@ export function sanitiseHeaderValue(value: string): string { } /** - * Build an RFC 6750 §3 `WWW-Authenticate` header value. + * Fixed, caller-safe `error_description` text, keyed by the RFC 6750 §3.1 / + * RFC 9449 §7.1 error code. * - * Maps SDK errors to the correct error code and authentication scheme: - * - {@link InsufficientScope} → `insufficient_scope` - * - {@link MultipleDPoPProofs} → `DPoP` scheme with `invalid_dpop_proof` - * (RFC 9449 §7.1 — the spec-defined error code for §4.3 - * proof-validation failures) - * - Other {@link DPoPError} subclasses → `DPoP` scheme with `invalid_token` - * (except {@link DPoPNotSupported}, see below) - * - All other {@link AuthplaneError} → `Bearer` scheme with `invalid_token` + * The challenge reaches a caller who by definition has not authenticated, so + * the description is chosen by the error code and never taken from the + * exception message. The SDK's own messages name the failing detail — the + * unknown `kid`, the claim that did not validate, the `typ` that was rejected + * — and an `aud` mismatch in particular would hand the caller the exact + * audience string the resource expects, which is the value they would need in + * order to request a token for it. RFC 6750 §3 does not require + * `error_description` to be diagnostic: the `error` code already carries + * everything a conforming client needs to decide what to do next. + * {@link sanitiseHeaderValue} is not a defence here — it prevents header + * injection, not disclosure; a sanitised `kid` is still a `kid`. * - * `DPoPNotSupported` is the carve-out: although it extends `DPoPError`, - * the request was *not* DPoP-bound — the client presented a DPoP signal - * against a resource that does not accept DPoP, so the retry challenge - * must be `Bearer`, not `DPoP`. The subclass branch order below is - * load-bearing. + * The descriptions carry no comma. A comma inside a quoted-string is legal + * RFC 7235, but it is also the separator between challenge parameters and + * between header values, so keeping it out of the one parameter whose text we + * choose leaves nothing for a lenient client-side parser to split on. + */ +const SAFE_ERROR_DESCRIPTIONS: Readonly> = { + invalid_token: "The access token is missing or not valid for this resource", + insufficient_scope: + "The access token does not carry the scope this operation requires", + invalid_dpop_proof: "The DPoP proof is missing or not valid for this request", +}; + +/** + * Fallback for an error code added without a matching entry above. Kept + * deliberately contentless for the same reason the table exists. + */ +const FALLBACK_ERROR_DESCRIPTION = "The request could not be authenticated"; + +/** Authentication scheme this SDK can advertise in a challenge. */ +export type ChallengeScheme = "Bearer" | "DPoP"; + +/** + * Schemes accepted from a caller, canonicalised. Unlike the quoted challenge + * parameters, the scheme is a bare RFC 7235 token, so an unrecognised value is + * rejected outright rather than sanitised into the header. + */ +const SUPPORTED_SCHEMES: Readonly> = { + bearer: "Bearer", + dpop: "DPoP", +}; + +/** The RFC 6750 §3.1 error code to advertise for `error` under `scheme`. */ +function errorCodeFor(error: AuthplaneError, scheme: ChallengeScheme): string { + if (error instanceof InsufficientScope) { + return "insufficient_scope"; + } + // RFC 9449 §7.1 prescribes `invalid_dpop_proof` for §4.3 cardinality + // rejections, not the `invalid_token` the other DPoPError shapes use. The + // code is defined for the DPoP scheme, so a Bearer challenge emitted + // alongside it keeps `invalid_token` rather than naming a code Bearer does + // not define. + if (error instanceof MultipleDPoPProofs && scheme === "DPoP") { + return "invalid_dpop_proof"; + } + return "invalid_token"; +} + +/** + * The single scheme that matches `error`'s type. * - * Optional `options.resourceMetadataUrl` appends RFC 9728 §5.1 - * `resource_metadata="…"`. Optional `options.scope` appends RFC 6750 - * `scope="…"` when non-empty; commonly paired with `insufficient_scope` - * but also valid alongside `invalid_token`. + * `DPoPNotSupported` is the carve-out: although it extends `DPoPError`, the + * request was *not* DPoP-bound — the client presented a DPoP signal against a + * resource that does not accept DPoP, so the retry challenge must be `Bearer`. + * The branch order below is load-bearing. */ -export function wwwAuthenticate( +function schemeFor(error: AuthplaneError): ChallengeScheme { + if (error instanceof DPoPNotSupported) { + return "Bearer"; + } + return error instanceof DPoPError ? "DPoP" : "Bearer"; +} + +/** + * The fixed sentence for `errorCode`, with no transform applied. Split out of + * {@link descriptionFor} because the challenge and the JSON body need the same + * text under different escaping rules: the header path runs it through + * {@link sanitiseHeaderValue}, the body path hands it to `JSON.stringify`. + */ +function safeDescriptionFor(errorCode: string): string { + return SAFE_ERROR_DESCRIPTIONS[errorCode] ?? FALLBACK_ERROR_DESCRIPTION; +} + +function descriptionFor( + error: AuthplaneError, + errorCode: string, + verbose: boolean, +): string { + if (verbose) { + return sanitiseHeaderValue(error.message); + } + return safeDescriptionFor(errorCode); +} + +/** Canonicalise and de-duplicate `schemes`, preserving caller order. */ +function normaliseSchemes(schemes: readonly string[]): ChallengeScheme[] { + const normalised: ChallengeScheme[] = []; + for (const scheme of schemes) { + // Object.hasOwn, not a bare index: SUPPORTED_SCHEMES is an object literal, + // so `constructor`, `tostring` and above all `__proto__` resolve to + // inherited members rather than undefined, skip the guard below, and put a + // non-token value straight into the scheme position of the header. + // noUncheckedIndexedAccess types this `ChallengeScheme | undefined`, which + // is exactly the case where the type lies. + const key = scheme.trim().toLowerCase(); + const canonical = Object.hasOwn(SUPPORTED_SCHEMES, key) + ? SUPPORTED_SCHEMES[key] + : undefined; + if (canonical === undefined) { + throw new TypeError( + `Unsupported authentication scheme ${JSON.stringify(scheme)}; only ${JSON.stringify( + Object.values(SUPPORTED_SCHEMES), + )} can be advertised`, + ); + } + if (!normalised.includes(canonical)) { + normalised.push(canonical); + } + } + if (normalised.length === 0) { + throw new TypeError( + "schemes must be non-empty; omit it to derive the scheme from the error", + ); + } + return normalised; +} + +/** + * Resolve `algs` to the exact set to advertise, rejecting what cannot be. + * + * Three inputs, three defined meanings: + * + * - the property absent — omit the parameter. This is what every caller that + * does not care about `algs` relies on, so it has to be the default. + * - the property present and `undefined` — the default set, the same meaning + * `InboundDPoPOptions.allowedProofAlgorithms` gives it. This is what + * makes the documented `algs: options.allowedProofAlgorithms` call correct + * on an options object built from defaults, where that field *is* + * `undefined`: it would otherwise advertise nothing at all. Presence is read + * off the object rather than off the value because those are the only two + * states TypeScript cannot collapse into one. + * - an array — validated, not sanitised. These are bare RFC 7235 tokens, the + * same shape as the scheme, so they get the same treatment: an unusable + * value is refused rather than quietly rewritten. Escaping alone lets a + * comma through, and a comma is the one character the surrounding code works + * to keep out of parameter text so that a lenient client-side parser has + * nothing to split on. An empty array stays "omit the parameter". + */ +function resolveAlgs(options: { + algs?: readonly string[] | undefined; +}): readonly string[] { + if (!Object.hasOwn(options, "algs")) { + return []; + } + const algs = options.algs; + if (algs === undefined) { + return SUPPORTED_DPOP_ALGORITHMS; + } + // Defense in depth for JSON-config callers that cast at the boundary: a + // bare string is iterable, so it would join into `algs="E S 2 5 6"` — a + // challenge advertising algorithms that do not exist, from which a + // conforming client concludes it cannot sign a proof at all. + if (typeof algs === "string") { + throw new TypeError( + `algs must be an array of algorithm names, not a bare string (${JSON.stringify(algs)}); pass [${JSON.stringify(algs)}] to advertise a single algorithm`, + ); + } + const supported = SUPPORTED_DPOP_ALGORITHMS as readonly string[]; + const unsupported = algs.filter((alg) => !supported.includes(alg)); + if (unsupported.length > 0) { + throw new TypeError( + `Unsupported DPoP proof algorithms ${JSON.stringify(unsupported)}; only ${JSON.stringify(SUPPORTED_DPOP_ALGORITHMS)} can be advertised`, + ); + } + return algs; +} + +/** + * Fall back to {@link InsufficientScope.requiredScopes} when the caller passed + * no `scope`. An explicit argument always wins, including an empty one — a + * middleware that knows its own required scopes has said what it wants. + */ +function resolveScope( error: AuthplaneError, + scope: readonly string[] | undefined, +): readonly string[] | undefined { + if ( + scope === undefined && + error instanceof InsufficientScope && + error.requiredScopes.length > 0 + ) { + return error.requiredScopes; + } + return scope; +} + +/** Assemble one `WWW-Authenticate` header value for a single scheme. */ +function buildChallenge( + error: AuthplaneError, + scheme: ChallengeScheme, options: { - realm?: string; - resourceMetadataUrl?: string; - scope?: readonly string[]; - } = {}, + realm: string | undefined; + resourceMetadataUrl: string | undefined; + scope: readonly string[] | undefined; + algs: readonly string[]; + verboseDescription: boolean; + }, ): string { - const errorCode = - error instanceof InsufficientScope - ? "insufficient_scope" - : error instanceof MultipleDPoPProofs - ? "invalid_dpop_proof" - : "invalid_token"; - const scheme = - error instanceof DPoPNotSupported - ? "Bearer" - : error instanceof DPoPError - ? "DPoP" - : "Bearer"; + const errorCode = errorCodeFor(error, scheme); const parts: string[] = []; if (options.realm) { parts.push(`realm="${sanitiseHeaderValue(options.realm)}"`); } parts.push(`error="${errorCode}"`); - parts.push(`error_description="${sanitiseHeaderValue(error.message)}"`); + parts.push( + `error_description="${descriptionFor(error, errorCode, options.verboseDescription)}"`, + ); if (options.scope && options.scope.length > 0) { parts.push(`scope="${sanitiseHeaderValue(options.scope.join(" "))}"`); } @@ -286,13 +476,222 @@ export function wwwAuthenticate( `resource_metadata="${sanitiseHeaderValue(options.resourceMetadataUrl)}"`, ); } + // RFC 9449 §7.1 defines `algs` for the DPoP challenge only, so a Bearer + // challenge in the same set never carries it. No escaping: `resolveAlgs` + // has already refused anything that is not one of the supported bare + // tokens, so there is nothing to escape. + if (scheme === "DPoP" && options.algs.length > 0) { + parts.push(`algs="${options.algs.join(" ")}"`); + } return `${scheme} ${parts.join(", ")}`; } +/** Options shared by both challenge builders. */ +export interface ChallengeOptions { + /** RFC 7235 `realm`, emitted on every challenge when non-empty. */ + realm?: string; + /** RFC 9728 §5.1 `resource_metadata`, emitted on every challenge. */ + resourceMetadataUrl?: string; + /** + * RFC 6750 §3 `scope`, space-joined, emitted when non-empty. Falls back to + * {@link InsufficientScope.requiredScopes} when not passed. + */ + scope?: readonly string[]; + /** + * Development-only. Restores the previous behaviour of copying the + * exception message into `error_description`. It discloses SDK-internal + * detail (the unknown `kid`, the claim that failed, the expected + * audience) to unauthenticated callers, so do not enable it in + * production. Defaults to `false`. + */ + verboseDescription?: boolean; +} + +/** + * Build an RFC 6750 §3 `WWW-Authenticate` header value. + * + * Maps SDK errors to the correct error code and authentication scheme: + * - {@link InsufficientScope} → `insufficient_scope` + * - {@link MultipleDPoPProofs} → `DPoP` scheme with `invalid_dpop_proof` + * (RFC 9449 §7.1 — the spec-defined error code for §4.3 + * proof-validation failures) + * - Other {@link DPoPError} subclasses → `DPoP` scheme with `invalid_token` + * (except {@link DPoPNotSupported}, which retries as `Bearer`) + * - All other {@link AuthplaneError} → `Bearer` scheme with `invalid_token` + * + * `error_description` is a fixed, caller-safe sentence chosen by the error + * code — the exception's own message is never placed on the wire, because the + * challenge is served to a caller who has not authenticated. The message stays + * on the exception for the resource server to log. + * + * Optional `options.resourceMetadataUrl` appends RFC 9728 §5.1 + * `resource_metadata="…"`. Optional `options.scope` appends RFC 6750 + * `scope="…"` when non-empty; commonly paired with `insufficient_scope` + * but also valid alongside `invalid_token`. Every interpolated value is + * sanitised against header injection (RFC 9110 §11.4). + * + * A resource that accepts more than one scheme — inbound DPoP in optional + * mode accepts both `Bearer` and `DPoP` — cannot be described by a single + * header value; use {@link wwwAuthenticateChallenges} for that. + */ +export function wwwAuthenticate( + error: AuthplaneError, + options: ChallengeOptions = {}, +): string { + return buildChallenge(error, schemeFor(error), { + realm: options.realm, + resourceMetadataUrl: options.resourceMetadataUrl, + scope: resolveScope(error, options.scope), + algs: [], + verboseDescription: options.verboseDescription ?? false, + }); +} + +/** Options for {@link wwwAuthenticateChallenges}. */ +export interface ChallengesOptions extends ChallengeOptions { + /** + * The schemes to advertise, in the order they should appear. `Bearer` and + * `DPoP` are recognised, case-insensitively; duplicates collapse. Omit it + * to derive the single scheme from the error's type, which returns exactly + * what {@link wwwAuthenticate} would, in a one-element array. + */ + schemes?: readonly string[]; + /** + * JOSE `alg` values accepted for DPoP proofs, emitted as the RFC 9449 §7.1 + * `algs` parameter on the `DPoP` challenge only, and ignored when `DPoP` is + * not among `schemes`. Pass `options.allowedProofAlgorithms` straight + * through: `undefined` there means "the default set", and means the same + * here, so an options object built from defaults advertises the algorithms + * it actually accepts rather than nothing. Omitting the property omits the + * parameter. Values are validated against the supported set, so an unusable + * one throws rather than reaching the wire. + */ + algs?: readonly string[] | undefined; +} + +/** + * Build one RFC 6750 §3 challenge per authentication scheme the resource + * accepts. + * + * {@link wwwAuthenticate} picks the scheme from the error's type, so it can + * only ever name one. A resource running inbound DPoP in optional mode accepts + * both `Bearer` and `DPoP` and should advertise both, so that a DPoP-capable + * client can discover that sender-constrained tokens are taken here + * (RFC 9449 §7.1; §7.2 covers running the two schemes side by side). + * + * Two challenges cannot be joined with a comma: the comma is also the + * separator *between parameters inside* a challenge, so the result cannot be + * parsed unambiguously. RFC 7235 §4.1 permits the comma-joined form but warns + * about parsing it, so separate `WWW-Authenticate` header values are the + * interoperable choice: this returns an array and the caller emits one header + * value per element. + * + * ```ts + * for (const challenge of wwwAuthenticateChallenges(error, { + * schemes: ["Bearer", "DPoP"], + * algs: inboundDPoP.allowedProofAlgorithms, + * })) { + * res.append("WWW-Authenticate", challenge); + * } + * ``` + * + * The error selects the error code the same way {@link wwwAuthenticate} does, + * per scheme: `invalid_dpop_proof` is DPoP-specific, so a `Bearer` challenge + * emitted alongside a DPoP one keeps `invalid_token`. + * + * @throws TypeError If `schemes` is empty or names a scheme this SDK cannot + * advertise, or if `algs` names an algorithm it cannot accept. + */ +export function wwwAuthenticateChallenges( + error: AuthplaneError, + options: ChallengesOptions = {}, +): string[] { + const schemes = + options.schemes === undefined + ? [schemeFor(error)] + : normaliseSchemes(options.schemes); + const algs = resolveAlgs(options); + const scope = resolveScope(error, options.scope); + return schemes.map((scheme) => + buildChallenge(error, scheme, { + realm: options.realm, + resourceMetadataUrl: options.resourceMetadataUrl, + scope, + algs, + verboseDescription: options.verboseDescription ?? false, + }), + ); +} + +/** The RFC 6750 §3 JSON error body an adapter serves alongside the challenge. */ +export interface ErrorResponseBody { + /** RFC 6750 §3.1 / RFC 9449 §7.1 error code — the same one the challenge names. */ + error: string; + /** Fixed, caller-safe sentence chosen by {@link ErrorResponseBody.error}. */ + error_description: string; +} + +/** Options for {@link errorResponseBody}. */ +export interface ErrorBodyOptions { + /** + * The scheme whose error code the body should name. Defaults to the single + * scheme that matches the error's own type — the same default + * {@link wwwAuthenticate} applies — so the body and the challenge agree + * without the caller restating it. Pass it explicitly only when emitting a + * multi-scheme challenge set, where the codes can differ per scheme and the + * one body has to pick one. + */ + scheme?: ChallengeScheme; + /** + * Development-only. Restores the previous behaviour of copying the + * exception message into `error_description`. It discloses SDK-internal + * detail (the unknown `kid`, the claim that failed, the expected audience) + * to unauthenticated callers, so do not enable it in production. Defaults + * to `false`. + */ + verboseDescription?: boolean; +} + +/** + * Build the RFC 6750 §3 JSON error body for `error`. + * + * The body and the `WWW-Authenticate` challenge travel in the same response to + * the same unauthenticated caller, so they are composed from the same two + * decisions: {@link wwwAuthenticate}'s error code, and the fixed sentence that + * code selects. The exception's own message never reaches the wire — it names + * the failing detail (the unknown `kid`, the claim that did not validate, the + * `aud` the resource expects, which is the value a caller would need in order + * to request a token for it), and a client reads whichever half it finds, so + * fixing only the challenge would leave the disclosure intact while making it + * look closed. The message stays on the exception for the resource server to + * log. + * + * Adapters should serve this rather than assembling `{ error, error_description }` + * themselves: hand-rolled copies picked the code with a two-way + * `InsufficientScope ? … : "invalid_token"` branch, which named `invalid_token` + * in the body while the challenge above it said `invalid_dpop_proof`. + */ +export function errorResponseBody( + error: AuthplaneError, + options: ErrorBodyOptions = {}, +): ErrorResponseBody { + const errorCode = errorCodeFor(error, options.scheme ?? schemeFor(error)); + return { + error: errorCode, + // Raw, not sanitised: sanitiseHeaderValue exists for the quoted-string + // rules of a header, and applying it here would mangle a verbose message + // that JSON escaping already handles correctly. + error_description: options.verboseDescription + ? error.message + : safeDescriptionFor(errorCode), + }; +} + // --------------------------------------------------------------------------- // Auth client / OAuth errors (AS interactions) // --------------------------------------------------------------------------- export { + AccessDeniedError, AuthError, ConsentRequiredError, DPoPNonceRequiredError, @@ -300,6 +699,7 @@ export { InvalidGrantError, InvalidRequestError, InvalidScopeError, + InvalidTargetError, mapOAuthError, ProtocolError, ServerError, diff --git a/packages/sdk/src/core/fetching/documentCache.ts b/packages/sdk/src/core/fetching/documentCache.ts index dc812a7..4211ae0 100644 --- a/packages/sdk/src/core/fetching/documentCache.ts +++ b/packages/sdk/src/core/fetching/documentCache.ts @@ -5,42 +5,104 @@ import { } from "../errors.js"; import type { FetchResult } from "./fetchResult.js"; -type Fetcher> = () => Promise< - FetchResult ->; +/** + * `forceUpstream` is set when the caller could not be satisfied from cache and + * a stale upstream answer would therefore be wrong — a JWKS `kid` miss is the + * case that matters, since the URI to fetch from is itself read from another + * cache. A proactive background refresh does not set it. + */ +type Fetcher> = ( + forceUpstream: boolean, +) => Promise>; export class DocumentCache> { + /** + * Default minimum interval between fetch attempts after a failed one. + * Shared across the SDKs: the effective floor is + * `max(1, min(failureBackoffSeconds, refreshSeconds))` — the `min` so a + * cache asked to refresh every 5 seconds is not pinned to a 30-second + * retry floor, the `max` so the floor never collapses to zero and an + * unreachable upstream cannot be re-attempted on every call. + * `refreshSeconds: 0` ("re-read every time") opts out of the floor + * entirely, consistent with the forced-read floor in + * `MetadataCache.admitForcedRead()`. + */ + private static readonly DEFAULT_FAILURE_BACKOFF_SECONDS = 30; + private readonly fetcher: Fetcher; private readonly refreshSeconds: number; - private readonly onChange?: - | ((oldDoc: TDocument, newDoc: TDocument) => Promise) - | undefined; + private readonly failureBackoffSeconds: number; private readonly errorFactory: (message: string) => Error; + /** "JWKS" or "metadata" — for log messages. */ + private readonly documentType: string; private cache: TDocument | undefined; private cacheTimeSeconds = 0; private serverExpiresAt: number | undefined; + private lastFailureSeconds: number | undefined; + private lastFailureError: unknown; + private failureFloorWarned = false; private fetchInFlight: Promise | undefined; + private fetchInFlightForced = false; private refreshInFlight: Promise | undefined; + // Two fetches run concurrently by design: a forced caller deliberately does + // not join a non-forced fetch already in flight. `startedSequence` orders + // them by start; `committedSequence` records the newest that has reached the + // cache, so a slower earlier fetch cannot overwrite a newer document. + private startedSequence = 0; + private committedSequence = 0; public constructor( fetcher: Fetcher, options: { refreshSeconds: number; + failureBackoffSeconds?: number | undefined; errorFactory?: (message: string) => Error; - onChange?: (oldDoc: TDocument, newDoc: TDocument) => Promise; + documentType?: string; }, ) { this.fetcher = fetcher; this.refreshSeconds = options.refreshSeconds; - this.onChange = options.onChange; + // Non-finite values fall back to the default rather than into the clamp: + // `min`/`max` propagate `NaN`, and a `NaN` floor compares false in the + // refusal check — the unbounded retry behaviour this floor exists to + // prevent, reachable from `Number(process.env.X)` on an unset variable. + const requestedBackoff = + options.failureBackoffSeconds ?? + DocumentCache.DEFAULT_FAILURE_BACKOFF_SECONDS; + const backoff = Number.isFinite(requestedBackoff) + ? requestedBackoff + : DocumentCache.DEFAULT_FAILURE_BACKOFF_SECONDS; + const refresh = Number.isFinite(options.refreshSeconds) + ? options.refreshSeconds + : Number.POSITIVE_INFINITY; + // `refreshSeconds: 0` means "re-read every time" and opts out of the + // failure floor the same way it opts out of the forced-read floor in + // `admitForcedRead()` — clamping it to 1 would quietly give the opt-out + // a floor the docs say it does not have. + this.failureBackoffSeconds = + refresh <= 0 ? 0 : Math.max(1, Math.min(backoff, refresh)); this.errorFactory = options.errorFactory ?? ((message) => new JWKSFetchError(message)); + this.documentType = options.documentType ?? "Document"; } private effectiveExpiresAt(): number { const localExpiry = this.cacheTimeSeconds + this.refreshSeconds; - if (this.serverExpiresAt === undefined) { + // A server expiry at or before the moment the document was cached is no + // preference at all, not a shorter TTL: `Cache-Control: max-age=0` and an + // `Expires:` header already in the past both arrive here as a zero or + // negative TTL, and mining either into the local expiry leaves the + // document expired on every read. `get()` then takes the synchronous + // re-fetch path on every call — and this cache is read on the verification + // path, before any signature is checked, so an unauthenticated caller + // drives one upstream fetch per request. Ignored here so the configured + // interval governs. `no-store` / `no-cache` never reach this: they carry no + // `max-age`, so the header parser reports no server expiry at all. + if ( + this.serverExpiresAt === undefined || + this.serverExpiresAt <= this.cacheTimeSeconds + ) { return localExpiry; } return Math.min(localExpiry, this.serverExpiresAt); @@ -62,41 +124,155 @@ export class DocumentCache> { if (this.refreshInFlight) { return; } - this.refreshInFlight = this.get(true) + // Bypasses this cache's TTL but not the upstream one: a proactive refresh + // is not evidence that anything upstream is stale. + this.refreshInFlight = this.fetchAndUpdate(false) .then(() => {}) + .catch(() => {}) .finally(() => { this.refreshInFlight = undefined; }); } - private async fetchAndUpdate(): Promise { - if (this.fetchInFlight) { - return this.fetchInFlight; + private failureFloorRemainingSeconds(): number { + if (this.lastFailureSeconds === undefined) { + return 0; } + return ( + this.failureBackoffSeconds - + (Math.floor(Date.now() / 1000) - this.lastFailureSeconds) + ); + } - this.fetchInFlight = (async () => { - const oldCache = this.cache; - const result = await this.fetcher(); - const now = Math.floor(Date.now() / 1000); - this.cache = result.document; - this.cacheTimeSeconds = now; - this.serverExpiresAt = result.expiresAt; + /** + * True while the failure floor holds — no fetch attempt can start before it + * elapses. Exposed to subclasses so a budget stamped ahead of an attempt + * (`MetadataCache.admitForcedRead()`) is not spent on a read the floor is + * going to refuse anyway. + */ + protected isWithinFailureFloor(): boolean { + return this.failureFloorRemainingSeconds() > 0; + } - if ( - oldCache && - this.onChange && - JSON.stringify(oldCache) !== JSON.stringify(result.document) - ) { - void this.onChange(oldCache, result.document); + /** + * Applied to a freshly fetched document before it is committed to the cache. + * A subclass that throws here leaves the previously cached document in place, + * so nothing downstream can read a document that failed validation — not even + * transiently. The default accepts every document. + */ + protected validateDocument(document: TDocument): TDocument { + return document; + } + + private async fetchAndUpdate(forceUpstream: boolean): Promise { + // Join an in-flight fetch only when it is at least as forceful as this one. + // A forced caller must not inherit the answer of a fetch that was allowed + // to resolve its URI from a stale upstream cache. + if (this.fetchInFlight && (this.fetchInFlightForced || !forceUpstream)) { + return this.fetchInFlight; + } + + // Retry floor: after a failed attempt, refuse to reach upstream again + // before `failureBackoffSeconds` have passed. Without it, an unreachable + // upstream turns into per-request latency amplification once the refresh + // interval elapses — every wave of traffic pays the fetch timeout again, + // with no backoff between waves (`fetchInFlight` dedupes within a wave, + // not across them). Applies to forced reads too: the caller that forces is + // a JWKS `kid` miss, and hammering an upstream that just failed does not + // make the key appear. Refusing rethrows the failure that started the + // window, so `get()` serves the last known good document when one exists + // and fails fast — same typed error, no network wait — when none does. + const floorRemaining = this.failureFloorRemainingSeconds(); + if (floorRemaining > 0) { + // One warning per floor window, not per refusal: an operator needs to + // tell a backoff from a live outage, but under per-request traffic the + // refusals are exactly what the floor makes cheap. + if (!this.failureFloorWarned) { + this.failureFloorWarned = true; + console.warn( + `[authplane] ${this.documentType} refresh backing off after a failed attempt (retry in ${floorRemaining}s).`, + ); } + // The retained instance itself, deliberately shared across every + // refusal in the window: it is the error the suppressed attempt + // produced, complete with its original stack. Rebuilding it per + // caller costs more than the sharing does — a descriptor-copying + // clone has no `[[ErrorData]]` slot, so its `stack` reads + // `undefined` and it fails `isNativeError`, while `errorFactory` + // would relabel a metadata failure surfacing through the JWKS + // cache — and callers do not own errors they catch. + throw this.lastFailureError; + } - return result.document; + const sequence = ++this.startedSequence; + const pending = (async () => { + let result: FetchResult; + let document: TDocument; + try { + result = await this.fetcher(forceUpstream); + // Validate before committing, not after reading. `this.cache` is what + // every reader sees — including `jwks_uri` resolution — so validating + // on the way out would let a rejected document decide where keys come + // from. + document = this.validateDocument(result.document); + } catch (error) { + // A rejected document opens the floor exactly like an unreachable + // upstream: both would otherwise be re-attempted on every call, and + // the retained error keeps refusals indistinguishable from the + // attempt they suppress. Guarded by the mirror of the success + // sequence check below: a failure that has already been superseded — + // a newer fetch committed while this one was in flight — proves + // nothing about the upstream now, and stamping it would open a floor + // against an upstream that demonstrably just answered. + if (sequence > this.committedSequence) { + this.lastFailureSeconds = Math.floor(Date.now() / 1000); + this.lastFailureError = error; + this.failureFloorWarned = false; + } + throw error; + } + // Any successful attempt closes the floor — the upstream answered, so + // the next expiry may reach it again — including a superseded one, + // which proves reachability even though its document is discarded. + this.lastFailureSeconds = undefined; + this.lastFailureError = undefined; + // Commit only if nothing newer has. Fetches can land out of order — + // a background refresh started before a rotation can return after a + // forced read that observed it — and an unconditional write is + // last-writer-wins, which would put the withdrawn `jwks_uri` back for + // the rest of the interval. Ordered by start rather than by + // forcefulness. Start order is an approximation of "which fetch saw the + // more recent upstream state", not that property: an earlier-started + // fetch the AS happens to serve later saw newer state and is still + // discarded. Without a server-side version there is no better signal, + // and the cost is bounded — the older document stands until the next + // interval or forced read, rather than a withdrawn URI standing in + // place of a current one. + if (sequence > this.committedSequence) { + this.committedSequence = sequence; + this.cache = document; + this.cacheTimeSeconds = Math.floor(Date.now() / 1000); + this.serverExpiresAt = result.expiresAt; + return document; + } + // Superseded: hand back what the cache holds rather than this stale + // answer, so the caller and the cache cannot disagree either. + return this.cache ?? document; })(); + this.fetchInFlight = pending; + this.fetchInFlightForced = forceUpstream; + try { - return await this.fetchInFlight; + return await pending; } finally { - this.fetchInFlight = undefined; + // Only the owner clears. A concurrent fetch that started later owns the + // fields by then, and tearing its dedupe state down would let the next + // caller start a duplicate upstream fetch instead of joining it. + if (this.fetchInFlight === pending) { + this.fetchInFlight = undefined; + this.fetchInFlightForced = false; + } } } @@ -113,11 +289,22 @@ export class DocumentCache> { } try { - return await this.fetchAndUpdate(); + return await this.fetchAndUpdate(forceRefresh); } catch (error) { if (this.cache !== undefined) { return this.cache; } + // Already one of the SDK's typed fetch errors: keep it. The JWKS fetcher + // resolves its URI through the metadata cache, so a metadata failure can + // surface here — relabelling it `JWKSFetchError` would point the operator + // at the wrong document. A validation rejection is likewise not a fetch + // failure and reads better without the prefix. + if ( + error instanceof MetadataFetchError || + error instanceof MissingMetadataEndpoint + ) { + throw error; + } const message = error instanceof Error ? error.message : String(error); throw this.errorFactory(`Failed to fetch document: ${message}`); } @@ -140,10 +327,16 @@ export interface JwksDocument extends Record { } export class JWKSCache extends DocumentCache { - public constructor(fetcher: Fetcher, refreshSeconds: number) { + public constructor( + fetcher: Fetcher, + refreshSeconds: number, + failureBackoffSeconds?: number | undefined, + ) { super(fetcher, { refreshSeconds, + failureBackoffSeconds, errorFactory: (message) => new JWKSFetchError(message), + documentType: "JWKS", }); } @@ -205,6 +398,14 @@ export class JWKSCache extends DocumentCache { export class MetadataCache extends DocumentCache> { private readonly expectedIssuer: string; private readonly allowHttp: boolean; + /** + * Ceiling on how far apart forced metadata reads can be spaced. The floor + * itself is `min(refreshSeconds, this)`, so a deployment that asks for + * fresher metadata than a minute still gets it. + */ + private static readonly FORCED_READ_FLOOR_CEILING_SECONDS = 60; + private readonly forcedReadFloorSeconds: number; + private lastForcedReadSeconds: number | undefined; private static readonly VALIDATED_ENDPOINT_FIELDS = [ "jwks_uri", "token_endpoint", @@ -216,31 +417,16 @@ export class MetadataCache extends DocumentCache> { fetcher: Fetcher>, options: { refreshSeconds: number; - onChange?: ( - oldDoc: Record, - newDoc: Record, - ) => Promise; + failureBackoffSeconds?: number | undefined; expectedIssuer?: string; allowHttp?: boolean; }, ) { - const config: { - refreshSeconds: number; - onChange?: ( - oldDoc: Record, - newDoc: Record, - ) => Promise; - errorFactory: (message: string) => Error; - } = { + super(fetcher, { refreshSeconds: options.refreshSeconds, + failureBackoffSeconds: options.failureBackoffSeconds, errorFactory: (message) => new MetadataFetchError(message), - }; - if (options.onChange) { - config.onChange = options.onChange; - } - - super(fetcher, { - ...config, + documentType: "metadata", }); // RFC 8414 §3.3: the issuer is compared for identity. Keep the expected @@ -248,6 +434,55 @@ export class MetadataCache extends DocumentCache> { // a trailing-slash difference must surface as a mismatch, not be reconciled. this.expectedIssuer = options.expectedIssuer ?? ""; this.allowHttp = options.allowHttp ?? false; + this.forcedReadFloorSeconds = Math.min( + options.refreshSeconds, + MetadataCache.FORCED_READ_FLOOR_CEILING_SECONDS, + ); + } + + /** + * A forced read bypasses `refreshSeconds`, so on its own it is no rate limit. + * The caller that reaches it is a JWKS `kid` miss, and nothing upstream of + * that has authenticated anything — `verify()` has only decoded the header — + * so a well-formed header carrying an attacker-chosen `kid` would otherwise + * cost the AS one discovery fetch per request, unthrottled, on top of the + * pre-existing JWKS fetch. + * + * The floor caps that at one forced read per interval while still following a + * real rotation promptly: the first miss after the floor elapses re-reads + * immediately. Refusing only downgrades the read to an ordinary one, which + * still serves a valid cached document or refetches an expired one. + * + * A floor of zero (`refreshSeconds: 0`, meaning "re-read every time") opts + * out, which is what the rotation conformance cases configure. + */ + private admitForcedRead(): boolean { + if (this.forcedReadFloorSeconds <= 0) { + return true; + } + const now = Math.floor(Date.now() / 1000); + if ( + this.lastForcedReadSeconds !== undefined && + now - this.lastForcedReadSeconds < this.forcedReadFloorSeconds + ) { + return false; + } + this.lastForcedReadSeconds = now; + return true; + } + + public override async get( + forceRefresh = false, + ): Promise> { + // The failure floor is consulted before the budget is spent: a read the + // floor refuses sends nothing upstream, and the budget exists solely to + // cap what an attacker-chosen `kid` can make the SDK ask of the AS. + // Burning it on a refusal would downgrade the first miss after the floor + // elapses — exactly the read that follows a rotation — to an ordinary + // one, serving the withdrawn document. + return super.get( + forceRefresh && !this.isWithinFailureFloor() && this.admitForcedRead(), + ); } private validateEndpointUrl(field: string, value: string): void { @@ -271,7 +506,7 @@ export class MetadataCache extends DocumentCache> { } } - private validateMetadata( + protected override validateDocument( metadata: Record, ): Record { // RFC 8414 §3.3: compare the raw issuer identifier for exact equality. @@ -298,13 +533,6 @@ export class MetadataCache extends DocumentCache> { return metadata; } - public override async get( - forceRefresh = false, - ): Promise> { - const metadata = await super.get(forceRefresh); - return this.validateMetadata(metadata); - } - public async getJwksUri(forceRefresh = false): Promise { const metadata = await this.get(forceRefresh); const jwksUri = metadata.jwks_uri; diff --git a/packages/sdk/src/core/fetching/metadataUrl.ts b/packages/sdk/src/core/fetching/metadataUrl.ts index e4352d9..4f8ee73 100644 --- a/packages/sdk/src/core/fetching/metadataUrl.ts +++ b/packages/sdk/src/core/fetching/metadataUrl.ts @@ -1,24 +1,20 @@ +import { validateIssuerIdentifier } from "../prm.js"; + /** Build RFC 8414 metadata URL from issuer. */ export function buildMetadataUrl(issuer: string): string { + // One gate, shared with the PRM builder. This function used to carry its own + // inline query/fragment check, which left `buildPrm` — the other place an + // issuer is consumed — with no check at all, and let the two drift. The + // shared gate is a superset of what was here: it still rejects a query or a + // fragment (RFC 8414 §2), and it additionally rejects an issuer that is not + // an absolute URL with a scheme and a host, or that carries userinfo. Both + // additions are reachable from here — the derivation below sets `pathname` + // on the parsed URL and returns it, so a host-less issuer produced a string + // no client could fetch, and a `user:pw@` issuer was carried verbatim into + // the fetch target. + validateIssuerIdentifier(issuer); const parsed = new URL(issuer); - // RFC 8414 §2: the issuer identifier MUST NOT contain a query or fragment - // component. Gate on the raw issuer string and reject BOTH symmetrically — - // a bare `?` (empty query) or `#` (empty fragment) is still a query/fragment - // delimiter and must not survive into the derived `.well-known` URL. We do - // not silently discard either component: that reconciliation would let a - // malformed identifier resolve to a document it does not actually name. - if (issuer.includes("?") || issuer.includes("#")) { - // Log only the scheme, host, and path so a credential-shaped query - // (e.g. `?token=...`) never lands in startup logs. We rebuild from - // `protocol`/`host`/`pathname` rather than `origin` because `origin` - // is the literal string `"null"` for a non-special scheme (e.g. - // `foo://bar/t?x=1`), which would render a degenerate `'null/t'`. - throw new TypeError( - `issuer identifier must not contain a query or fragment component (RFC 8414 §2): '${parsed.protocol}//${parsed.host}${parsed.pathname}'`, - ); - } - // Derivation (RFC 8414 §3.1): the terminating slash of the issuer path is // dropped when building the `.well-known` URL. This is a location-building // operation and is distinct from issuer identity comparison. diff --git a/packages/sdk/src/core/prm.ts b/packages/sdk/src/core/prm.ts index 1ce4f4d..1bb74d5 100644 --- a/packages/sdk/src/core/prm.ts +++ b/packages/sdk/src/core/prm.ts @@ -32,10 +32,25 @@ export interface BuildPrmOptions { * - which signing algorithms are accepted * - which scopes the resource understands * + * Both URL-shaped members are gated before they are copied into the document: + * `resource` by `validateResourceIndicator` — the same check + * `AuthplaneResource`'s constructor applies — and `issuer` by + * `validateIssuerIdentifier`, which is the same gate `AuthplaneClient.create()` + * runs when it derives the AS metadata URL. + * This builder is exported and its documented use is to serve the document + * directly, so it is a boundary in its own right: RFC 9728 §3.3 has a client + * discard a document whose `resource` member is not the identifier it used to + * reach the resource server, and every derivation in this module builds the + * document URL from scheme + host + path, so an identifier carrying a + * component that derivation drops would be served as a `resource` value no + * client can reconcile with the URL it fetched. The resource server then looks + * unreachable rather than misconfigured. Rejecting here turns that silent + * interop failure into an error the operator can act on. + * * Usage: * * ```ts - * import { buildPrm } from "@authplane/core"; + * import { buildPrm } from "@authplane/sdk/core"; * * const prm = buildPrm( * "https://auth.example.com", @@ -46,6 +61,9 @@ export interface BuildPrmOptions { * * // return as JSON from your /.well-known/oauth-protected-resource endpoint * ``` + * + * @throws TypeError when `issuer` is not a valid issuer identifier, or + * `resource` is not a valid resource identifier. */ export function buildPrm( issuer: string, @@ -53,6 +71,8 @@ export function buildPrm( scopes: readonly string[], options: BuildPrmOptions = {}, ): ProtectedResourceMetadata { + validateIssuerIdentifier(issuer); + validateResourceIndicator(resource); const doc: ProtectedResourceMetadata = { resource, authorization_servers: [issuer], @@ -70,34 +90,713 @@ export function buildPrm( return doc; } -function parseResourceUrl(resource: string): URL { +/** + * RFC 3986 §3.1 scheme grammar followed by the authority's `//`, anchored. + * + * Shared by the gate and the redactor deliberately: the gate rejects on the raw + * string, so the message has to describe the raw string too, and both need the + * same notion of "carries an authority". + */ +const SCHEME_AND_AUTHORITY = /^[A-Za-z][A-Za-z0-9+.-]*:\/\//u; + +/** + * Render an identifier for an error message without echoing anything + * credential-shaped. Shared by the resource and issuer gates: both reject on + * the raw string, so both can be handed one carrying a credential, and the + * shapes worth masking are the same either way. + * + * `URL.host` drops any `userinfo@` embedded in the + * authority and `URL.pathname` excludes both the query and the fragment, so + * neither a `?token=…` nor the rejected fragment itself reaches a startup log. + * + * Parses defensively: every caller is already on an error path handling a + * malformed identifier, so letting `new URL` throw here would replace the RFC + * citation the caller wrote with the platform's parse failure. The fallback + * truncates at the first `?`/`#` and strips the first `userinfo@` by hand. The + * parsed branch reads `protocol` and `host` rather than `origin`: `origin` is + * the literal string `"null"` for a non-special scheme, and for `blob:` it is + * borrowed from the inner URL while `pathname` is that whole inner URL, so + * `origin + pathname` would print the authority twice and lift the inner + * `userinfo@` back out of the path. An empty `host` is what sends both of those + * to the fallback. The fallback's optional prefix is the authority marker, not + * a scheme, so a scheme-relative `//user:pass@host/path` — unparseable, and + * rejected by the absolute-URL gate — is stripped too. + * + * The parsed branch is taken only when the raw string carries `scheme://`. For + * `https:example.com/mcp` and its three siblings WHATWG invents the authority + * the gate exists to deny, so echoing the parse would show the operator a + * string that is an absolute URL with a scheme and a host — and that the gate + * accepts — with the missing `//`, the one actionable detail, removed on the + * way out. + * + * The result is quoted and escaped through {@link quoteForMessage} on its way + * out, once here rather than at each of the gates' throw sites. Both branches + * can hand back a string carrying the bytes the whitespace axis rejects — the + * fallback returns the raw prefix whenever the anchored `SCHEME_AND_AUTHORITY` + * test fails, which is exactly what a leading control character causes — so + * quoting at the call sites would mean getting it right at every one of them, + * and every one added later. Callers therefore interpolate the result bare; + * they must not wrap it in quotes of their own. + */ +function redactIdentifier(identifier: string): string { + let redacted: string | undefined; try { - return new URL(resource); - } catch (cause) { + const parsed = new URL(identifier); + if (parsed.host !== "" && SCHEME_AND_AUTHORITY.test(identifier)) { + redacted = `${parsed.protocol}//${parsed.host}${parsed.pathname}`; + } + } catch { + // Unparseable — fall through to the raw truncation below. + } + if (redacted === undefined) { + const beforeDelimiter = identifier.split(/[?#]/u)[0] ?? ""; + // Deliberately not anchored on a `scheme://` prefix. The shapes that reach + // this branch carry no such prefix — `//svc:pw@api.example.com/mcp` + // (scheme-relative, throws), `svc:pw@api.example.com` (no `//`), and now + // `https:/svc:pw@example.com/mcp` (single slash, which the parsed branch no + // longer masks) — so an anchored strip matches none of them and the + // credential survives into the message. The second alternative is `:\/` + // rather than `:\/*` on purpose: with `*`, `svc:pw@host` matches the scheme + // alternative and the username survives as a `svc:` prefix. Stopping at the + // first `@` inside the authority rather than the last one in the string + // keeps a legitimate `@` in a path out of it. + redacted = beforeDelimiter.replace( + /^([^/?#]*\/\/|[A-Za-z][A-Za-z0-9+.-]*:\/)?[^/?#]*@/u, + "$1", + ); + } + return quoteForMessage(redacted); +} + +/** + * Every codepoint {@link isWhitespaceOrControl} rejects, rendered as an escape + * inside a quoted string. + * + * `JSON.stringify` alone does not cover the class: it escapes the C0 controls + * but emits DEL, the C1 controls and the non-ASCII spaces (U+00A0, U+2028, + * U+3000, U+FEFF and the rest) raw, so the two ranges beyond C0 would reach a + * startup log as the invisible bytes they are. A raw space is deliberately left + * as itself: the surrounding quotes already make it legible. + */ +function quoteForMessage(value: string): string { + return JSON.stringify(value).replace(/[\u007f-\u009f]|\s/gu, (char) => + char === " " + ? char + : `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`, + ); +} + +/** + * True for a character that is whitespace or a control, treated as one class. + * + * Three ranges, deliberately: the C0 controls and space (U+0000–U+0020), which + * are the ones the WHATWG parser trims and strips; DEL and the C1 controls + * (U+007F–U+009F), which it does not strip but silently percent-encodes; and + * everything the Unicode `\s` class adds on top (U+00A0, U+1680, U+2000–U+200A, + * U+2028, U+2029, U+202F, U+205F, U+3000, U+FEFF), which is likewise + * percent-encoded. None of them is a URI character: RFC 3986 §2 builds every + * component out of `unreserved`, `reserved` and `pct-encoded`, all of which are + * printable ASCII, so no identifier that carries one is a URI in the first + * place. + * + * Spelled as codepoint comparisons rather than a character class so the control + * ranges are readable, since a regex literal would have to carry the escapes + * themselves. `charCodeAt` rather than `codePointAt` so there is no `undefined` + * to defend against: every codepoint named above is in the BMP, and the empty + * string this is never called with yields `NaN`, which fails both comparisons. + */ +function isWhitespaceOrControl(char: string): boolean { + const code = char.charCodeAt(0); + return code <= 0x20 || (code >= 0x7f && code <= 0x9f) || /^\s$/u.test(char); +} + +/** + * Scan the raw identifier for the first whitespace or control character. + * Returns a phrase naming the offending codepoint and its offset within the + * identifier, or `undefined` when there is none. + * + * Same shape and the same reasoning as {@link findQueryGrammarOffence}: the + * offending codepoint and where it sits is what turns an otherwise unactionable + * startup failure into a one-line fix, while nothing else about the value + * leaks. It matters more here than there, because the characters this rejects + * are by definition the ones nothing renders — a tab, a CR or a stray NUL is + * invisible in a message that merely quotes the string it came from. So is the + * redacted echo the caller appends whenever the offending character leads the + * identifier, which is why {@link redactIdentifier} quotes what it returns. + */ +function findWhitespaceOrControlOffence( + identifier: string, +): string | undefined { + for (let index = 0; index < identifier.length; index += 1) { + const char = identifier.charAt(index); + if (isWhitespaceOrControl(char)) { + return `invalid character ${quoteForMessage(char)} at offset ${String(index)}`; + } + } + return undefined; +} + +/** + * RFC 3986 §3.4 `query` production, one character at a time: + * `query = *( pchar / "/" / "?" )`, where + * `pchar = unreserved / pct-encoded / sub-delims / ":" / "@"`. Spelled out: + * `A-Za-z0-9 -._~ ! $ & ' ( ) * + , ; = : @ / ?` plus well-formed `%XX` + * escapes (checked separately below). Everything else is out of grammar — + * notably `[`, `]`, `|`, `^`, `\`, `` ` ``, `{`, `}`, and a raw space, `"`, + * `<` or `>`. + */ +const RFC3986_QUERY_CHAR = /^[A-Za-z0-9\-._~!$&'()*+,;=:@/?]$/u; + +const WELL_FORMED_PCT_ESCAPE = /^%[0-9A-Fa-f]{2}$/u; + +/** + * Scan `query` (leading `?` already removed) for the first octet outside the + * RFC 3986 §3.4 `query` grammar. Returns a phrase naming the offending + * codepoint (or malformed escape) and its offset within the query, or + * `undefined` when the query is in-grammar. Reporting the single offending + * byte and where it sits turns an otherwise unactionable startup failure into + * a one-line fix, while the query's value stays out of the log. + */ +function findQueryGrammarOffence(query: string): string | undefined { + for (let index = 0; index < query.length; index += 1) { + const char = query.charAt(index); + if (char === "%") { + const pctEscape = query.slice(index, index + 3); + if (!WELL_FORMED_PCT_ESCAPE.test(pctEscape)) { + return `malformed percent-escape ${JSON.stringify(pctEscape)} at offset ${String(index)}`; + } + index += 2; + } else if (!RFC3986_QUERY_CHAR.test(char)) { + return `invalid character ${JSON.stringify(char)} at offset ${String(index)}`; + } + } + return undefined; +} + +/** + * True when the identifier's authority carries a userinfo component. Shared by + * the resource and issuer gates — the authority grammar it reads is the same + * for both. + * + * Read off the raw string like the sibling gates, not off a parsed `URL`: the + * authority is everything between the `//` that opens it and the first `/`, + * `?` or `#` that closes it, and an unescaped `@` delimits userinfo there and + * appears nowhere else in an authority (RFC 3986 §3.2). So a host with a port + * (`https://api.example.com:8443/mcp`) and an IPv6 literal + * (`https://[::1]:8443/mcp`) both pass, an `@` in the path + * (`https://api.example.com/@handle`) is not mistaken for one, and an + * identifier with no authority at all (`urn:example:api`) is not this gate's + * business. An empty userinfo (`https://@api.example.com/mcp`) is still a + * userinfo component and is reported. + */ +function hasUserinfoComponent(identifier: string): boolean { + const schemeAndAuthority = SCHEME_AND_AUTHORITY.exec(identifier); + if (schemeAndAuthority === null) { + return false; + } + const authorityStart = schemeAndAuthority[0].length; + let authorityEnd = identifier.length; + for (let index = authorityStart; index < identifier.length; index += 1) { + const char = identifier.charAt(index); + if (char === "/" || char === "?" || char === "#") { + authorityEnd = index; + break; + } + } + return identifier.lastIndexOf("@", authorityEnd - 1) >= authorityStart; +} + +/** + * Reject a resource indicator that carries a URI fragment, is not an absolute + * URL with a scheme and a host, or carries a query that is not a valid + * RFC 3986 §3.4 `query` production. + * + * Fragment — RFC 8707 §2: "The URI MUST NOT include a fragment component." + * RFC 9728 §1.2 says the same of the resource identifier — "a URL that uses + * the https scheme and has no fragment component". + * + * The fragment check is on the raw string, not on a parsed `URL`: `URL` splits + * the fragment off into `hash`, and every derivation in this module is built + * from scheme + host + `pathname` (+ `search`), so a fragment is silently dropped + * rather than rejected. What that produces is a PRM document whose `resource` + * member carries a fragment while the document is served at the URL derived + * without one — and RFC 9728 §3.3 requires a client that sees that mismatch to + * discard the document. Rejecting at construction turns a silent interop + * failure into a startup error the operator can act on. It also runs first, so + * an identifier that is wrong in both ways deterministically reports the + * fragment. + * + * Absolute URL — the scheme requirement is RFC 8707 §2: the resource parameter + * "MUST be an absolute URI, as specified by Section 4.3 of [RFC3986]", whose + * grammar is `absolute-URI = scheme ":" hier-part [ "?" query ]`. The host + * requirement is RFC 9728 §3: the well-known suffix is inserted after the host + * component — no host, no derivable metadata URL. Both halves are checked + * explicitly on the raw string rather than inferred from parseability. The + * scheme, because a scheme-relative `//api.example.com/mcp` carries an + * authority, and a guard phrased as "opaque or authority-less" would wrongly + * admit it. The authority's `//`, for the mirror-image reason: WHATWG invents + * an authority for a special scheme, so `new URL("https:example.com/mcp").host` + * is `"example.com"` for an identifier RFC 3986 gives no authority at all + * (`hier-part = path-rootless`). Reading the host off the parse would admit an + * identifier this gate's own message says it rejects — and one whose served + * `resource` member no conformant RFC 3986 client can turn back into the + * advertised document URL, since there is no authority to insert the + * well-known suffix after, so RFC 9728 §3.3 has it discard the document. That + * is the same silent-interop failure the fragment rationale above describes, + * reached from the other side. This gate used + * to be fragment-only, on the argument that an opaque audience string worked + * for `AuthplaneResource.verify()`; that position no longer holds — an opaque + * value like `urn:example:api` (scheme but no host) previously derived the + * garbage document URL `null/.well-known/oauth-protected-resourceexample:api`, + * and RFC 9728 §3 has no way to derive a metadata URL from an identifier with + * no host. + * + * Query — gated on the RAW query: the substring of the configured string + * after the first `?` (a fragment is rejected above, so the query runs to the + * end of the string). The resource identifier is an identity, compared + * byte-for-byte, so the gate judges the bytes the operator configured — not + * the WHATWG-normalised `URL.search`, which percent-encodes a space, `"`, `<` + * or `>` on the way through `new URL` and would have the SDK silently decide + * the operator meant a different identifier than the one they typed. Rejected: + * any query octet outside `pchar / "/" / "?"` — notably `[`, `]`, `|`, `^`, + * `\`, `` ` ``, `{`, `}`, and a raw space, `"`, `<` or `>` — and malformed + * `%` escapes. The grammar admits every sub-delim, so a legal query passes + * byte-for-byte, and the derivations below splice the same raw query, so the + * configured, served and advertised identifiers stay the same bytes. It runs + * after the absolute-URL gate, so the ordering is deterministic: fragment, + * then absoluteness, then query. + * + * The grammar matters here because the query is carried, as configured, into + * the quoted-string `resource_metadata` parameter of the `WWW-Authenticate` + * challenge, where a `"` terminates the quoted-string and a `\` starts a + * quoted-pair (RFC 9110 §11.2), and any other out-of-grammar byte makes the + * advertised URL unparseable for a conformant client — the same + * silent-wrong-derivation class the fragment gate exists to eliminate, so the + * query is gated at the same boundary. That quoted-string argument is about + * the query, which this module splices raw; the path half of the URL is not + * gated here, because WHATWG normalisation percent-encodes the + * quoted-string-terminating bytes out of `pathname` before any derivation + * reads it. + * + * Userinfo — RFC 9110 §4.2.4 deprecates a userinfo component in an `http` or + * `https` URI and directs a recipient to reject a URI carrying one, and RFC + * 3986 §3.2.1 notes that it routinely holds a credential in clear text. The + * stakes here are higher than for a request target that merely gets logged: + * this identifier is stored verbatim, published as the `resource` member of + * the Protected Resource Metadata document RFC 9728 §3 serves to + * unauthenticated callers, and spliced into the `resource_metadata` parameter + * of the 401 `WWW-Authenticate` challenge — so a credential in the userinfo + * would be handed to every client that asks. Rejecting at construction is what + * makes that guarantee: eliding the userinfo at each sink only covers the sinks + * that remember to, and every sink added later has to remember again. It also + * closes the second half of the mismatch this module exists to prevent — + * every derivation builds the document URL from scheme + host + path, which + * drops userinfo, so an accepted identifier carrying it would be served as a + * `resource` value naming a different string than the URL it was fetched from, + * the RFC 9728 §3.3 mismatch a conformant client answers by discarding the + * document. Checked last of the four, so an identifier that is also + * scheme-relative reports the missing scheme first. + * + * The `http` scheme remains accepted — a deliberate profile relaxation for + * local development (`http://localhost:8080/mcp`); this gate imposes no + * https-only narrowing. + * + * Throws `TypeError`, matching the sibling issuer guard in + * `core/fetching/metadataUrl.ts` and the platform's own convention for an + * argument of the wrong shape (`new URL("nope")` throws `TypeError` too). It is + * deliberately *not* an `AuthplaneError`: that hierarchy is the + * token-verification taxonomy consumed by `httpStatus()` and + * `wwwAuthenticate()`, and a configuration error found at construction must not + * be representable as a challenge on a request path. + * + * @throws TypeError when `resource` carries a fragment component, is not an + * absolute URL with a scheme and a host, carries a query component that is not + * a valid RFC 3986 §3.4 `query`, or carries a userinfo component in its + * authority. + */ +export function validateResourceIndicator(resource: string): void { + if (resource.includes("#")) { + throw new TypeError( + `resource indicator must not contain a fragment component (RFC 8707 §2): ${redactIdentifier(resource)}`, + ); + } + // RFC 3986 §3.1 scheme grammar followed by the authority's `//`, anchored: + // rejects a relative `/mcp` and a scheme-relative `//api.example.com/mcp` + // alike. The `//` is load-bearing on the RAW string and must not be + // "simplified" back onto `parsed.host` — WHATWG invents an authority for a + // special scheme (`http`, `https`, `ws`, `wss`, `ftp`, `file`), so + // `https:example.com/mcp`, `https:/example.com/mcp` and + // `https:\\api.example.com\mcp` all parse to a non-empty `host` they have + // no authority to give (see the docstring above). + const hasSchemeAndAuthority = SCHEME_AND_AUTHORITY.test(resource); + let parsed: URL | undefined; + if (hasSchemeAndAuthority) { + try { + parsed = new URL(resource); + } catch { + // Carries a scheme and an authority marker but does not parse — + // rejected below. + } + } + if (parsed === undefined || parsed.host === "") { throw new TypeError( - `resource is not a valid URL (got ${JSON.stringify(resource)})`, - { cause }, + `resource identifier must be an absolute URL with a scheme and a host (RFC 8707 §2): ${redactIdentifier(resource)}`, + ); + } + const queryStart = resource.indexOf("?"); + if (queryStart !== -1) { + const offence = findQueryGrammarOffence(resource.slice(queryStart + 1)); + if (offence !== undefined) { + throw new TypeError( + `resource indicator query must be a valid RFC 3986 §3.4 query (pchar / "/" / "?" and well-formed %XX escapes) — ${offence}: ${redactIdentifier(resource)}`, + ); + } + } + // Last of the four, so an identifier that is also scheme-relative or + // fragment-bearing is reported for that instead — the defect an operator + // fixes first. + if (hasUserinfoComponent(resource)) { + throw new TypeError( + `resource identifier must not include a userinfo component in its authority (RFC 9110 §4.2.4): ${redactIdentifier(resource)}`, ); } } +/** + * Report the first octet in `url` that cannot survive the quoted-string it is + * advertised in: a double quote closes it, a backslash is a quoted-pair escape + * a conforming client unescapes into a different URL (RFC 9110 §5.6.4), and + * whitespace or a C0 control is not a URI character at all (RFC 3986 §2). + * + * Returns a description for the message, or `undefined` when the string is + * clean. + */ +function findQuotedStringOffence(url: string): string | undefined { + for (const char of url) { + if (char === '"') { + return "a literal double quote"; + } + if (char === "\\") { + return "a literal backslash"; + } + const code = char.codePointAt(0) ?? 0; + // Everything outside printable ASCII, not just the C0 range and DEL. RFC + // 3986 §2 limits a URI to a fixed ASCII repertoire, and this value is + // never re-derived — it is stored and advertised exactly as typed — so + // nothing downstream percent-encodes it the way WHATWG normalisation does + // for the resource identifier's path. A raw `ü` would otherwise be + // advertised as a byte sequence that is not a URI, and Node rejects a + // header value above U+00FF outright, turning the 401 into a 500. An IDN + // host has to be given in punycode, which is what RFC 3986 requires. + if (code <= 0x20 || code >= 0x7f) { + return `the non-URI octet U+${code.toString(16).toUpperCase().padStart(4, "0")}`; + } + // The ASCII specials RFC 3986 excludes from every component. The query is + // already held to its own grammar four lines down, so without these the + // same octet was refused in `?q=a|b` and accepted in `/prm|x`. + if ("<>`{}|^".includes(char)) { + return `the non-URI character ${JSON.stringify(char)}`; + } + } + return undefined; +} + +/** + * Reject a Protected Resource Metadata document URL that carries a fragment, + * is not an absolute URL with a scheme and a host, carries a query that is not + * a valid RFC 3986 §3.4 `query`, or carries a userinfo component in its + * authority. + * + * This gates the *override* — the URL an operator configures when the metadata + * document is not served by this resource server (see + * `AuthplaneResourceOptions.resourceMetadataUrl`). The derived URL needs no + * gate: it is built here from an identifier the resource gate already vouched + * for. A configured one is a third URL-shaped input with the same sinks as the + * other two — it is spliced into the quoted-string `resource_metadata` + * parameter of the `WWW-Authenticate` challenge (RFC 9110 §11.2) and published + * to unauthenticated clients — so it is held to the same requirements, for the + * reasons argued on {@link validateResourceIndicator} and {@link + * validateIssuerIdentifier}. + * + * A fragment is rejected because RFC 9728 §3.3 has the client fetch this URL + * and compare the document it gets back; a fragment is never sent to the + * server, so it could only mislead. A query is allowed and gated by grammar + * rather than rejected outright: the derived URL carries the resource + * identifier's query through (RFC 9728 §3), so an override must be able to + * express the same document. + * + * The scheme is narrowed to `http`/`https`, which the identifier and issuer + * gates do not do: those two values are *compared*, this one is + * *dereferenced* — RFC 9728 §3.2 has the client fetch it with an HTTP GET, so + * any other scheme names a document no client can retrieve. `http` itself + * remains accepted, the same deliberate profile relaxation the issuer and + * resource gates make for local development. + * + * The whole raw string is also scanned for the octets that break the + * quoted-string it is spliced into (`"` and `\`) and for whitespace and C0 + * controls. The identifier gate can skip that scan because WHATWG + * normalisation percent-encodes those bytes out of `pathname` before any + * derivation reads them; this value is never re-derived — it is stored and + * advertised exactly as typed — so the premise does not hold for it. Without + * the scan the challenge sanitiser would silently replace the offending byte + * and advertise a well-formed challenge naming an unfetchable URL. + * + * @throws TypeError when `url` carries a fragment component, is not an + * absolute URL with a scheme and a host, uses a scheme other than `http` or + * `https`, carries a query component that is not a valid RFC 3986 §3.4 + * `query`, holds a `"`, a `\`, whitespace or a C0 control anywhere, or + * carries a userinfo component in its authority. + */ +export function validateResourceMetadataUrl(url: string): void { + if (url.includes("#")) { + throw new TypeError( + `resource metadata URL must not contain a fragment component (RFC 9728 §3.3): '${redactIdentifier(url)}'`, + ); + } + const hasSchemeAndAuthority = SCHEME_AND_AUTHORITY.test(url); + let parsed: URL | undefined; + if (hasSchemeAndAuthority) { + try { + parsed = new URL(url); + } catch { + // Carries a scheme and an authority marker but does not parse — + // rejected below. + } + } + if (parsed === undefined || parsed.host === "") { + throw new TypeError( + `resource metadata URL must be an absolute URL with a scheme and a host (RFC 9728 §3): '${redactIdentifier(url)}'`, + ); + } + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new TypeError( + `resource metadata URL must use the http or https scheme — RFC 9728 §3.2 has the client dereference it with an HTTP GET: '${redactIdentifier(url)}'`, + ); + } + const brokenOctet = findQuotedStringOffence(url); + if (brokenOctet !== undefined) { + throw new TypeError( + `resource metadata URL must not contain ${brokenOctet} — the value is advertised verbatim in the quoted-string \`resource_metadata\` parameter of the WWW-Authenticate challenge (RFC 9110 §5.6.4, §11.2): '${redactIdentifier(url)}'`, + ); + } + const queryStart = url.indexOf("?"); + if (queryStart !== -1) { + const offence = findQueryGrammarOffence(url.slice(queryStart + 1)); + if (offence !== undefined) { + throw new TypeError( + `resource metadata URL query must be a valid RFC 3986 §3.4 query (pchar / "/" / "?" and well-formed %XX escapes) — ${offence}: '${redactIdentifier(url)}'`, + ); + } + } + if (hasUserinfoComponent(url)) { + throw new TypeError( + `resource metadata URL must not include a userinfo component in its authority (RFC 9110 §4.2.4): '${redactIdentifier(url)}'`, + ); + } +} + +/** + * Reject an issuer identifier that carries a query or fragment component, + * contains whitespace or a control character, is not an absolute URL with a + * scheme and a host, or carries a userinfo component in its authority. + * + * The issuer is the other URL-shaped member of the PRM document, and it is + * published under the same conditions as `resource`: {@link buildPrm} copies + * it straight into `authorization_servers`, and that document is served to + * unauthenticated callers. So this gate exists for the same reason its + * resource-side sibling does, and the sharpest case is the one it shares — + * an issuer carrying `user:password@` would otherwise be disclosed verbatim + * to anyone who fetches the document. + * + * Query and fragment — RFC 8414 §2: the issuer identifier is "a URL that uses + * the https scheme and has no query or fragment components". Both are gated on + * the raw string and rejected symmetrically, because a bare `?` or `#` is + * still a delimiter and must not survive into the derived `.well-known` URL. + * Neither is silently discarded: that reconciliation would let a malformed + * identifier resolve to a document it does not actually name, and RFC 8414 + * §3.3 then has the client reject the metadata for an `issuer` mismatch. + * + * Whitespace and control characters — RFC 3986 §2 builds every URI component + * out of `unreserved`, `reserved` and `pct-encoded`, none of which is either, + * so an identifier carrying one is not a URI. The WHATWG parser does not reject + * them, it silently *cleans* them: it trims leading and trailing C0-or-space, + * removes every tab, CR and LF anywhere in the input, and percent-encodes the + * rest — so `"https://auth.example.com\n"` and `"https://auth.exa\tmple.com"` + * both parse with a non-empty host and cleared every check below, which all + * read the parse. What survived was the split this module exists to prevent, + * read from the issuer side: the raw string is published verbatim in + * `authorization_servers` and stored byte-for-byte as the expected `iss` + * (RFC 8414 §3.3 compares it for identity), while `buildMetadataUrl` derives + * the `.well-known` location from the cleaned parse. A trailing newline in an + * environment variable is the realistic trigger, and what it produces is every + * token rejected on an `iss` comparison against a value that looks identical in + * a log. + * + * It runs second, immediately after the query/fragment check and ahead of + * everything that parses, for two reasons. Soundness: the checks below all read + * the parse, and none of them can be trusted about a string the parser is going + * to alter underneath them — an identifier must be whitespace-free before "what + * does this parse to" is a question worth asking. Attribution: a leading space + * or control was already rejected, but by accident and under the wrong name — + * it fails the anchored `SCHEME_AND_AUTHORITY` test and was reported as not + * being an absolute URL, sending an operator to re-check a scheme and a host + * that were both there. Placing the check here makes every position of the + * offending byte report the same defect with the same offset. + * + * Absolute URL — the same two requirements the resource gate applies, read off + * the clauses that govern the issuer. The scheme is RFC 8414 §2 (the issuer is + * a URL). The host is RFC 8414 §3.1, which derives the metadata location by + * inserting `/.well-known/oauth-authorization-server` between the host and the + * issuer's path: with no host there is nothing for that insertion to anchor to, + * and the derivation yields a string no client can fetch. The `//` is checked + * on the raw string for the reason spelled out on {@link + * validateResourceIndicator} — WHATWG invents an authority for a special + * scheme, so `https:auth.example.com` parses with a host it was never given. + * + * An invalid port needs no check of its own here: WHATWG + * `new URL` rejects `https://auth.example.com:80O` (letter O) and every other + * unparseable port at construction, so `parsed` stays `undefined` and the + * absolute-URL branch below reports it. Pinned by a test rather than duplicated + * as a redundant check. + * + * Userinfo — RFC 9110 §4.2.4: "a sender MUST NOT generate the userinfo + * subcomponent" in an http(s) URI. Checked last, so an issuer that is also + * scheme-relative or query-bearing reports that first — the defect an operator + * fixes first. + * + * The `http` scheme remains accepted, the same deliberate profile relaxation + * the resource gate makes for local development; this gate imposes no + * https-only narrowing even though RFC 8414 §2 would support one. + * + * Throws `TypeError` for the reasons given on {@link validateResourceIndicator} + * — a configuration error found at construction must not be representable as a + * challenge on a request path. + * + * @throws TypeError when `issuer` carries a query or fragment component, + * contains whitespace or a control character, is not an absolute URL with a + * scheme and a host, or carries a userinfo component in its authority. + */ +export function validateIssuerIdentifier(issuer: string): void { + if (issuer.includes("?") || issuer.includes("#")) { + throw new TypeError( + `issuer identifier must not contain a query or fragment component (RFC 8414 §2): ${redactIdentifier(issuer)}`, + ); + } + // Second of the four, ahead of everything that parses — see the docstring + // for why the order is load-bearing rather than incidental. The echo is + // redacted like every other and quoted by `redactIdentifier` itself, which + // matters most here: the offending byte is by definition one nothing + // renders, and for a leading one the redaction falls back to the raw prefix. + const whitespaceOffence = findWhitespaceOrControlOffence(issuer); + if (whitespaceOffence !== undefined) { + throw new TypeError( + `issuer identifier must not contain whitespace or control characters (RFC 3986 §2, RFC 8414 §3.3) — ${whitespaceOffence}: ${redactIdentifier(issuer)}`, + ); + } + const hasSchemeAndAuthority = SCHEME_AND_AUTHORITY.test(issuer); + let parsed: URL | undefined; + if (hasSchemeAndAuthority) { + try { + parsed = new URL(issuer); + } catch { + // Carries a scheme and an authority marker but does not parse — + // rejected below. + } + } + if (parsed === undefined || parsed.host === "") { + throw new TypeError( + `issuer identifier must be an absolute URL with a scheme and a host (RFC 8414 §2, §3.1): ${redactIdentifier(issuer)}`, + ); + } + if (hasUserinfoComponent(issuer)) { + throw new TypeError( + `issuer identifier must not include a userinfo component in its authority (RFC 9110 §4.2.4): ${redactIdentifier(issuer)}`, + ); + } +} + +function parseResourceUrl(resource: string): URL { + // Defensive backstop, not the authoritative gate — `AuthplaneResource`'s + // constructor runs the same check, and it is what stops a misconfigured + // deployment from starting. This call is still load-bearing because both + // public callers below are reachable without ever building a resource: the + // NestJS module calls `oauthProtectedResourceMetadataPath()` at module + // registration, and `prmDocumentUrl()` feeds the `resource_metadata` + // parameter of an RFC 9728 challenge — i.e. a 401 response path, the worst + // place to first discover a configuration error. + validateResourceIndicator(resource); + // `validateResourceIndicator` only returns for an identifier that already + // parsed as an absolute URL, so this construction cannot throw. + return new URL(resource); +} + function resourceMetadataSuffix(parsed: URL): string { return parsed.pathname.replace(/\/+$/u, ""); } +/** + * The query component of `resource` exactly as configured, `?` included, or + * the empty string when there is none. Read from the raw string rather than + * WHATWG `URL.search` because the derived document URL must carry the + * operator's bytes: `URL.search` percent-encodes `'` to `%27` on a special + * scheme even though `'` is a legal sub-delim, which would advertise an + * identifier the operator never configured. Callers run after + * {@link validateResourceIndicator}, so the substring is already in-grammar + * and fragment-free. A bare trailing `?` is treated as no query. + */ +function rawResourceQuery(resource: string): string { + const queryStart = resource.indexOf("?"); + if (queryStart === -1 || queryStart === resource.length - 1) { + return ""; + } + return resource.slice(queryStart); +} + /** * RFC 9728 §3.1 — absolute URL of the Protected Resource Metadata document for `resource`. * - * Path template: `/.well-known/oauth-protected-resource{resource-path}`. - * Trailing slashes on the resource path are dropped so - * `https://api.example.com/mcp/` and `https://api.example.com/mcp` yield the - * same document URL. + * RFC 9728 §3 forms the URL by inserting the well-known string "between the + * host component and the path and/or query components, if any" — so a query + * on the resource identifier is carried into the document URL, after the + * inserted path suffix: + * + * - `https://api.example.com/mcp?tenant=a` → + * `https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a` + * - `https://api.example.com?x=1` → + * `https://api.example.com/.well-known/oauth-protected-resource?x=1` + * + * A query is legal in a resource identifier: RFC 8707 §2 states the SHOULD NOT + * and its exception in the same sentence — "...it is recognized that there are + * cases that make a query component a useful and necessary part of the + * resource parameter" — and RFC 9728 §1.2 carries that forward. + * + * Trailing slashes on the resource path are dropped (RFC 9728 §3.1 removes the + * terminating "/" following the host when a path or query component is + * present), so `https://api.example.com/mcp/` and `https://api.example.com/mcp` + * yield the same document URL. That removal is also what makes + * `https://api.example.com/?x=1` derive the same URL as + * `https://api.example.com?x=1` — the bare-host form has no terminating slash + * to remove in the first place, so for it the suffix simply lands directly + * after the host with the query following. + * + * The query is carried byte-for-byte as configured — an accepted query is + * in-grammar already (see {@link validateResourceIndicator}), so it is never + * re-encoded on the way into the document URL. A bare `?` with nothing after + * it is treated as no query: `https://api.example.com/mcp?` derives the + * query-less document URL. + * + * @throws TypeError when `resource` is not a valid absolute URL, or carries a + * fragment component (RFC 8707 §2 — see {@link validateResourceIndicator}). */ export function oauthProtectedResourceMetadataDocumentUrl( resource: string, ): string { const parsed = parseResourceUrl(resource); - return `${parsed.origin}/.well-known/oauth-protected-resource${resourceMetadataSuffix(parsed)}`; + // `protocol` + `host`, not `origin`: WHATWG `URL.origin` is the literal + // string "null" for every scheme outside its special set, and the gate + // deliberately admits any scheme with a host (`mcp://api.example.com/mcp` + // must derive `mcp://api.example.com/.well-known/…`, not `null/…`). + return `${parsed.protocol}//${parsed.host}/.well-known/oauth-protected-resource${resourceMetadataSuffix(parsed)}${rawResourceQuery(resource)}`; } /** @@ -106,7 +805,14 @@ export function oauthProtectedResourceMetadataDocumentUrl( * that requires a literal path at module-registration time (e.g. the NestJS * dynamic module) before any HTTP client has been instantiated. * - * @throws TypeError when `resource` is not a valid absolute URL. + * Deliberately excludes the resource identifier's query component, unlike + * {@link oauthProtectedResourceMetadataDocumentUrl}: this value is a route + * registration, and routing is path-keyed — a request for the query-bearing + * document URL reaches this same route with the query ignored. Serving + * distinct documents per query value is not supported. + * + * @throws TypeError when `resource` is not a valid absolute URL, or carries a + * fragment component (RFC 8707 §2 — see {@link validateResourceIndicator}). */ export function oauthProtectedResourceMetadataPath(resource: string): string { const parsed = parseResourceUrl(resource); diff --git a/packages/sdk/src/core/requestContext.ts b/packages/sdk/src/core/requestContext.ts index ac624f4..1c30ff9 100644 --- a/packages/sdk/src/core/requestContext.ts +++ b/packages/sdk/src/core/requestContext.ts @@ -18,7 +18,11 @@ export interface BuildRequestUrlParams { readonly pathAndQuery: string; /** * The origin (scheme + authority) of the configured resource URL — e.g. - * `"https://api.example.com"`. Use `new URL(resource).origin`. + * `"https://api.example.com"`. Build it as + * `` `${u.protocol}//${u.host}` `` from `new URL(resource)`, not + * `URL.origin`: `origin` is the literal string `"null"` for a non-special + * scheme such as `mcp:`, which the resource-indicator gate accepts, and a + * `"null"`-anchored `htu` fails verification for every DPoP-bound request. */ readonly resourceOrigin: string; } diff --git a/packages/sdk/src/core/resource.ts b/packages/sdk/src/core/resource.ts index c0c3a4b..0190428 100644 --- a/packages/sdk/src/core/resource.ts +++ b/packages/sdk/src/core/resource.ts @@ -42,6 +42,8 @@ import { buildPrm, oauthProtectedResourceMetadataDocumentUrl, type ProtectedResourceMetadata, + validateResourceIndicator, + validateResourceMetadataUrl, } from "./prm.js"; /** @@ -57,6 +59,14 @@ export type RevocationChecker = ( export interface AuthplaneResourceOptions { /** * Resource URI this token must be bound to. + * + * Must be an absolute URL with a scheme and a host (RFC 8707 §2: "MUST be + * an absolute URI"; RFC 9728 §3 inserts the well-known suffix after the + * host component) and must not carry a fragment component (RFC 8707 §2, + * RFC 9728 §1.2). Anything else — a relative path, a scheme-relative + * `//host/path`, an opaque `urn:` value, a fragment — is rejected with a + * `TypeError` at construction rather than silently producing a malformed + * PRM document URL. `http` hosts are still accepted for local development. */ resource: string; @@ -66,6 +76,42 @@ export interface AuthplaneResourceOptions { */ scopes: string[]; + /** + * Absolute URL of the Protected Resource Metadata document to advertise in + * the `resource_metadata` parameter of every `WWW-Authenticate` challenge + * (RFC 9728 §5.1), overriding the URL derived from `resource`. + * + * Leave it unset — the default — when this server hosts its own metadata + * document: the derived `/.well-known/oauth-protected-resource[/path]` URL + * is where the SDK's own PRM handler serves it, and the two stay in step by + * construction. Set it when the document lives elsewhere, the case being an + * authorization server that publishes one per registered resource: the + * resource server then only points at it. That topology is the way out for + * a deployment that cannot serve well-known paths at its own origin. + * + * Only the advertised URL changes. The PRM path the SDK's handler mounts at + * is still derived from `resource`, so pointing this elsewhere does not + * unmount the local document, and `prmResponse()` still builds it. + * + * Whatever serves the document, RFC 9728 §3.3 binds it to this resource: + * the `resource` member inside it must equal the identifier the client used + * to reach this server, byte for byte, or a conformant client discards the + * document. So the Resource URI registered at the authorization server, the + * `resource` configured here and the public URL of this server must be the + * same string. + * + * Held to a **stricter** gate than `resource`: an absolute URL with a + * scheme and a host, no fragment and no userinfo — and, unlike `resource`, + * the scheme is narrowed to `http`/`https`, the query must satisfy the RFC + * 3986 §3.4 grammar, and no octet outside printable ASCII may appear + * anywhere in the value. A `resource` of `mcp://…` is accepted; the same + * scheme here is not. The reason is that RFC 9728 §3.2 has the client + * dereference this one, and it is advertised exactly as typed rather than + * re-derived. Same error type and redaction discipline: a `TypeError` at + * construction, with any userinfo elided. + */ + resourceMetadataUrl?: string; + /** * Allowed JWT `alg` values. Dangerous algorithms (HS*) are rejected. */ @@ -141,6 +187,7 @@ export class AuthplaneResource { private readonly issuer: string; private readonly resource: string; + private readonly resourceMetadataUrlOverride: string | undefined; private readonly allowedAlgorithms: readonly string[]; private readonly clockSkewSeconds: number; private readonly failClosed: boolean; @@ -160,10 +207,31 @@ export class AuthplaneResource { | RevocationChecker | undefined; + private readonly metadataCache: MetadataCache; private readonly getJwksCache: () => JWKSCache; private readonly introspectionChecker: IntrospectionChecker | undefined; + private introspectionOwnershipWarned = false; public constructor(options: InternalResourceOptions) { + // THIS is the authoritative resource-indicator gate — every + // construction path reaches it. The class is exported from + // `@authplane/sdk/core`, so constructing it directly is supported, and + // every adapter (`@authplane/mcp`, `@authplane/fastmcp`, + // `@authplane/hono`, `@authplane/nestjs`) funnels its operator-supplied + // `resource` through `AuthplaneClient.resource()` into here. + // + // A gate living only in that factory would leave the direct-construction + // path to fail later, in `prmDocumentUrl()` — which builds the + // `resource_metadata` parameter of an RFC 9728 challenge, i.e. inside a + // 401 response path. Turning a configuration error into a failure on the + // failure path is the outcome this check exists to prevent. + // + // `AuthplaneClient.resource()` deliberately does *not* repeat the check: + // a JS stack trace already names the caller's frame alongside this + // constructor's, so a second call would add no diagnostic value and + // would be an unpinned duplicate of this one. + validateResourceIndicator(options.resource); + const allowedAlgorithms = options.allowedAlgorithms ?? [ ...ALLOWED_ALGORITHMS, ]; @@ -177,8 +245,18 @@ export class AuthplaneResource { ); } + // Gated beside the resource indicator, and for the same reason: this + // value's only sink is the `resource_metadata` parameter of a + // `WWW-Authenticate` challenge, so a malformed one would surface on the + // 401 path — a configuration error turning into a failure on the + // failure path. + if (options.resourceMetadataUrl !== undefined) { + validateResourceMetadataUrl(options.resourceMetadataUrl); + } + this.issuer = options.issuer; this.resource = options.resource; + this.resourceMetadataUrlOverride = options.resourceMetadataUrl; this.scopes = Object.freeze([...options.scopes]); this.allowedAlgorithms = Object.freeze(allowedAlgorithms); this.clockSkewSeconds = options.clockSkewSeconds ?? CLOCK_SKEW_SECONDS; @@ -208,6 +286,7 @@ export class AuthplaneResource { this.asCredentials = options.asCredentials; this.revocationChecker = options.revocationChecker; + this.metadataCache = options.metadataCache; this.getJwksCache = options.getJwksCache; // Prepare introspection revocation checks eagerly so verify() stays fast. @@ -216,31 +295,27 @@ export class AuthplaneResource { this.isIntrospectionRevocation(this.revocationChecker) || this.isIntrospectionConfig(this.revocationChecker) ) { - if (this.isIntrospectionRevocation(this.revocationChecker)) { - if (!this.asCredentials) { - console.warn( - "[authplane] IntrospectionRevocation used without asCredentials; introspection requests will be unauthenticated.", - ); - } - introspectionChecker = new IntrospectionChecker( - () => options.metadataCache.get(), - { - fetchSettings: options.fetchSettings, - clientId: this.asCredentials?.clientId, - clientSecret: this.asCredentials?.clientSecret, - }, - ); - } else { - const config = this.revocationChecker as IntrospectionConfig; - introspectionChecker = new IntrospectionChecker( - () => options.metadataCache.get(), - { - fetchSettings: options.fetchSettings, - clientId: config.clientId, - clientSecret: config.clientSecret, - }, + const credentials: ASCredentials | IntrospectionConfig | undefined = + this.isIntrospectionRevocation(this.revocationChecker) + ? this.asCredentials + : (this.revocationChecker as IntrospectionConfig); + // The unauthenticated RFC 7662 path stays reachable, but authserver + // >= 0.1.2 answers it with `active: false`, which verify() reads as + // "revoked" — so say so once, at construction, instead of letting every + // token fail with no server-side signal. + if (!credentials?.clientId || !credentials?.clientSecret) { + console.warn( + "[authplane] Introspection revocation configured without AS client credentials (asCredentials / clientId + clientSecret); introspection requests will be unauthenticated. authserver >= 0.1.2 answers unauthenticated introspection with active: false, so every token will be rejected as revoked. Configure a confidential client that is the issuing client or a runtime-client of this resource.", ); } + introspectionChecker = new IntrospectionChecker( + () => options.metadataCache.get(), + { + fetchSettings: options.fetchSettings, + clientId: credentials?.clientId, + clientSecret: credentials?.clientSecret, + }, + ); } this.introspectionChecker = introspectionChecker; @@ -268,8 +343,6 @@ export class AuthplaneResource { token: string, options: { dpopRequest?: DPoPRequestContext | undefined } = {}, ): Promise { - const jwksCache = this.getJwksCache(); - let header: ReturnType; try { header = decodeProtectedHeader(token); @@ -302,6 +375,14 @@ export class AuthplaneResource { ); } + // Metadata is re-read here, on the ordinary verification path, because a + // resource server that only verifies tokens never calls an AS-facing + // operation. Without this the document read at construction would be the + // only one the process ever sees: `metadataRefreshSeconds` would never + // elapse into a refetch and a rotated `jwks_uri` would never be followed. + await this.refreshMetadata(); + const jwksCache = this.getJwksCache(); + let key = await jwksCache.getKeyByKid(kid, false, alg); if (!key) { key = await jwksCache.getKeyByKid(kid, true, alg); @@ -361,6 +442,7 @@ export class AuthplaneResource { isRevoked = false; } if (isRevoked) { + this.warnIntrospectionOwnershipOnce(claims.jti); throw new TokenRevoked(`Token '${claims.jti}' has been revoked`); } } @@ -368,6 +450,50 @@ export class AuthplaneResource { return claims; } + /** + * A token that passed local JWT verification and then came back + * `active: false` from introspection is either revoked or — under + * authserver >= 0.1.2 — introspected by a client the AS does not treat as + * its owner: only the issuing client or a runtime-client of the Resource + * named in `aud` gets a real answer. The two are indistinguishable on the + * wire, so point at the second cause once per resource; a revocation storm + * must not turn into a log storm. + */ + private warnIntrospectionOwnershipOnce(jti: string): void { + if (this.introspectionOwnershipWarned || !this.introspectionChecker) { + return; + } + this.introspectionOwnershipWarned = true; + console.warn( + `[authplane] Introspection returned active: false for token jti=${jti} that passed local JWT verification. If the token was not revoked, the authorization server did not recognise this resource server as the token's owner: authserver >= 0.1.2 only answers the issuing client or a runtime-client of the Resource named in aud. Register it with: authserver admin resource runtime-client add --client-id --slug . This warning is logged once per resource.`, + ); + } + + /** + * Re-read the AS metadata document if the configured refresh interval has + * elapsed. + * + * This is a cache read, not a fetch: `MetadataCache` only reaches the network + * once its interval is up, so the cost per verification is a cache lookup. + * Nothing is rebound when `jwks_uri` changes and there is no second cache to + * swap in: the JWKS fetcher resolves the URI from this document on every + * fetch, so committing a rotated document here is the whole of the handover. + * + * Failures are swallowed: keeping metadata current serves verification, it is + * not a precondition for it. A refetch that fails leaves the last known good + * document in place (`DocumentCache.get`), and a document that fails + * validation must not take verification down with it. + */ + private async refreshMetadata(): Promise { + try { + await this.metadataCache.get(); + } catch (error) { + console.warn( + `[authplane] AS metadata refresh failed during verify; continuing with the last known metadata: ${String(error)}`, + ); + } + } + private resolveRevocationChecker(): RevocationChecker | undefined { if ( this.isIntrospectionRevocation(this.revocationChecker) || @@ -612,11 +738,26 @@ export class AuthplaneResource { }); } - /** RFC 9728 §3.1 — absolute URL of the Protected Resource Metadata document for this resource. */ + /** RFC 9728 §3.1 — absolute URL of the Protected Resource Metadata document derived from this resource. */ public prmDocumentUrl(): string { return oauthProtectedResourceMetadataDocumentUrl(this.resource); } + /** + * The URL to advertise as `resource_metadata` (RFC 9728 §5.1): the + * configured {@link AuthplaneResourceOptions.resourceMetadataUrl} when one + * is set, otherwise the URL derived from `resource`. + * + * This is what every challenge path reads, so the override reaches the 401, + * the 403 and the DPoP challenges through one accessor rather than each + * adapter resolving it again. {@link prmDocumentUrl} stays the derived URL + * on purpose: it is what the SDK's own PRM handler is mounted at, and + * advertising someone else's document must not move the local route. + */ + public resourceMetadataUrl(): string { + return this.resourceMetadataUrlOverride ?? this.prmDocumentUrl(); + } + public async close(): Promise { // AuthplaneResource does not own caches; no-op. return; diff --git a/packages/sdk/src/shared/ssrf.ts b/packages/sdk/src/shared/ssrf.ts index f82f742..c6272f1 100644 --- a/packages/sdk/src/shared/ssrf.ts +++ b/packages/sdk/src/shared/ssrf.ts @@ -3,7 +3,13 @@ import type { IncomingHttpHeaders } from "node:http"; import { request as httpRequest } from "node:http"; import { request as httpsRequest } from "node:https"; import { BlockList, isIP } from "node:net"; -import * as ipaddr from "ipaddr.js"; +// Default import, not a namespace import. ipaddr.js is CommonJS with no +// `exports` map, so under Node's own ESM loader the namespace object carries +// only `default` and `ipaddr.parse` is `undefined`. Vitest's interop papers +// over the difference, which is why a unit test cannot catch a regression +// here; tests/core/ssrfEsmInterop.test.ts runs the built output under the +// real loader instead. +import ipaddr from "ipaddr.js"; const DEFAULT_MAX_SIZE = 65_536; const DEFAULT_TIMEOUT_SECONDS = 10; @@ -121,22 +127,40 @@ function parseEmbeddedIpv4From6to4(ip: string): string | undefined { return octets.join("."); } +/** + * Parse an IP, or `undefined` when the string is not one. + * + * The only entry point to `ipaddr.parse` in this module, so the distinction it + * makes cannot be forgotten at a future call site: `ipaddr.parse` rejects a + * malformed address with a plain `Error`, which is the one failure a caller + * may read as "not an address". A `TypeError` means the call never happened — + * `ipaddr` resolved to something with no `parse` function, as it did under + * Node ESM before the default import — and swallowing that turns a broken + * import into a guard that silently rejects every address. It is rethrown + * here rather than left to each caller's `catch`. + */ +function parseIp(ip: string): ipaddr.IPv4 | ipaddr.IPv6 | undefined { + try { + return ipaddr.parse(ip); + } catch (error) { + if (error instanceof TypeError) { + throw error; + } + return undefined; + } +} + function parseEmbeddedIpv4FromTeredo(ip: string): | { server: string; client: string; } | undefined { - let parsed: ipaddr.IPv6; - try { - const addr = ipaddr.parse(ip); - if (!(addr instanceof ipaddr.IPv6)) { - return undefined; - } - parsed = addr; - } catch { + const addr = parseIp(ip); + if (!(addr instanceof ipaddr.IPv6)) { return undefined; } + const parsed = addr; if (parsed.range() !== "teredo") { return undefined; @@ -209,10 +233,8 @@ export function isIpAllowed( allowPrivateNetworks?: boolean | undefined; } = {}, ): boolean { - let parsed: ipaddr.IPv4 | ipaddr.IPv6; - try { - parsed = ipaddr.parse(ip); - } catch { + const parsed = parseIp(ip); + if (parsed === undefined) { return false; } diff --git a/packages/sdk/tests/auth/index.test.ts b/packages/sdk/tests/auth/index.test.ts index fa81243..c89d6bc 100644 --- a/packages/sdk/tests/auth/index.test.ts +++ b/packages/sdk/tests/auth/index.test.ts @@ -1,9 +1,11 @@ import { describe, expect, test } from "vitest"; import { + AccessDeniedError, DPoPKeyMaterial, DPoPProvider, FetchSettings, GRANT_TYPE_TOKEN_EXCHANGE, + InvalidTargetError, TOKEN_TYPE_ACCESS_TOKEN, clientCredentialsGrant, exchange, @@ -20,6 +22,8 @@ describe("index exports", () => { expect(typeof revokeToken).toBe("function"); expect(typeof DPoPProvider).toBe("function"); expect(typeof DPoPKeyMaterial).toBe("function"); + expect(typeof AccessDeniedError).toBe("function"); + expect(typeof InvalidTargetError).toBe("function"); expect(GRANT_TYPE_TOKEN_EXCHANGE).toBe( "urn:ietf:params:oauth:grant-type:token-exchange", ); diff --git a/packages/sdk/tests/core/circuitPolicy.test.ts b/packages/sdk/tests/core/circuitPolicy.test.ts index 01a92eb..46af955 100644 --- a/packages/sdk/tests/core/circuitPolicy.test.ts +++ b/packages/sdk/tests/core/circuitPolicy.test.ts @@ -2,10 +2,12 @@ import { describe, expect, it } from "vitest"; import { shouldTripCircuit } from "../../src/core/circuitPolicy.js"; import { + AccessDeniedError, AuthError, InvalidClientError, InvalidGrantError, InvalidRequestError, + InvalidTargetError, ProtocolError, ServerError, UnauthorizedClientError, @@ -51,6 +53,26 @@ describe("shouldTripCircuit", () => { ).toBe(false); }); + it("does not trip on access_denied (403) or invalid_target (400)", () => { + // Both are policy answers from a healthy AS (exchanging client not + // allowlisted on the target Resource; `resource` not matching a granted + // resource byte for byte), never an outage — listed explicitly so the + // exclusion does not depend on the generic 4xx fallthrough. + expect(shouldTripCircuit(new AccessDeniedError("not allowed", 403))).toBe( + false, + ); + expect(shouldTripCircuit(new InvalidTargetError("no match", 400))).toBe( + false, + ); + // The code alone is enough, independent of the status the AS attached. + expect( + shouldTripCircuit(new AuthError("denied", { code: "access_denied" })), + ).toBe(false); + expect( + shouldTripCircuit(new AuthError("target", { code: "invalid_target" })), + ).toBe(false); + }); + it("does not trip on unknown OAuth 4xx codes", () => { expect( shouldTripCircuit( diff --git a/packages/sdk/tests/core/claims.test.ts b/packages/sdk/tests/core/claims.test.ts index 77a6d86..82d4d7b 100644 --- a/packages/sdk/tests/core/claims.test.ts +++ b/packages/sdk/tests/core/claims.test.ts @@ -37,12 +37,41 @@ describe("VerifiedClaims", () => { expect(() => claims.requireScope("tools/admin")).toThrow(InsufficientScope); }); + it("carries the required scopes on the thrown error", () => { + const claims = makeClaims(); + // wwwAuthenticate() falls back to this for `scope="…"`, so a host with no + // configured scopes of its own still tells the client what to step up to. + try { + claims.requireScope("tools/admin"); + expect.unreachable("requireScope must throw"); + } catch (error) { + expect(error).toBeInstanceOf(InsufficientScope); + expect((error as InsufficientScope).requiredScopes).toEqual([ + "tools/admin", + ]); + } + }); + describe("requireScopes (AND)", () => { it("is a no-op when the required list is empty", () => { const claims = makeClaims(); expect(() => claims.requireScopes([])).not.toThrow(); }); + it("carries the full required set on the thrown error, not just the missing ones", () => { + const claims = makeClaims(); + try { + claims.requireScopes(["tools/query", "tools/admin"]); + expect.unreachable("requireScopes must throw"); + } catch (error) { + expect(error).toBeInstanceOf(InsufficientScope); + expect((error as InsufficientScope).requiredScopes).toEqual([ + "tools/query", + "tools/admin", + ]); + } + }); + it("passes when every required scope is present", () => { const claims = makeClaims(); expect(() => diff --git a/packages/sdk/tests/core/clientMoreBranches.test.ts b/packages/sdk/tests/core/clientMoreBranches.test.ts index a87f5ea..2dbb21c 100644 --- a/packages/sdk/tests/core/clientMoreBranches.test.ts +++ b/packages/sdk/tests/core/clientMoreBranches.test.ts @@ -152,6 +152,47 @@ describe("AuthplaneClient more branches", () => { } }); + // RFC 8707 §2 / RFC 9728 §1.2: a fragment-bearing resource indicator is + // rejected where the operator configures it, not later from + // `prmDocumentUrl()` on the 401 challenge path. `client.resource()` has no + // check of its own — it inherits `AuthplaneResource`'s constructor gate, and + // this pins that the factory really does surface it. + it("rejects a fragment-bearing resource from client.resource()", async () => { + const serverData = await startFullServer({ + tokenHandler: async () => ({ + statusCode: 200, + json: { access_token: "at", token_type: "Bearer", expires_in: 10, scope: "" }, + }), + }); + + const { server, base } = serverData; + try { + const client = await AuthplaneClient.create({ + issuer: base, + devMode: true, + metadataRefreshSeconds: 60, + jwksRefreshSeconds: 60, + }); + + expect(() => + client.resource({ resource: `${base}/mcp#frag`, scopes: [] }), + ).toThrow(TypeError); + expect(() => + client.resource({ resource: `${base}/mcp#frag`, scopes: [] }), + ).toThrow(/RFC 8707 §2/u); + + // The same identifier without the fragment is unaffected. + const ok = client.resource({ resource: `${base}/mcp`, scopes: [] }); + expect(ok.prmResponse().resource).toBe(`${base}/mcp`); + await ok.close(); + await client.close(); + } finally { + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ); + } + }); + it("throws 'client not initialized' when metadataCache is missing (all entrypoints)", async () => { const serverData = await startFullServer({ tokenHandler: async () => ({ diff --git a/packages/sdk/tests/core/documentCache.test.ts b/packages/sdk/tests/core/documentCache.test.ts index 0361d14..ab7b5e5 100644 --- a/packages/sdk/tests/core/documentCache.test.ts +++ b/packages/sdk/tests/core/documentCache.test.ts @@ -1,6 +1,11 @@ import { describe, expect, it, vi } from "vitest"; -import { DocumentCache } from "../../src/core/fetching/documentCache.js"; +import { MetadataFetchError } from "../../src/core/errors.js"; +import { + DocumentCache, + JWKSCache, + MetadataCache, +} from "../../src/core/fetching/documentCache.js"; type Doc = { v: number }; @@ -64,13 +69,11 @@ describe("fetching/documentCache", () => { } }); - it("triggers background refresh once and calls onChange when document differs", async () => { + it("triggers background refresh once and serves the new document after it lands", async () => { const t0 = 1_700_000_000; // Background refresh should start when nowSeconds - cacheTimeSeconds >= ttl * 0.8. // Here we force serverExpiresAt to make ttl smaller, so it triggers earlier. - const onChange = vi.fn(async () => {}); - let call = 0; let resolveSecond: (() => void) | undefined; @@ -95,7 +98,6 @@ describe("fetching/documentCache", () => { const cache = new DocumentCache(fetcher, { refreshSeconds: 100, - onChange, }); const nowSpy = vi.spyOn(Date, "now"); @@ -116,8 +118,107 @@ describe("fetching/documentCache", () => { // Next get should observe the updated document. const dAfter = await cache.get(); expect(dAfter).toEqual({ v: 2 }); - expect(onChange).toHaveBeenCalledTimes(1); - expect(onChange).toHaveBeenCalledWith({ v: 1 }, { v: 2 }); + expect(call).toBe(2); + } finally { + nowSpy.mockRestore(); + } + }); + + it("keeps the cached document when validation rejects a fetched one", async () => { + // The rejected document must not reach `this.cache` even transiently: + // `jwks_uri` resolution reads the cache, so a document that fails validation + // deciding where keys come from is the defect this ordering exists to close. + class ValidatingCache extends DocumentCache { + public validated: Doc[] = []; + protected override validateDocument(document: Doc): Doc { + this.validated.push(document); + if (document.v === 2) { + throw new Error("rejected by validation"); + } + return document; + } + } + + let call = 0; + const cache = new ValidatingCache( + async () => { + call += 1; + return { document: { v: call }, expiresAt: undefined }; + }, + { refreshSeconds: 100 }, + ); + + const nowSpy = vi.spyOn(Date, "now"); + try { + const t0 = 1_700_000_000; + nowSpy.mockReturnValue(t0 * 1000); + expect(await cache.get()).toEqual({ v: 1 }); + + // Past the TTL: the fetch returns { v: 2 }, which validation rejects. + nowSpy.mockReturnValue((t0 + 200) * 1000); + expect(await cache.get()).toEqual({ v: 1 }); + expect(cache.validated).toEqual([{ v: 1 }, { v: 2 }]); + } finally { + nowSpy.mockRestore(); + } + }); + + it("throws through the error factory when validation rejects the first document", async () => { + class AlwaysRejects extends DocumentCache { + protected override validateDocument(): Doc { + throw new Error("bad document"); + } + } + + const cache = new AlwaysRejects( + async () => ({ document: { v: 1 }, expiresAt: undefined }), + { + refreshSeconds: 100, + errorFactory: (message) => new Error(`wrapped: ${message}`), + }, + ); + + await expect(cache.get()).rejects.toThrow( + "wrapped: Failed to fetch document: bad document", + ); + }); + + it("close() drains an in-flight background refresh", async () => { + const t0 = 1_700_000_000; + let settled = false; + let resolveSecond: (() => void) | undefined; + let call = 0; + + const cache = new DocumentCache( + async () => { + call += 1; + if (call === 1) { + return { document: { v: 1 }, expiresAt: t0 + 50 }; + } + return new Promise<{ document: Doc; expiresAt: number | undefined }>( + (resolve) => { + resolveSecond = () => { + settled = true; + resolve({ document: { v: 2 }, expiresAt: t0 + 150 }); + }; + }, + ); + }, + { refreshSeconds: 100 }, + ); + + const nowSpy = vi.spyOn(Date, "now"); + try { + nowSpy.mockReturnValue(t0 * 1000); + await cache.get(); + nowSpy.mockReturnValue((t0 + 41) * 1000); + await cache.get(); + + const closing = cache.close(); + expect(settled).toBe(false); + resolveSecond?.(); + await closing; + expect(settled).toBe(true); } finally { nowSpy.mockRestore(); } @@ -211,5 +312,828 @@ describe("fetching/documentCache", () => { await expect(cache.get()).rejects.toThrow(/Failed to fetch document: nope/); }); + it("does not let a slower background refresh overwrite a newer forced fetch", async () => { + // A forced caller deliberately does not join a non-forced fetch already in + // flight, which is what puts two fetches in the air at once. Committing + // unconditionally is then last-writer-wins rather than + // latest-document-wins: the background refresh started before the rotation + // lands after the forced read that observed it, and puts the withdrawn + // document back for the rest of the interval. + const t0 = 1_700_000_000; + let now = t0 * 1000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + + const release: Array<() => void> = []; + const cache = new DocumentCache( + async (forceUpstream) => { + // v1 for the background read, v2 for the forced one: the forced read is + // the one that saw the newer upstream state. + const document = { v: forceUpstream ? 2 : 1 }; + await new Promise((resolve) => release.push(resolve)); + return { document, expiresAt: undefined }; + }, + { refreshSeconds: 100 }, + ); + + try { + const boot = cache.get(); + release[0]?.(); + await boot; + + // Into the stale-while-revalidate window, so an ordinary read starts a + // background refresh instead of returning from cache alone. + now = (t0 + 85) * 1000; + await cache.get(); + const forced = cache.get(true); + + // Two fetchers are suspended: [1] the background refresh, [2] the forced + // read. Release the forced one first so the stale answer commits last. + release[2]?.(); + await forced; + release[1]?.(); + await cache.close(); + + // The document that saw the newer state survives the one that landed + // after it. + now = (t0 + 86) * 1000; + expect(await cache.get()).toEqual({ v: 2 }); + } finally { + nowSpy.mockRestore(); + } + }); + + it("keeps deduping after a concurrent fetch settles", async () => { + // Clearing `fetchInFlight` unconditionally in `finally` tears down the + // dedupe state of a fetch that is still running, so the next caller starts a + // duplicate upstream fetch instead of joining the one in flight. + const t0 = 1_700_000_000; + let now = t0 * 1000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + + let fetchCount = 0; + const release: Array<() => void> = []; + const cache = new DocumentCache( + async () => { + fetchCount += 1; + const v = fetchCount; + await new Promise((resolve) => release.push(resolve)); + return { document: { v }, expiresAt: undefined }; + }, + { refreshSeconds: 100 }, + ); + + try { + const boot = cache.get(); + release[0]?.(); + await boot; + expect(fetchCount).toBe(1); + + now = (t0 + 85) * 1000; + await cache.get(); + const forced = cache.get(true); + expect(fetchCount).toBe(3); + + // Settle the background refresh, which started first and no longer owns + // the dedupe fields. + release[1]?.(); + await cache.close(); + + // A caller arriving now must join the forced fetch that is still running. + // Asserted before releasing anything: the fetcher increments + // synchronously, so a fourth upstream fetch would already be counted here. + // Waiting instead would turn the regression into a hang, not a failure. + const joined = cache.get(true); + expect(fetchCount).toBe(3); + + for (const resolve of release) { + resolve(); + } + await Promise.all([forced, joined]); + } finally { + nowSpy.mockRestore(); + } + }); + it("caps forced metadata reads at one per floor, and follows a rotation after it", async () => { + // A forced read bypasses `refreshSeconds`, and the caller that reaches it is + // a JWKS `kid` miss — unauthenticated, since only the token header has been + // decoded. Without a floor an attacker-chosen `kid` costs the AS one + // discovery fetch per request. Refusing downgrades the read rather than + // failing it, so a valid cached document is still served. + const issuer = "https://as.example.com"; + const t0 = 1_700_000_000; + let now = t0 * 1000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + + let fetchCount = 0; + let jwksUri = `${issuer}/jwks-v1.json`; + const cache = new MetadataCache( + async () => { + fetchCount += 1; + return { + document: { issuer, jwks_uri: jwksUri }, + expiresAt: undefined, + }; + }, + { refreshSeconds: 100, expectedIssuer: issuer }, + ); + + try { + await cache.get(); + expect(fetchCount).toBe(1); + + // First miss after boot: admitted, so a real rotation is followed at once. + await cache.get(true); + expect(fetchCount).toBe(2); + + // A flood of misses inside the floor costs the AS nothing more. + now = (t0 + 5) * 1000; + jwksUri = `${issuer}/jwks-v2.json`; + for (let i = 0; i < 10; i += 1) { + await cache.get(true); + } + expect(fetchCount).toBe(2); + expect((await cache.get()).jwks_uri).toBe(`${issuer}/jwks-v1.json`); + + // Past the floor — `min(refreshSeconds, 60)` — the next miss re-reads and + // the rotation lands. + now = (t0 + 61) * 1000; + await cache.get(true); + expect(fetchCount).toBe(3); + expect((await cache.get()).jwks_uri).toBe(`${issuer}/jwks-v2.json`); + } finally { + nowSpy.mockRestore(); + } + }); + + it("does not re-attempt a failed fetch before the retry floor elapses", async () => { + // Once the refresh interval lapses against an unreachable upstream, every + // call would otherwise start a fresh fetch and pay the timeout again — the + // in-flight dedupe collapses a wave of concurrent callers, not the waves + // that follow. The floor turns that into one attempt per + // `max(1, min(30, refreshSeconds))`, serving the last known good document + // in between. + const t0 = 1_700_000_000; + let now = t0 * 1000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + + let fetchCount = 0; + let failing = false; + const cache = new DocumentCache( + async () => { + fetchCount += 1; + if (failing) { + throw new Error("upstream unreachable"); + } + return { document: { v: fetchCount }, expiresAt: undefined }; + }, + { refreshSeconds: 100, errorFactory: (m) => new Error(m) }, + ); + + try { + expect(await cache.get()).toEqual({ v: 1 }); + failing = true; + + // Expired: this call pays for the attempt, which fails and opens the + // floor. The cached document is still served. + now = (t0 + 101) * 1000; + expect(await cache.get()).toEqual({ v: 1 }); + expect(fetchCount).toBe(2); + + // Inside the floor nothing reaches upstream — not an ordinary read, not + // a forced one. Both are served from cache. + now = (t0 + 102) * 1000; + expect(await cache.get()).toEqual({ v: 1 }); + expect(await cache.get(true)).toEqual({ v: 1 }); + expect(fetchCount).toBe(2); + + // The floor — min(30, refreshSeconds) after the failure — elapses, and + // the next call retries. Upstream is back, so the fresh document lands. + failing = false; + now = (t0 + 131) * 1000; + expect(await cache.get()).toEqual({ v: 3 }); + expect(fetchCount).toBe(3); + } finally { + nowSpy.mockRestore(); + } + }); + + it("fails fast inside the floor when nothing is cached", async () => { + // With no last known good document there is nothing to serve, so refusal + // surfaces the retained failure immediately instead of stalling the caller + // on another doomed fetch. + const t0 = 1_700_000_000; + let now = t0 * 1000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + + let fetchCount = 0; + const cache = new DocumentCache( + async () => { + fetchCount += 1; + throw new Error("nope"); + }, + { refreshSeconds: 100, errorFactory: (message) => new Error(message) }, + ); + + try { + await expect(cache.get()).rejects.toThrow(/Failed to fetch document: nope/); + expect(fetchCount).toBe(1); + + // Same typed error, no fetch attempt. + now = (t0 + 1) * 1000; + await expect(cache.get()).rejects.toThrow(/Failed to fetch document: nope/); + expect(fetchCount).toBe(1); + + // Past the floor the attempt is admitted again. + now = (t0 + 31) * 1000; + await expect(cache.get()).rejects.toThrow(/Failed to fetch document: nope/); + expect(fetchCount).toBe(2); + } finally { + nowSpy.mockRestore(); + } + }); + + it("closes the floor on a successful fetch", async () => { + // Success must clear the failure state, not restart the window: a forced + // read shortly after a successful one is admitted, where a floor stamped + // at the success would have refused it and served the older document. + const t0 = 1_700_000_000; + let now = t0 * 1000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + + let fetchCount = 0; + const cache = new DocumentCache( + async () => { + fetchCount += 1; + if (fetchCount === 1) { + throw new Error("upstream unreachable"); + } + return { document: { v: fetchCount }, expiresAt: undefined }; + }, + { refreshSeconds: 100, errorFactory: (message) => new Error(message) }, + ); + + try { + await expect(cache.get()).rejects.toThrow(); + expect(fetchCount).toBe(1); + + // Floor elapsed; the retry succeeds and closes it. + now = (t0 + 31) * 1000; + expect(await cache.get()).toEqual({ v: 2 }); + + // Nine seconds later — inside what a floor restarted at the success + // would still cover — a forced read reaches upstream. + now = (t0 + 40) * 1000; + expect(await cache.get(true)).toEqual({ v: 3 }); + expect(fetchCount).toBe(3); + } finally { + nowSpy.mockRestore(); + } + }); + + it("caps the floor at the refresh interval and lifts it to one second", async () => { + // `max(1, min(configured, refreshSeconds))`: a cache asked to refresh + // every 2 seconds must not be pinned to the 30-second default, and a + // configured zero must not collapse the floor entirely — that would put + // the unbounded retry behaviour back under another name. + const t0 = 1_700_000_000; + let now = t0 * 1000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + + let fetchCount = 0; + let failing = true; + const shortRefresh = new DocumentCache( + async () => { + fetchCount += 1; + if (failing) { + throw new Error("down"); + } + return { document: { v: fetchCount }, expiresAt: undefined }; + }, + { refreshSeconds: 2, errorFactory: (message) => new Error(message) }, + ); + + try { + await expect(shortRefresh.get()).rejects.toThrow(); + now = (t0 + 1) * 1000; + await expect(shortRefresh.get()).rejects.toThrow(); + expect(fetchCount).toBe(1); + + // The interval, not the default, bounds the floor: 2 s, not 30. + failing = false; + now = (t0 + 2) * 1000; + expect(await shortRefresh.get()).toEqual({ v: 2 }); + expect(fetchCount).toBe(2); + } finally { + nowSpy.mockRestore(); + } + + let zeroFloorFetches = 0; + const zeroConfigured = new DocumentCache( + async () => { + zeroFloorFetches += 1; + throw new Error("down"); + }, + { + refreshSeconds: 100, + failureBackoffSeconds: 0, + errorFactory: (message) => new Error(message), + }, + ); + + const zeroSpy = vi.spyOn(Date, "now"); + try { + const t1 = 1_700_001_000; + zeroSpy.mockReturnValue(t1 * 1000); + await expect(zeroConfigured.get()).rejects.toThrow(); + await expect(zeroConfigured.get()).rejects.toThrow(); + expect(zeroFloorFetches).toBe(1); + + zeroSpy.mockReturnValue((t1 + 1) * 1000); + await expect(zeroConfigured.get()).rejects.toThrow(); + expect(zeroFloorFetches).toBe(2); + } finally { + zeroSpy.mockRestore(); + } + }); + + it("does not open the floor when a superseded fetch fails after a newer one committed", async () => { + // The failure path must carry the same sequence guard as the success path: + // an older fetch that fails after a newer one has already committed proves + // nothing about the upstream now — it demonstrably answered seconds ago — + // and stamping the floor from it would refuse the next forced read for up + // to a full window. Under steady verify traffic this is the ordinary + // shape: a background refresh opens at 80% of TTL, a `kid` miss forces a + // read, and the background refresh returns last and fails. + const t0 = 1_700_000_000; + let now = t0 * 1000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + + let fetchCount = 0; + const release: Array<(ok: boolean) => void> = []; + const cache = new DocumentCache( + async () => { + fetchCount += 1; + const document = { v: fetchCount }; + await new Promise((resolve, reject) => { + release.push((ok) => + ok ? resolve() : reject(new Error("stale fetch failed")), + ); + }); + return { document, expiresAt: undefined }; + }, + { refreshSeconds: 100, errorFactory: (message) => new Error(message) }, + ); + + try { + const boot = cache.get(); + release[0]?.(true); + await boot; + + // Into the stale-while-revalidate window: an ordinary read starts a + // non-forced background refresh [1], then a forced read [2] runs + // concurrently — a forced caller deliberately does not join it. + now = (t0 + 85) * 1000; + await cache.get(); + const forced = cache.get(true); + + // The forced read succeeds and commits first; the stale background + // refresh then fails. + release[2]?.(true); + await forced; + release[1]?.(false); + await cache.close(); + + // The superseded failure must not have opened the floor: the next + // forced read reaches upstream instead of being served the cache. + // Asserted before releasing: the fetcher increments synchronously, so a + // floored read would leave the count unchanged here. + now = (t0 + 86) * 1000; + const attemptsBefore = fetchCount; + const next = cache.get(true); + expect(fetchCount).toBe(attemptsBefore + 1); + release[3]?.(true); + await next; + } finally { + nowSpy.mockRestore(); + } + }); + + it("does not spend the forced-read budget on a floor-refused metadata read", async () => { + // `admitForcedRead()` stamps the forced-read budget, which exists solely + // to cap what an attacker-chosen `kid` can make the SDK ask of the AS. + // A read the failure floor is going to refuse sends nothing upstream, so + // it must not spend that budget: otherwise the first miss after the floor + // elapses — exactly the read that follows a rotation — is downgraded to + // an ordinary one and served the withdrawn document. + const issuer = "https://as.example.com"; + const t0 = 1_700_000_000; + let now = t0 * 1000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + + const attempts: Array<{ at: number; forced: boolean }> = []; + let failing = false; + let jwksUri = `${issuer}/jwks-old.json`; + const cache = new MetadataCache( + async (forceUpstream) => { + attempts.push({ + at: Math.floor(Date.now() / 1000) - t0, + forced: forceUpstream, + }); + if (failing) { + throw new Error("as unreachable"); + } + return { document: { issuer, jwks_uri: jwksUri }, expiresAt: undefined }; + }, + { refreshSeconds: 3600, expectedIssuer: issuer }, + ); + + try { + await cache.get(); + + // At 80% of the interval the background refresh fails, opening the + // 30-second failure floor. + failing = true; + now = (t0 + 2880) * 1000; + await cache.get(); + await cache.close(); + + // A `kid` miss inside the floor: refused, downgraded, served from + // cache — and the 60-second forced-read budget must not be burnt. + now = (t0 + 2881) * 1000; + expect((await cache.get(true)).jwks_uri).toBe(`${issuer}/jwks-old.json`); + + // The AS recovers and rotates. The first miss past the failure floor + // must be a FORCED attempt that lands the rotation. + failing = false; + jwksUri = `${issuer}/jwks-new.json`; + now = (t0 + 2911) * 1000; + expect((await cache.get(true)).jwks_uri).toBe(`${issuer}/jwks-new.json`); + expect(attempts).toEqual([ + { at: 0, forced: false }, + { at: 2880, forced: false }, + { at: 2911, forced: true }, + ]); + } finally { + nowSpy.mockRestore(); + } + }); + + it("honours a configured failureBackoffSeconds", async () => { + const t0 = 1_700_000_000; + let now = t0 * 1000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + + let fetchCount = 0; + const cache = new DocumentCache( + async () => { + fetchCount += 1; + throw new Error("down"); + }, + { + refreshSeconds: 100, + failureBackoffSeconds: 5, + errorFactory: (message) => new Error(message), + }, + ); + + try { + await expect(cache.get()).rejects.toThrow(); + now = (t0 + 4) * 1000; + await expect(cache.get()).rejects.toThrow(); + expect(fetchCount).toBe(1); + + now = (t0 + 5) * 1000; + await expect(cache.get()).rejects.toThrow(); + expect(fetchCount).toBe(2); + } finally { + nowSpy.mockRestore(); + } + }); + + it("applies no failure floor when refreshSeconds is zero", async () => { + // `refreshSeconds: 0` means "re-read every time" — the opt-out the + // CHANGELOG and the user guide document, and the configuration the + // rotation conformance cases run. It must opt out of the failure floor + // exactly as it opts out of the forced-read floor: clamping it to 1 would + // refuse same-second retries the operator asked for. + const t0 = 1_700_000_000; + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(t0 * 1000); + + let fetchCount = 0; + const cache = new DocumentCache( + async () => { + fetchCount += 1; + throw new Error("down"); + }, + { refreshSeconds: 0, errorFactory: (message) => new Error(message) }, + ); + + try { + await expect(cache.get()).rejects.toThrow(/down/); + await expect(cache.get()).rejects.toThrow(/down/); + expect(fetchCount).toBe(2); + } finally { + nowSpy.mockRestore(); + } + }); + + it("falls back to the default floor when a knob is not finite", async () => { + // `min`/`max` propagate `NaN`, and `x < NaN` is always false — an + // unguarded clamp would silently disable the floor entirely, which is the + // unbounded retry behaviour it exists to prevent, reachable from + // `Number(process.env.X)` on an unset variable. + const t0 = 1_700_000_000; + let now = t0 * 1000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + + let nanBackoffFetches = 0; + const nanBackoff = new DocumentCache( + async () => { + nanBackoffFetches += 1; + throw new Error("down"); + }, + { + refreshSeconds: 100, + failureBackoffSeconds: Number.NaN, + errorFactory: (message) => new Error(message), + }, + ); + + let nanRefreshFetches = 0; + const nanRefresh = new DocumentCache( + async () => { + nanRefreshFetches += 1; + throw new Error("down"); + }, + { + refreshSeconds: Number.NaN, + errorFactory: (message) => new Error(message), + }, + ); + + try { + await expect(nanBackoff.get()).rejects.toThrow(/down/); + await expect(nanRefresh.get()).rejects.toThrow(/down/); + + // Inside the default 30-second floor: refused, no attempt. + now = (t0 + 29) * 1000; + await expect(nanBackoff.get()).rejects.toThrow(/down/); + await expect(nanRefresh.get()).rejects.toThrow(/down/); + expect(nanBackoffFetches).toBe(1); + expect(nanRefreshFetches).toBe(1); + + // Past it: admitted again. + now = (t0 + 30) * 1000; + await expect(nanBackoff.get()).rejects.toThrow(/down/); + await expect(nanRefresh.get()).rejects.toThrow(/down/); + expect(nanBackoffFetches).toBe(2); + expect(nanRefreshFetches).toBe(2); + } finally { + nowSpy.mockRestore(); + } + }); + + it("warns once per floor window when refusals suppress attempts", async () => { + // An operator must be able to tell a 30-second backoff from a live + // outage — the retained error is deliberately the same one a fresh + // attempt would produce — but per-refusal logging under per-request + // traffic would be spam, so the warning is bounded to one per window. + const t0 = 1_700_000_000; + let now = t0 * 1000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + let fetchCount = 0; + const cache = new DocumentCache( + async () => { + fetchCount += 1; + throw new Error("down"); + }, + { refreshSeconds: 100, errorFactory: (message) => new Error(message) }, + ); + + try { + // The attempt itself does not warn — only a refusal does. + await expect(cache.get()).rejects.toThrow(); + expect(warnSpy).not.toHaveBeenCalled(); + + // First refusal in the window warns; the rest of the window is silent. + now = (t0 + 1) * 1000; + await expect(cache.get()).rejects.toThrow(); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenLastCalledWith( + "[authplane] Document refresh backing off after a failed attempt (retry in 29s).", + ); + now = (t0 + 2) * 1000; + await expect(cache.get()).rejects.toThrow(); + await expect(cache.get()).rejects.toThrow(); + expect(warnSpy).toHaveBeenCalledTimes(1); + + // A new failed attempt opens a new window, and its first refusal warns + // again. + now = (t0 + 31) * 1000; + await expect(cache.get()).rejects.toThrow(); + expect(fetchCount).toBe(2); + now = (t0 + 32) * 1000; + await expect(cache.get()).rejects.toThrow(); + expect(warnSpy).toHaveBeenCalledTimes(2); + } finally { + warnSpy.mockRestore(); + nowSpy.mockRestore(); + } + }); + + it("names the document in the backoff warning", async () => { + // Two caches share this class, and a floored metadata document and a + // floored JWKS one have different causes and different fixes — an operator + // watching a resource server during an AS outage needs to know which is + // backing off. Same labels as the java-sdk's `documentType`. + const t0 = 1_700_000_000; + let now = t0 * 1000; + const nowSpy = vi.spyOn(Date, "now").mockImplementation(() => now); + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + + const failingFetcher = async (): Promise => { + throw new Error("down"); + }; + const metadataCache = new MetadataCache(failingFetcher, { + refreshSeconds: 100, + }); + const jwksCache = new JWKSCache(failingFetcher, 100); + + try { + await expect(metadataCache.get()).rejects.toThrow(); + await expect(jwksCache.get()).rejects.toThrow(); + now = (t0 + 1) * 1000; + await expect(metadataCache.get()).rejects.toThrow(); + await expect(jwksCache.get()).rejects.toThrow(); + expect(warnSpy).toHaveBeenCalledTimes(2); + expect(warnSpy).toHaveBeenNthCalledWith( + 1, + "[authplane] metadata refresh backing off after a failed attempt (retry in 29s).", + ); + expect(warnSpy).toHaveBeenNthCalledWith( + 2, + "[authplane] JWKS refresh backing off after a failed attempt (retry in 29s).", + ); + } finally { + warnSpy.mockRestore(); + nowSpy.mockRestore(); + } + }); + + it("rethrows the retained failure with its stack intact on every refusal", async () => { + // A refusal rethrows the retained instance itself — deliberately shared + // across the window, so what callers catch is a real Error carrying the + // original attempt's stack. A per-caller rebuild is worse: a + // descriptor-copying clone has no `[[ErrorData]]` slot, so its `stack` + // getter yields `undefined` and it fails `isNativeError` — a stackless + // error on exactly the path that dominates during an outage. + const t0 = 1_700_000_000; + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(t0 * 1000); + + const cache = new DocumentCache>( + async () => { + throw new MetadataFetchError("as down"); + }, + { refreshSeconds: 100 }, + ); + + try { + const errors: unknown[] = []; + for (let i = 0; i < 3; i += 1) { + await cache.get().catch((error: unknown) => errors.push(error)); + } + const [first, second, third] = errors; + // Same typed error a fresh attempt would have produced — refusals are + // indistinguishable from the attempt they suppress, stack included. + for (const error of errors) { + expect(error).toBeInstanceOf(MetadataFetchError); + expect((error as Error).message).toBe("as down"); + expect(typeof (error as Error).stack).toBe("string"); + expect((error as Error).stack).toContain("as down"); + } + expect(second).toBe(first); + expect(third).toBe(first); + } finally { + nowSpy.mockRestore(); + } + }); + + it("applies no forced-read floor when refreshSeconds is zero", async () => { + // `refreshSeconds: 0` means "re-read every time", which is what the rotation + // conformance cases configure, and what the CHANGELOG and the user guide both + // tell operators opts out of the floor. The floor is `min(refreshSeconds, 60)`. + // + // Asserted on the flags the fetcher receives, not on the fetch count: at a TTL + // of 0 an ordinary read fetches too, so a count is 3 whether or not the floor + // refused anything. `forceUpstream` is the one thing the opt-out still changes + // at this interval, and it is load-bearing — the join condition in + // `fetchAndUpdate` treats a forced caller differently from an ordinary one. + const issuer = "https://as.example.com"; + const t0 = 1_700_000_000; + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(t0 * 1000); + + const forcedFlags: boolean[] = []; + const cache = new MetadataCache( + async (forceUpstream) => { + forcedFlags.push(forceUpstream); + return { + document: { issuer, jwks_uri: `${issuer}/jwks.json` }, + expiresAt: undefined, + }; + }, + { refreshSeconds: 0, expectedIssuer: issuer }, + ); + + try { + await cache.get(); + await cache.get(true); + await cache.get(true); + expect(forcedFlags).toEqual([false, true, true]); + } finally { + nowSpy.mockRestore(); + } + }); }); +describe("fetching/documentCache server expiry", () => { + // The pre-existing coverage above only exercises a server expiry in the + // future, which is honoured before and after this fix and so says nothing + // about a non-future one. These two pin the open door: `max-age=0` and a + // stale `Expires:` header both arrive as a zero or negative TTL, and honouring + // either leaves the document expired on every read — every verification takes + // the synchronous re-fetch path, which on this cache is an unauthenticated + // caller driving one upstream fetch per request. + it("ignores a server expiry of zero and lets the configured interval govern", async () => { + const t0 = 1_700_000_000; + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(t0 * 1000); + + let fetchCount = 0; + const cache = new DocumentCache( + async () => { + fetchCount += 1; + return { document: { v: fetchCount }, expiresAt: 0 }; + }, + { refreshSeconds: 100 }, + ); + + try { + expect(await cache.get()).toEqual({ v: 1 }); + nowSpy.mockReturnValue((t0 + 50) * 1000); + expect(await cache.get()).toEqual({ v: 1 }); + expect(fetchCount).toBe(1); + } finally { + nowSpy.mockRestore(); + } + }); + + it("ignores a server expiry that is already in the past when the document is cached", async () => { + const t0 = 1_700_000_000; + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(t0 * 1000); + + let fetchCount = 0; + const cache = new DocumentCache( + async () => { + fetchCount += 1; + return { document: { v: fetchCount }, expiresAt: t0 - 1 }; + }, + { refreshSeconds: 100 }, + ); + + try { + expect(await cache.get()).toEqual({ v: 1 }); + nowSpy.mockReturnValue((t0 + 50) * 1000); + expect(await cache.get()).toEqual({ v: 1 }); + expect(fetchCount).toBe(1); + } finally { + nowSpy.mockRestore(); + } + }); + + it("still shortens the interval for a server expiry in the future", async () => { + // The negative control for the two above: only a non-future expiry is + // discarded. A server that asks for a shorter TTL than the configured + // interval is still obeyed, so the fix cannot be "ignore the server". + const t0 = 1_700_000_000; + const nowSpy = vi.spyOn(Date, "now").mockReturnValue(t0 * 1000); + + let fetchCount = 0; + const cache = new DocumentCache( + async () => { + fetchCount += 1; + return { document: { v: fetchCount }, expiresAt: t0 + 10 }; + }, + { refreshSeconds: 100 }, + ); + + try { + expect(await cache.get()).toEqual({ v: 1 }); + nowSpy.mockReturnValue((t0 + 11) * 1000); + expect(await cache.get()).toEqual({ v: 2 }); + expect(fetchCount).toBe(2); + } finally { + nowSpy.mockRestore(); + } + }); +}); diff --git a/packages/sdk/tests/core/dpopHelpers.test.ts b/packages/sdk/tests/core/dpopHelpers.test.ts index b58096a..d3166c0 100644 --- a/packages/sdk/tests/core/dpopHelpers.test.ts +++ b/packages/sdk/tests/core/dpopHelpers.test.ts @@ -16,6 +16,7 @@ import { DPoPReplayDetected, InvalidDPoPProof, MultipleDPoPProofs, + wwwAuthenticate, } from "../../src/core/errors.js"; function sha256Base64Url(value: string): string { @@ -391,5 +392,60 @@ describe("dpop helpers", () => { vi.useRealTimers(); }); -}); + // RFC 9449 §9: the resource-server nonce proves the proof was minted after + // contacting the server. A caller who can read the expected nonce out of the + // 401 skips that round trip, so the assertion is on the COMPOSED challenge — + // the value that actually reaches the wire — not on the thrown error. + it("does not disclose the expected nonce in the WWW-Authenticate challenge", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-13T00:00:00Z")); + + const nowSeconds = Math.floor(Date.now() / 1000); + const { privateKey, publicKey } = await generateKeyPair("ES256"); + const publicJwk = await exportJWK(publicKey); + const expectedJkt = await calculateJwkThumbprint(publicJwk); + + const method = "GET"; + const url = "https://api.example.com/resource"; + const accessToken = "at_1"; + + const proof = await new SignJWT({ + htm: method, + htu: url, + iat: nowSeconds, + exp: nowSeconds + 120, + jti: "jti_nonce_1", + ath: sha256Base64Url(accessToken), + nonce: "client-stale-nonce", + } as Record) + .setProtectedHeader({ alg: "ES256", typ: "dpop+jwt", jwk: publicJwk }) + .sign(privateKey); + + const error = await verifyDpopProof({ + proof, + method, + url, + accessToken, + expectedJkt: String(expectedJkt), + maxAgeSeconds: 60, + clockSkewSeconds: 0, + expectedNonce: "server-nonce-abc", + replayStore: new InMemoryDPoPReplayStore(), + }).then( + () => undefined, + (caught: unknown) => caught, + ); + + expect(error).toBeInstanceOf(InvalidDPoPProof); + const challenge = wwwAuthenticate(error as InvalidDPoPProof); + expect(challenge).not.toContain("server-nonce-abc"); + expect(challenge).toContain('error="invalid_token"'); + // The fixed description would hide the nonce on its own; assert the + // message itself is clean, so the escape hatch cannot reopen the leak. + expect( + wwwAuthenticate(error as InvalidDPoPProof, { verboseDescription: true }), + ).not.toContain("server-nonce-abc"); + vi.useRealTimers(); + }); +}); diff --git a/packages/sdk/tests/core/errors.test.ts b/packages/sdk/tests/core/errors.test.ts index b9fbf13..ebf82e2 100644 --- a/packages/sdk/tests/core/errors.test.ts +++ b/packages/sdk/tests/core/errors.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { + AccessDeniedError, AuthError, ConsentRequiredError, DPoPBindingMismatch, @@ -12,6 +13,7 @@ import { InvalidDPoPProof, InvalidGrant, InvalidSignature, + InvalidTargetError, JWKSFetchError, MetadataFetchError, MissingMetadataEndpoint, @@ -21,9 +23,11 @@ import { TokenMissing, TokenRevoked, VerifierRuntimeError, + errorResponseBody, httpStatus, mapOAuthError, wwwAuthenticate, + wwwAuthenticateChallenges, } from "../../src/core/errors.js"; describe("mapOAuthError", () => { @@ -59,6 +63,32 @@ describe("mapOAuthError", () => { expect(consent.code).toBe("interaction_required"); }); + it("maps access_denied (403, cross-client exchange not allowlisted) into AccessDeniedError", () => { + const err = mapOAuthError("token exchange", 403, { + error: "access_denied", + error_description: "client is not allowed to exchange for this resource", + }); + + expect(err).toBeInstanceOf(AccessDeniedError); + expect(err).not.toBeInstanceOf(ConsentRequiredError); + expect(err.code).toBe("access_denied"); + expect(err.statusCode).toBe(403); + expect(err.message).toBe( + "authplane: token exchange: client is not allowed to exchange for this resource", + ); + }); + + it("maps invalid_target (RFC 8707 §2.2) into InvalidTargetError", () => { + const err = mapOAuthError("token exchange", 400, { + error: "invalid_target", + }); + + expect(err).toBeInstanceOf(InvalidTargetError); + expect(err.code).toBe("invalid_target"); + expect(err.statusCode).toBe(400); + expect(err.message).toBe("authplane: token exchange: invalid_target"); + }); + it("falls back to AuthError for unknown 4xx oauth errors", () => { const err = mapOAuthError("token exchange", 400, { error: "unknown_error", @@ -213,17 +243,86 @@ describe("wwwAuthenticate", () => { const header = wwwAuthenticate(new TokenExpired("x"), { scope: [] }); expect(header).not.toContain("scope="); }); + + // An `insufficient_scope` challenge that names no scope tells the client + // it was refused but not what to step up to. + it("falls back to InsufficientScope.requiredScopes when no scope is passed", () => { + const header = wwwAuthenticate( + new InsufficientScope("needs admin", ["tools/admin"]), + ); + expect(header).toContain('scope="tools/admin"'); + expect( + wwwAuthenticateChallenges( + new InsufficientScope("needs admin", ["tools/admin"]), + { schemes: ["Bearer", "DPoP"] }, + ).every((challenge) => challenge.includes('scope="tools/admin"')), + ).toBe(true); + }); + + it("lets an explicit scope win over requiredScopes, empty included", () => { + const error = new InsufficientScope("needs admin", ["tools/admin"]); + expect(wwwAuthenticate(error, { scope: ["tools/read"] })).toContain( + 'scope="tools/read"', + ); + expect(wwwAuthenticate(error, { scope: [] })).not.toContain("scope="); + }); + }); + + // The challenge answers a caller who has not authenticated, so + // `error_description` is chosen by the error code. The SDK's own messages + // name the unknown `kid`, the claim that failed, or the audience the + // resource expects — the last of which is exactly what the caller would + // need in order to request a token for it. + describe("error_description carries no SDK-internal detail", () => { + it.each([ + [ + "invalid_token", + new InvalidClaims("aud mismatch: expected 'https://api.example.com/mcp'"), + "The access token is missing or not valid for this resource", + ], + [ + "insufficient_scope", + new InsufficientScope("token carries [read], route needs tools/admin"), + "The access token does not carry the scope this operation requires", + ], + [ + "invalid_dpop_proof", + new MultipleDPoPProofs("two DPoP headers: proofA, proofB"), + "The DPoP proof is missing or not valid for this request", + ], + ])("%s → a fixed description", (_code, error, description) => { + const header = wwwAuthenticate(error); + expect(header).toContain(`error_description="${description}"`); + expect(header).not.toContain(error.message); + }); + + it("emits no comma inside the description a lenient parser could split on", () => { + const header = wwwAuthenticate(new MultipleDPoPProofs("a, b")); + const description = /error_description="([^"]*)"/.exec(header)?.[1]; + expect(description).not.toContain(","); + }); + + it("verboseDescription restores the exception message (development only)", () => { + const header = wwwAuthenticate( + new InvalidClaims("aud mismatch: expected 'https://api.example.com/mcp'"), + { verboseDescription: true }, + ); + expect(header).toContain( + "error_description=\"aud mismatch: expected 'https://api.example.com/mcp'\"", + ); + }); }); describe("sanitisation (RFC 9110 §11.4) — quoted-string values cannot contain CR/LF/quote/backslash", () => { - it("strips CR/LF/quotes from error.message", () => { + it("strips CR/LF/quotes from a verbose error.message", () => { const header = wwwAuthenticate( new TokenExpired('crafted "value"\r\nInjected: header'), + { verboseDescription: true }, ); expect(header).not.toMatch(/[\r\n]/); expect(header).not.toContain('value"'); // The malicious payload text is preserved (just defanged), so the - // real error description still reaches the client. + // real error description still reaches the operator who opted in. expect(header).toContain("Injected: header"); }); @@ -245,6 +344,135 @@ describe("wwwAuthenticate", () => { }); }); +describe("wwwAuthenticateChallenges", () => { + it("derives the single scheme from the error when schemes is omitted", () => { + const error = new DPoPReplayDetected("jti seen"); + expect(wwwAuthenticateChallenges(error)).toEqual([wwwAuthenticate(error)]); + }); + + // RFC 9449 §7.2: a resource running inbound DPoP in optional mode takes + // both schemes and has to say so, and two challenges cannot be comma-joined + // because the comma also separates parameters inside one. + it("emits one header value per scheme, in the order given", () => { + const challenges = wwwAuthenticateChallenges(new TokenExpired("past exp"), { + schemes: ["Bearer", "DPoP"], + }); + expect(challenges).toHaveLength(2); + expect(challenges[0]?.startsWith("Bearer ")).toBe(true); + expect(challenges[1]?.startsWith("DPoP ")).toBe(true); + }); + + it("canonicalises scheme case and collapses duplicates", () => { + expect( + wwwAuthenticateChallenges(new TokenExpired("x"), { + schemes: ["dpop", " DPOP ", "bearer"], + }).map((challenge) => challenge.split(" ")[0]), + ).toEqual(["DPoP", "Bearer"]); + }); + + it("keeps invalid_dpop_proof off the Bearer challenge that names it alongside", () => { + const [bearer, dpop] = wwwAuthenticateChallenges( + new MultipleDPoPProofs("two DPoP headers"), + { schemes: ["Bearer", "DPoP"] }, + ); + expect(bearer).toContain('error="invalid_token"'); + expect(dpop).toContain('error="invalid_dpop_proof"'); + }); + + it("rejects an unsupported scheme rather than sanitising it into the header", () => { + expect(() => + wwwAuthenticateChallenges(new TokenExpired("x"), { schemes: ["Basic"] }), + ).toThrow(TypeError); + expect(() => + wwwAuthenticateChallenges(new TokenExpired("x"), { schemes: [] }), + ).toThrow(/non-empty/); + }); + + describe("algs (RFC 9449 §7.1)", () => { + it("omits the parameter when algs is not passed", () => { + const [challenge] = wwwAuthenticateChallenges(new TokenExpired("x"), { + schemes: ["DPoP"], + }); + expect(challenge).not.toContain("algs="); + }); + + it("advertises the default set when algs is explicitly undefined", () => { + // `InboundDPoPOptions.allowedProofAlgorithms` is `undefined` on an + // options object built from defaults, and passing it straight through + // has to advertise what the resource accepts, not nothing. + const [challenge] = wwwAuthenticateChallenges(new TokenExpired("x"), { + schemes: ["DPoP"], + algs: undefined, + }); + expect(challenge).toContain('algs="ES256 RS256"'); + }); + + it("advertises the given set, space-separated", () => { + const [challenge] = wwwAuthenticateChallenges(new TokenExpired("x"), { + schemes: ["DPoP"], + algs: ["ES256"], + }); + expect(challenge).toContain('algs="ES256"'); + }); + + it("omits the parameter for an empty array", () => { + const [challenge] = wwwAuthenticateChallenges(new TokenExpired("x"), { + schemes: ["DPoP"], + algs: [], + }); + expect(challenge).not.toContain("algs="); + }); + + it("never rides the Bearer challenge", () => { + const [bearer] = wwwAuthenticateChallenges(new TokenExpired("x"), { + schemes: ["Bearer", "DPoP"], + algs: ["ES256"], + }); + expect(bearer).not.toContain("algs="); + }); + + // Validated, not escaped: escaping lets a comma through, and a comma is + // what a lenient client-side parser splits a joined challenge on. + it("rejects an unsupported algorithm", () => { + expect(() => + wwwAuthenticateChallenges(new TokenExpired("x"), { + schemes: ["DPoP"], + algs: ["ES256,HS256"], + }), + ).toThrow(TypeError); + }); + + it("rejects a bare string that would join one character at a time", () => { + expect(() => + wwwAuthenticateChallenges(new TokenExpired("x"), { + schemes: ["DPoP"], + algs: "ES256" as unknown as readonly string[], + }), + ).toThrow(/not a bare string/); + }); + }); + + it("carries realm, scope and resource_metadata onto every challenge", () => { + for (const challenge of wwwAuthenticateChallenges( + new InsufficientScope("needs admin"), + { + schemes: ["Bearer", "DPoP"], + realm: "mcp", + scope: ["tools/admin"], + resourceMetadataUrl: "https://api.example.com/.well-known/x", + verboseDescription: true, + }, + )) { + expect(challenge).toContain('realm="mcp"'); + expect(challenge).toContain('scope="tools/admin"'); + expect(challenge).toContain( + 'resource_metadata="https://api.example.com/.well-known/x"', + ); + expect(challenge).toContain('error_description="needs admin"'); + } + }); +}); + describe("ConsentRequiredError.describe", () => { it("formats message with serviceId and causeDetail", () => { const err = new ConsentRequiredError("Consent needed", { @@ -271,3 +499,96 @@ describe("ConsentRequiredError.describe", () => { expect(err.describe()).toBe("Consent needed (drive: Consent needed)"); }); }); + +describe("errorResponseBody", () => { + const INVALID_TOKEN = + "The access token is missing or not valid for this resource"; + const INSUFFICIENT_SCOPE = + "The access token does not carry the scope this operation requires"; + const INVALID_DPOP_PROOF = + "The DPoP proof is missing or not valid for this request"; + + it("never carries the exception's own message", () => { + // The body reaches a caller who has not authenticated, and core's + // messages name the failing detail — here the exact audience the + // resource expects, which is the value a caller would need in order to + // go request a token for it. + const body = errorResponseBody( + new InvalidClaims( + "Token 'aud' claim mismatch: expected https://api.example.com/mcp", + ), + ); + + expect(body.error_description).toBe(INVALID_TOKEN); + expect(body.error_description).not.toMatch(/aud|api\.example\.com/); + }); + + it("names the same error code the challenge does, per error type", () => { + expect(errorResponseBody(new TokenExpired()).error).toBe("invalid_token"); + expect(errorResponseBody(new InsufficientScope("x", ["a"])).error).toBe( + "insufficient_scope", + ); + // The two-way branch the adapters used to hand-roll got this one wrong: + // it said `invalid_token` in the body while the challenge above it said + // `invalid_dpop_proof` (RFC 9449 §7.1). + expect(errorResponseBody(new MultipleDPoPProofs()).error).toBe( + "invalid_dpop_proof", + ); + }); + + it("picks the description from the error code", () => { + expect(errorResponseBody(new TokenExpired()).error_description).toBe( + INVALID_TOKEN, + ); + expect( + errorResponseBody(new InsufficientScope("x", ["a"])).error_description, + ).toBe(INSUFFICIENT_SCOPE); + expect( + errorResponseBody(new MultipleDPoPProofs()).error_description, + ).toBe(INVALID_DPOP_PROOF); + }); + + it("emits the same text the challenge emits, for the same error", () => { + // One table, two surfaces: a client reads whichever half it finds, so + // they must not drift. + const error = new InsufficientScope("missing scope", ["tools/delete"]); + const challenge = wwwAuthenticate(error); + const body = errorResponseBody(error); + + expect(challenge).toContain( + `error_description="${body.error_description}"`, + ); + expect(challenge).toContain(`error="${body.error}"`); + }); + + it("honours an explicit scheme when a multi-scheme set is emitted", () => { + // `invalid_dpop_proof` is defined for the DPoP scheme, so the Bearer + // half of a combined challenge keeps `invalid_token` — and a body + // pinned to that half has to say the same. + const error = new MultipleDPoPProofs(); + + expect(errorResponseBody(error, { scheme: "Bearer" }).error).toBe( + "invalid_token", + ); + expect(errorResponseBody(error, { scheme: "DPoP" }).error).toBe( + "invalid_dpop_proof", + ); + }); + + it("restores the message under verboseDescription, unsanitised", () => { + // The development escape hatch. Unlike the header path, the body needs + // no sanitising: JSON escaping already handles the quotes and the CRLF + // that would break a quoted-string. + const body = errorResponseBody( + new TokenExpired('Token has expired: "exp" claim\r\ncheck'), + { verboseDescription: true }, + ); + + expect(body.error_description).toBe( + 'Token has expired: "exp" claim\r\ncheck', + ); + expect(JSON.parse(JSON.stringify(body)).error_description).toBe( + 'Token has expired: "exp" claim\r\ncheck', + ); + }); +}); diff --git a/packages/sdk/tests/core/metadataRefreshOnVerify.test.ts b/packages/sdk/tests/core/metadataRefreshOnVerify.test.ts new file mode 100644 index 0000000..e2d6587 --- /dev/null +++ b/packages/sdk/tests/core/metadataRefreshOnVerify.test.ts @@ -0,0 +1,583 @@ +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; + +import { + exportJWK, + generateKeyPair, + SignJWT, + type JWK, + type KeyLike, +} from "jose"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; + +import { AuthplaneClient } from "../../src/core/index.js"; + +/** + * A verify-only resource server never calls an AS-facing operation, so the + * verification path is the only thing that can keep AS metadata current. These + * tests drive nothing but `verify()`: no forced refresh, no private state, no + * test-only hook. If metadata is only ever read once at construction, the + * rotation test below cannot pass — the token is signed by a key that is + * published at the new `jwks_uri` and nowhere else. + */ + +interface Keypair { + kid: string; + privateKey: KeyLike; + jwks: { keys: JWK[] }; +} + +async function generateKeypair(kid: string): Promise { + const { privateKey, publicKey } = await generateKeyPair("RS256"); + const jwk = (await exportJWK(publicKey)) as JWK; + jwk.kid = kid; + jwk.alg = "RS256"; + jwk.use = "sig"; + return { kid, privateKey, jwks: { keys: [jwk] } }; +} + +const METADATA_PATH = "/.well-known/oauth-authorization-server"; +const JWKS_V1_PATH = "/jwks-v1.json"; +const JWKS_V2_PATH = "/jwks-v2.json"; + +interface RotatingAuthServer { + server: Server; + origin: string; + v1: Keypair; + v2: Keypair; + /** Every path the SDK requested, in order. */ + requests: string[]; + count(path: string): number; + /** Publish `jwks_uri` as v2 and withdraw the v1 document. */ + rotateJwksUri(): void; +} + +/** + * Delay applied to the v2 JWKS response. Widening that round trip is what makes + * the stale-while-revalidate race observable: the background metadata refresh + * commits the rotated document while whatever depends on it is still in flight. + */ +async function startRotatingAuthServer( + jwksV2DelayMs = 0, + metadataDelayMs = 0, +): Promise { + const v1 = await generateKeypair("key-v1"); + const v2 = await generateKeypair("key-v2"); + + let publishedJwksPath = JWKS_V1_PATH; + let v1Withdrawn = false; + const requests: string[] = []; + + const server = createServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + + server.on("request", (req, res) => { + const url = req.url ?? ""; + requests.push(url); + res.setHeader("content-type", "application/json"); + + if (url === METADATA_PATH) { + const body = JSON.stringify({ + issuer: origin, + jwks_uri: `${origin}${publishedJwksPath}`, + }); + if (metadataDelayMs > 0) { + setTimeout(() => res.end(body), metadataDelayMs); + return; + } + res.end(body); + return; + } + if (url === JWKS_V1_PATH && !v1Withdrawn) { + res.end(JSON.stringify(v1.jwks)); + return; + } + if (url === JWKS_V2_PATH) { + if (jwksV2DelayMs > 0) { + setTimeout(() => res.end(JSON.stringify(v2.jwks)), jwksV2DelayMs); + return; + } + res.end(JSON.stringify(v2.jwks)); + return; + } + + // A withdrawn JWKS URI is gone, not merely stale. Answering it would let a + // failure to follow the rotation pass unnoticed. + res.statusCode = url === JWKS_V1_PATH ? 410 : 404; + res.end(); + }); + + return { + server, + origin, + v1, + v2, + requests, + count: (path) => requests.filter((r) => r === path).length, + rotateJwksUri: () => { + publishedJwksPath = JWKS_V2_PATH; + v1Withdrawn = true; + }, + }; +} + +async function mintToken(options: { + keypair: Keypair; + issuer: string; + audience: string; +}): Promise { + const now = Math.floor(Date.now() / 1000); + return await new SignJWT({ + client_id: "client_1", + scope: "read:data", + jti: `jti_${Math.random().toString(36).slice(2)}`, + }) + .setProtectedHeader({ + alg: "RS256", + typ: "at+jwt", + kid: options.keypair.kid, + }) + .setSubject("user_1") + .setIssuer(options.issuer) + .setAudience(options.audience) + .setIssuedAt(now) + .setExpirationTime(now + 300) + .sign(options.keypair.privateKey); +} + +/** The configured metadata refresh interval, and a wait that outlasts it. */ +const METADATA_REFRESH_SECONDS = 2; +/** + * The SWR test needs a TTL at which the window it is named after exists. + * `cacheTimeSeconds` and `now` are whole seconds, and the predicate trips at + * `ttl * 0.8`, so at a TTL of 2 the age is 0 or 1 while the threshold is 1.6 — + * the branch is unreachable and the test lands on the expired path instead. + * At 10 the window is 8 s to 10 s, comfortably resolvable at second granularity. + */ +const SWR_METADATA_REFRESH_SECONDS = 10; +const sleepPastRefreshInterval = (): Promise => + new Promise((resolve) => + setTimeout(resolve, METADATA_REFRESH_SECONDS * 1000 + 100), + ); + +describe("AS metadata refresh on the verification path", () => { + let as: RotatingAuthServer; + + beforeAll(async () => { + as = await startRotatingAuthServer(); + }); + + afterAll(async () => { + await new Promise((resolve, reject) => + as.server.close((err) => (err ? reject(err) : resolve())), + ); + }); + + it("follows a rotated jwks_uri driven only by verify() traffic", async () => { + const client = await AuthplaneClient.create({ + issuer: as.origin, + devMode: true, + metadataRefreshSeconds: METADATA_REFRESH_SECONDS, + jwksRefreshSeconds: 300, + }); + const resource = client.resource({ + resource: `${as.origin}/api`, + scopes: ["read:data"], + }); + + try { + // Baseline: the resource verifies against the originally published URI. + const beforeRotation = await resource.verify( + await mintToken({ + keypair: as.v1, + issuer: as.origin, + audience: `${as.origin}/api`, + }), + ); + expect(beforeRotation.sub).toBe("user_1"); + + // Inside the refresh interval, verification is not paying for a refetch. + expect(as.count(METADATA_PATH)).toBe(1); + expect(as.count(JWKS_V1_PATH)).toBe(1); + + as.rotateJwksUri(); + const v1RequestsAtRotation = as.count(JWKS_V1_PATH); + const requestsAtRotation = as.requests.length; + + // Nothing but the passage of time and ordinary traffic from here on. + await sleepPastRefreshInterval(); + + const token = await mintToken({ + keypair: as.v2, + issuer: as.origin, + audience: `${as.origin}/api`, + }); + const claims = await resource.verify(token); + expect(claims.sub).toBe("user_1"); + expect(claims.kid).toBe("key-v2"); + + // Two metadata reads land here: the refresh interval elapsed, so verify() + // re-read the document (2); the `kid` miss then forced a JWKS refetch, + // which re-reads metadata rather than resolving the URI from a cached + // document that may still name the withdrawn one (3). That second read is + // what makes a rotation observed inside the stale-while-revalidate window + // land, instead of only one observed after full expiry. + expect(as.count(METADATA_PATH)).toBe(3); + expect(as.count(JWKS_V2_PATH)).toBe(1); + + // The withdrawn URI was not touched again — not even as a fallback on the + // request that first observed the rotation. + expect(as.count(JWKS_V1_PATH)).toBe(v1RequestsAtRotation); + expect(as.requests.slice(requestsAtRotation)).not.toContain(JWKS_V1_PATH); + + // Further traffic inside the new interval resolves the new URI from the + // cached document without refetching either. + const later = await resource.verify( + await mintToken({ + keypair: as.v2, + issuer: as.origin, + audience: `${as.origin}/api`, + }), + ); + expect(later.kid).toBe("key-v2"); + expect(as.count(METADATA_PATH)).toBe(3); + expect(as.count(JWKS_V2_PATH)).toBe(1); + expect(as.count(JWKS_V1_PATH)).toBe(v1RequestsAtRotation); + } finally { + await client.close(); + } + }); + + it("follows a rotation observed inside the stale-while-revalidate window", async () => { + // `shouldRefreshInBackground` trips at 80% of TTL, so under steady traffic + // this window opens long before the document expires and is the branch a + // continuously-served resource server reaches *first*. A cache hit here + // returns immediately, so the JWKS fetch that follows must not be allowed to + // resolve its URI from the document being revalidated. + const JWKS_V2_ROUND_TRIP_MS = 300; + const METADATA_ROUND_TRIP_MS = 400; + const swrAs = await startRotatingAuthServer( + JWKS_V2_ROUND_TRIP_MS, + METADATA_ROUND_TRIP_MS, + ); + const client = await AuthplaneClient.create({ + issuer: swrAs.origin, + devMode: true, + metadataRefreshSeconds: SWR_METADATA_REFRESH_SECONDS, + jwksRefreshSeconds: 300, + }); + const resource = client.resource({ + resource: `${swrAs.origin}/api`, + scopes: ["read:data"], + }); + + // Real time still has to pass for the HTTP round trips, so the clock is + // offset rather than frozen: the cache reads `Date.now()` for both the age + // and the commit stamp, and an 8.5 s offset puts it inside the window + // without an 8.5 s sleep. + const realNow = Date.now.bind(Date); + const bootedAt = realNow(); + let clockOffsetMs = 0; + const nowSpy = vi + .spyOn(Date, "now") + .mockImplementation(() => realNow() + clockOffsetMs); + + try { + const beforeRotation = await resource.verify( + await mintToken({ + keypair: swrAs.v1, + issuer: swrAs.origin, + audience: `${swrAs.origin}/api`, + }), + ); + expect(beforeRotation.kid).toBe("key-v1"); + + swrAs.rotateJwksUri(); + const v1RequestsAtRotation = swrAs.count(JWKS_V1_PATH); + const requestsAtRotation = swrAs.requests.length; + + // Land strictly inside the SWR window: past 80% of the TTL, before expiry. + // Computed at the point of use rather than as a fixed offset. The cache + // stamped `cacheTimeSeconds` from the real clock during `create()`, before + // the spy existed, so a constant would drift by however long everything + // since then took — an RSA sign, a JWKS round trip, a second mint — and the + // window is only two whole seconds wide. Off the end of it the test silently + // goes back to exercising the expired path. + const elapsedSinceBoot = realNow() - bootedAt; + clockOffsetMs = + SWR_METADATA_REFRESH_SECONDS * 1000 * 0.85 - elapsedSinceBoot; + const metadataRequestsBeforeWindow = swrAs.count(METADATA_PATH); + + // Ordinary traffic on a still-valid token. This is the cache hit that + // starts the background refresh; the v1 keys are still cached, so it + // succeeds without touching the network. + const startedDuringWindow = realNow(); + const duringWindow = await resource.verify( + await mintToken({ + keypair: swrAs.v1, + issuer: swrAs.origin, + audience: `${swrAs.origin}/api`, + }), + ); + const duringWindowMs = realNow() - startedDuringWindow; + expect(duringWindow.kid).toBe("key-v1"); + + // The assertion that tells the two branches apart. The metadata endpoint + // answers in `METADATA_ROUND_TRIP_MS`; on the SWR path that fetch runs + // *beside* the verify, which returns off the cached document, so the verify + // finishes well inside it. On the expired path the verify awaits the fetch + // and cannot finish before it. A fetch count cannot discriminate here — the + // expired path issues exactly one metadata request too, which is how the + // round-4 version of this test passed while never entering the window. + expect(duringWindowMs).toBeLessThan(METADATA_ROUND_TRIP_MS / 2); + + // Long enough for the background metadata refresh to commit the rotated + // document, short enough that anything it depends on is still in flight. + await new Promise((resolve) => + setTimeout(resolve, JWKS_V2_ROUND_TRIP_MS / 4), + ); + + // And the refresh did run: a cache hit that does not trip + // `shouldRefreshInBackground` issues no request at all. Necessary but not + // sufficient on its own — see the latency assertion above for the half that + // discriminates. + expect(swrAs.count(METADATA_PATH)).toBe(metadataRequestsBeforeWindow + 1); + + const claims = await resource.verify( + await mintToken({ + keypair: swrAs.v2, + issuer: swrAs.origin, + audience: `${swrAs.origin}/api`, + }), + ); + expect(claims.sub).toBe("user_1"); + expect(claims.kid).toBe("key-v2"); + + // The withdrawn URI was never requested again, and both tokens verified. + // Before this change, a rotation observed in this window left the cached + // document and the key source disagreeing for as long as the JWKS round + // trip lasted, and verification inside that gap failed outright — the + // document said one thing about where keys live and `jwksCache` another. + expect(swrAs.count(JWKS_V1_PATH)).toBe(v1RequestsAtRotation); + expect(swrAs.requests.slice(requestsAtRotation)).not.toContain( + JWKS_V1_PATH, + ); + expect(swrAs.count(JWKS_V2_PATH)).toBe(1); + } finally { + nowSpy.mockRestore(); + await client.close(); + await new Promise((resolve, reject) => + swrAs.server.close((err) => (err ? reject(err) : resolve())), + ); + } + }); + + it("does not repoint key retrieval from a metadata document that fails validation", async () => { + // The document is rejected; the key source it names must not survive it. + // Putting the metadata read on the verification path is what makes this + // reachable on every request for a verify-only resource server, so the + // rejection has to happen before the document is committed — validating on + // the way out would leave a rejected document deciding where keys come from, + // and a token minted by the key it names would then verify. + const rogue = await generateKeypair("key-rogue"); + const ROGUE_JWKS_PATH = "/jwks-rogue.json"; + let serveRogue = false; + + const honest = await generateKeypair("key-honest"); + const server = createServer(); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + + server.on("request", (req, res) => { + const url = req.url ?? ""; + res.setHeader("content-type", "application/json"); + if (url === METADATA_PATH) { + res.end( + JSON.stringify( + serveRogue + ? // RFC 8414 §3.3: the issuer is not ours, so this document is not + // about this authorization server at all. + { + issuer: "http://127.0.0.1:1/elsewhere", + jwks_uri: `${origin}${ROGUE_JWKS_PATH}`, + } + : { issuer: origin, jwks_uri: `${origin}${JWKS_V1_PATH}` }, + ), + ); + return; + } + if (url === JWKS_V1_PATH) { + res.end(JSON.stringify(honest.jwks)); + return; + } + if (url === ROGUE_JWKS_PATH) { + res.end(JSON.stringify(rogue.jwks)); + return; + } + res.statusCode = 404; + res.end(); + }); + + const client = await AuthplaneClient.create({ + issuer: origin, + devMode: true, + metadataRefreshSeconds: METADATA_REFRESH_SECONDS, + jwksRefreshSeconds: 300, + }); + const resource = client.resource({ + resource: `${origin}/api`, + scopes: ["read:data"], + }); + + try { + const good = await resource.verify( + await mintToken({ + keypair: honest, + issuer: origin, + audience: `${origin}/api`, + }), + ); + expect(good.kid).toBe("key-honest"); + + serveRogue = true; + await sleepPastRefreshInterval(); + + // Minted by the key the rejected document points at, but carrying the + // real issuer — the shape a client would present after the AS metadata + // endpoint is compromised. + const forged = await mintToken({ + keypair: rogue, + issuer: origin, + audience: `${origin}/api`, + }); + await expect(resource.verify(forged)).rejects.toThrow(); + + // And the honest key still verifies: rejecting the document left the + // previous one in place rather than emptying the cache. + const stillGood = await resource.verify( + await mintToken({ + keypair: honest, + issuer: origin, + audience: `${origin}/api`, + }), + ); + expect(stillGood.kid).toBe("key-honest"); + } finally { + await client.close(); + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ); + } + }); + + it("pays for an unreachable metadata endpoint once per floor, not once per verification", async () => { + // Once `metadataRefreshSeconds` elapses against a down metadata endpoint, + // every verification would otherwise start a fresh fetch and stall on it — + // an AS outage amplified into per-request latency on the resource server. + // The retry floor caps that at one attempt per + // `max(1, min(fetchFailureBackoffSeconds, metadataRefreshSeconds))`; + // verifications in between are served entirely from the cached documents. + const keypair = await generateKeypair("key-v1"); + let metadataDown = false; + let metadataRequests = 0; + + const server = createServer(); + await new Promise((resolve) => + server.listen(0, "127.0.0.1", resolve), + ); + const origin = `http://127.0.0.1:${(server.address() as AddressInfo).port}`; + + server.on("request", (req, res) => { + const url = req.url ?? ""; + if (url === METADATA_PATH) { + metadataRequests += 1; + if (metadataDown) { + res.statusCode = 503; + res.end(); + return; + } + res.setHeader("content-type", "application/json"); + res.end( + JSON.stringify({ + issuer: origin, + jwks_uri: `${origin}${JWKS_V1_PATH}`, + }), + ); + return; + } + if (url === JWKS_V1_PATH) { + res.setHeader("content-type", "application/json"); + res.end(JSON.stringify(keypair.jwks)); + return; + } + res.statusCode = 404; + res.end(); + }); + + const client = await AuthplaneClient.create({ + issuer: origin, + devMode: true, + metadataRefreshSeconds: METADATA_REFRESH_SECONDS, + jwksRefreshSeconds: 300, + }); + const resource = client.resource({ + resource: `${origin}/api`, + scopes: ["read:data"], + }); + const verifyOne = async () => { + const claims = await resource.verify( + await mintToken({ + keypair, + issuer: origin, + audience: `${origin}/api`, + }), + ); + expect(claims.kid).toBe("key-v1"); + }; + + try { + await verifyOne(); + expect(metadataRequests).toBe(1); + + metadataDown = true; + await sleepPastRefreshInterval(); + + // The first verification past the interval pays for the failed attempt, + // and still succeeds on the cached keys. + await verifyOne(); + expect(metadataRequests).toBe(2); + + // Immediate traffic behind it touches nothing on the network: the floor + // is open, so these are pure cache reads. + await verifyOne(); + await verifyOne(); + expect(metadataRequests).toBe(2); + + // Past the floor — min(30, metadataRefreshSeconds) — exactly one more + // attempt is admitted for the next wave. + await sleepPastRefreshInterval(); + await verifyOne(); + await verifyOne(); + expect(metadataRequests).toBe(3); + + // The endpoint comes back: the next admitted attempt succeeds, closes + // the floor, and refresh behaviour is back to interval-driven. + metadataDown = false; + await sleepPastRefreshInterval(); + await verifyOne(); + expect(metadataRequests).toBe(4); + await verifyOne(); + expect(metadataRequests).toBe(4); + } finally { + await client.close(); + await new Promise((resolve, reject) => + server.close((err) => (err ? reject(err) : resolve())), + ); + } + // Three real refresh intervals have to pass: one to reach the failed + // attempt, one to outlast the floor, one to observe the recovery. + }, 20_000); +}); diff --git a/packages/sdk/tests/core/prm.test.ts b/packages/sdk/tests/core/prm.test.ts index 463522e..edabfcf 100644 --- a/packages/sdk/tests/core/prm.test.ts +++ b/packages/sdk/tests/core/prm.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { buildPrm } from "../../src/core/index.js"; +import { buildPrm, validateIssuerIdentifier } from "../../src/core/index.js"; describe("buildPrm", () => { it("rfc9728-prm-must-contain-required-fields — builds RFC9728-like metadata shape", () => { @@ -19,4 +19,309 @@ describe("buildPrm", () => { ]); expect(prm.scopes_supported).toEqual(["tools/query", "tools/write"]); }); + + // `buildPrm` is exported and documented as the standalone way to serve the + // PRM document, so it is a public boundary and not merely an internal helper + // reached through a gated caller. An identifier it accepted but the document + // URL derivation reshapes produces the RFC 9728 §3.3 mismatch a conformant + // client responds to by discarding the document — which surfaces as an + // unreachable resource server, not as a configuration error. + it("rejects a resource identifier carrying a fragment", () => { + expect(() => + buildPrm("https://auth.example.com", "https://api.example.com/mcp#frag", [ + "read", + ]), + ).toThrow(TypeError); + expect(() => + buildPrm("https://auth.example.com", "https://api.example.com/mcp#frag", [ + "read", + ]), + ).toThrow(/must not contain a fragment component/); + }); + + it("rejects a resource identifier that is not an absolute URL", () => { + for (const resource of [ + "/mcp", + "//api.example.com/mcp", + "https:api.example.com/mcp", + "urn:example:api", + ]) { + expect(() => + buildPrm("https://auth.example.com", resource, ["read"]), + ).toThrow(/must be an absolute URL with a scheme and a host/); + } + }); + + it("rejects a resource identifier whose query is not a valid RFC 3986 query", () => { + expect(() => + buildPrm( + "https://auth.example.com", + "https://api.example.com/mcp?a=%zz", + ["read"], + ), + ).toThrow(/query must be a valid RFC 3986/); + }); + + it("accepts the identifier shapes the derivation preserves byte-for-byte", () => { + expect( + buildPrm("https://auth.example.com", "https://api.example.com/mcp?v=2", [ + "read", + ]).resource, + ).toBe("https://api.example.com/mcp?v=2"); + // The profile deliberately admits http for local development, and any + // scheme that carries a host. + expect( + buildPrm("https://auth.example.com", "http://localhost:8080/mcp", [ + "read", + ]).resource, + ).toBe("http://localhost:8080/mcp"); + }); + // The issuer is the other URL-shaped member of the document `buildPrm` + // serves, so the builder runs the gate on it too. The axes themselves are + // pinned against `validateIssuerIdentifier` directly, below; this is the + // wiring. + it("runs the issuer gate", () => { + expect(() => + buildPrm( + "https://svc:s3cr3t@auth.example.com", + "https://api.example.com/mcp", + ["read"], + ), + ).toThrow(/must not include a userinfo component/u); + }); + + it("accepts the issuer shapes the derivation preserves", () => { + expect( + buildPrm( + "https://auth.example.com/tenant-a", + "https://api.example.com/mcp", + ["read"], + ).authorization_servers, + ).toEqual(["https://auth.example.com/tenant-a"]); + // A trailing slash is an identity difference, not a defect — the gate + // must not reject it, and must not normalise it away either. + expect( + buildPrm("https://auth.example.com/", "https://api.example.com/mcp", [ + "read", + ]).authorization_servers, + ).toEqual(["https://auth.example.com/"]); + }); +}); + +/** + * All five axes of the issuer gate, pinned against `validateIssuerIdentifier` + * itself rather than through `buildPrm`. + * + * The resource-side axes are pinned the same way in `resourceIndicator.test.ts`; + * these used to sit under `describe("buildPrm")`, which gave equivalent coverage + * but left the two halves of the same module tested at different levels. Wiring + * — that `buildPrm`, `buildMetadataUrl` and `AuthplaneClient.create` all reach + * this gate — stays pinned where each of those lives. + */ +describe("validateIssuerIdentifier query and fragment (RFC 8414 §2)", () => { + it("rejects a query or a fragment, including the bare delimiters", () => { + for (const issuer of [ + "https://auth.example.com?x=1", + "https://auth.example.com#frag", + "https://auth.example.com?", + "https://auth.example.com#", + ]) { + expect(() => validateIssuerIdentifier(issuer)).toThrow(TypeError); + expect(() => validateIssuerIdentifier(issuer)).toThrow( + /must not contain a query or fragment component \(RFC 8414 §2\)/u, + ); + } + }); + + it("does not echo a credential-shaped query", () => { + const issuer = "https://auth.example.com?token=s3cr3t"; + expect(() => validateIssuerIdentifier(issuer)).toThrow( + "https://auth.example.com", + ); + expect(() => validateIssuerIdentifier(issuer)).not.toThrow("s3cr3t"); + }); +}); + +describe("validateIssuerIdentifier whitespace and control characters (RFC 3986 §2)", () => { + it("rejects whitespace the WHATWG parser would trim or strip away", () => { + // Every one of these parsed with a non-empty host before the gate: the + // parser trims leading and trailing C0-or-space and removes tab, CR and + // LF anywhere in the input. The issuer is published verbatim in + // `authorization_servers` and stored byte-for-byte as the expected `iss`, + // while the `.well-known` location is derived from the cleaned parse. + for (const issuer of [ + "https://auth.example.com\n", + "\nhttps://auth.example.com", + "https://auth.example.com ", + "https://auth.exa\tmple.com", + "https://auth.example.com/ten ant", + ]) { + expect(() => validateIssuerIdentifier(issuer)).toThrow(TypeError); + expect(() => validateIssuerIdentifier(issuer)).toThrow( + /must not contain whitespace or control characters/u, + ); + } + }); + + it("rejects the boundary codepoints of the class", () => { + // The three ranges the class is built from: C0 and space, DEL and the C1 + // controls, and what Unicode `\s` adds on top. The last two are the ones + // `JSON.stringify` alone would emit raw. + for (const char of [ + "\u0000", + "\u001f", + "\u007f", + "\u009f", + "\u00a0", + "\u2028", + "\u3000", + "\ufeff", + ]) { + expect(() => + validateIssuerIdentifier(`https://auth.example.com/${char}t`), + ).toThrow(/must not contain whitespace or control characters/u); + } + }); + + it("names the offending codepoint and its offset", () => { + // A tab keeps `JSON.stringify`'s own short escape; only the codepoints it + // has no escape for are rewritten to `\uXXXX`. + expect(() => + validateIssuerIdentifier("https://auth.exa\tmple.com"), + ).toThrow('invalid character "\\t" at offset 16'); + expect(() => + validateIssuerIdentifier("https://auth.example.com/\u00a0t"), + ).toThrow('invalid character "\\u00a0" at offset 25'); + }); + + it("renders the offending codepoint as an escape, never as the raw byte", () => { + // `JSON.stringify` is not enough on its own: it emits DEL, the C1 + // controls, U+00A0, U+2028, U+3000 and U+FEFF raw, so the message would + // carry into a startup log exactly the invisible byte it is reporting. + for (const char of ["\u007f", "\u009f", "\u00a0", "\u2028", "\u3000", "\ufeff"]) { + const message = String( + (() => { + try { + validateIssuerIdentifier(`https://auth.example.com/${char}`); + } catch (error) { + return (error as Error).message; + } + return "did not throw"; + })(), + ); + expect(message).toContain("must not contain whitespace or control"); + expect(message).not.toContain(char); + } + }); + + it("accepts an issuer with no whitespace at all", () => { + expect(() => + validateIssuerIdentifier("https://auth.example.com/tenant-a"), + ).not.toThrow(); + }); +}); + +describe("validateIssuerIdentifier absolute-URL requirement (RFC 8414 §2, §3.1)", () => { + it("rejects a relative, empty, opaque or authority-less issuer", () => { + for (const issuer of [ + "/auth", + "", + "//auth.example.com", + "urn:example:as", + "https:auth.example.com", + "https:/auth.example.com", + ]) { + expect(() => validateIssuerIdentifier(issuer)).toThrow(TypeError); + expect(() => validateIssuerIdentifier(issuer)).toThrow( + /must be an absolute URL with a scheme and a host \(RFC 8414 §2, §3\.1\)/u, + ); + } + }); + + it("rejects an invalid port at the parse, with no port check of its own", () => { + // The gate carries no port branch of its own: `new URL` throws for a + // port that is not a decimal number in range, so `parsed` stays undefined + // and the absoluteness branch reports it. Pinned rather than duplicated as + // a redundant check — if the platform ever starts accepting these, this + // test is what fails. + for (const issuer of [ + "https://auth.example.com:80O", + "https://auth.example.com:99999", + "https://auth.example.com:-1", + ]) { + expect(() => validateIssuerIdentifier(issuer)).toThrow( + /must be an absolute URL with a scheme and a host/u, + ); + } + }); + + it("accepts http and a non-special scheme with a host", () => { + // The same deliberate profile relaxation the resource gate makes. + expect(() => + validateIssuerIdentifier("http://localhost:8080"), + ).not.toThrow(); + expect(() => + validateIssuerIdentifier("https://auth.example.com:8443/tenant-a"), + ).not.toThrow(); + }); +}); + +describe("validateIssuerIdentifier userinfo requirement (RFC 9110 §4.2.4)", () => { + it("rejects a userinfo component and does not echo the credential", () => { + const issuer = "https://svc:s3cr3t@auth.example.com"; + expect(() => validateIssuerIdentifier(issuer)).toThrow( + /must not include a userinfo component in its authority \(RFC 9110 §4\.2\.4\)/u, + ); + expect(() => validateIssuerIdentifier(issuer)).toThrow("auth.example.com"); + expect(() => validateIssuerIdentifier(issuer)).not.toThrow("s3cr3t"); + }); + + it("rejects a username-only and an empty userinfo", () => { + for (const issuer of [ + "https://svc@auth.example.com", + "https://@auth.example.com", + ]) { + expect(() => validateIssuerIdentifier(issuer)).toThrow( + /must not include a userinfo component/u, + ); + } + }); +}); + +describe("validateIssuerIdentifier message ordering and quoting", () => { + it("reports the query or fragment ahead of the whitespace", () => { + // Ordering pin. It is also what makes the quoting below load-bearing: + // the first axis to fire is the one that has to render a raw control + // character the whitespace axis has not reached yet. + expect(() => + validateIssuerIdentifier("\nhttps://auth.example.com#frag"), + ).toThrow(/must not contain a query or fragment component/u); + }); + + it("never lets a raw control character reach the message, on any axis", () => { + // The demonstrated leak: a leading LF fails the anchored + // `SCHEME_AND_AUTHORITY` test the redaction needs to take its parsed + // branch, so the fallback hands back the raw prefix — and each of these + // four axes embedded that prefix unquoted. + for (const issuer of [ + // Fragment, reported ahead of the whitespace axis. + "\nhttps://auth.example.com#frag", + // Absoluteness. + "\u0000https:auth.example.com", + // Userinfo, reached only once the string parses — the C1 control + // survives the parse rather than being trimmed. + "https://svc@auth.example.com/\u0085t", + ]) { + let message = "did not throw"; + try { + validateIssuerIdentifier(issuer); + } catch (error) { + message = (error as Error).message; + } + expect(message).not.toBe("did not throw"); + for (const char of ["\n", "\u0000", "\u0085"]) { + expect(message).not.toContain(char); + } + } + }); }); diff --git a/packages/sdk/tests/core/prmDocumentUrl.test.ts b/packages/sdk/tests/core/prmDocumentUrl.test.ts index b1bb4eb..e2f78a6 100644 --- a/packages/sdk/tests/core/prmDocumentUrl.test.ts +++ b/packages/sdk/tests/core/prmDocumentUrl.test.ts @@ -42,6 +42,140 @@ describe("oauthProtectedResourceMetadataDocumentUrl (RFC 9728 §3.1)", () => { oauthProtectedResourceMetadataDocumentUrl("not a url"), ).toThrow(TypeError); }); + + it("derives from protocol + host for a non-special scheme — never the literal 'null'", () => { + // WHATWG `URL.origin` is the string "null" for any scheme outside its + // special set, while the gate deliberately admits any scheme with a + // host. The derivation must therefore anchor on `protocol` + `host`, + // or the advertised URL would begin with `null/`. + expect( + oauthProtectedResourceMetadataDocumentUrl("mcp://api.example.com/mcp"), + ).toBe("mcp://api.example.com/.well-known/oauth-protected-resource/mcp"); + expect( + oauthProtectedResourceMetadataPath("mcp://api.example.com/mcp"), + ).toBe("/.well-known/oauth-protected-resource/mcp"); + }); + + it("derives distinct URLs for //mcp and /mcp — the path trim is trailing-only", () => { + // Regression pin: `resourceMetadataSuffix` strips trailing slashes + // only. RFC 9728 §3.1 speaks solely of the terminating slash, so + // `https://api.example.com//mcp` is a different identifier from + // `https://api.example.com/mcp` and must derive a different document + // URL. A future tidy-up to a leading-and-trailing trim + // (`/^\/+|\/+$/g`) would silently merge them. + expect( + oauthProtectedResourceMetadataDocumentUrl("https://api.example.com//mcp"), + ).toBe("https://api.example.com/.well-known/oauth-protected-resource//mcp"); + expect( + oauthProtectedResourceMetadataDocumentUrl("https://api.example.com//mcp"), + ).not.toBe( + oauthProtectedResourceMetadataDocumentUrl("https://api.example.com/mcp"), + ); + }); +}); + +describe("oauthProtectedResourceMetadataDocumentUrl query preservation (RFC 9728 §3)", () => { + it("carries the resource query into the document URL, after the path", () => { + // RFC 9728 §3: the well-known string is inserted "between the host + // component and the path and/or query components, if any" — the query + // is part of the identifier and survives the insertion. + expect( + oauthProtectedResourceMetadataDocumentUrl( + "https://api.example.com/mcp?tenant=a", + ), + ).toBe( + "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a", + ); + }); + + it("appends a query directly after the well-known suffix when there is no path", () => { + expect( + oauthProtectedResourceMetadataDocumentUrl("https://api.example.com?x=1"), + ).toBe("https://api.example.com/.well-known/oauth-protected-resource?x=1"); + }); + + it("removes the terminating slash following the host when a query is present (RFC 9728 §3.1)", () => { + expect( + oauthProtectedResourceMetadataDocumentUrl("https://api.example.com/?x=1"), + ).toBe("https://api.example.com/.well-known/oauth-protected-resource?x=1"); + }); + + it("derives distinct URLs for identifiers differing only by query", () => { + const a = oauthProtectedResourceMetadataDocumentUrl( + "https://api.example.com/mcp?tenant=a", + ); + const b = oauthProtectedResourceMetadataDocumentUrl( + "https://api.example.com/mcp?tenant=b", + ); + expect(a).not.toBe(b); + }); + + it("still strips a trailing path slash ahead of the query", () => { + expect( + oauthProtectedResourceMetadataDocumentUrl( + "https://api.example.com/mcp/?tenant=a", + ), + ).toBe( + "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a", + ); + }); + + it("treats a bare ? as no query", () => { + // A trailing bare `?` carries no query bytes, so the derived document + // URL is the query-less one. Pinned as documented behaviour: the + // sibling issuer gate rejects a bare `?` (RFC 8414 §2), and this + // asymmetry is deliberate — a bare `?` is not a fragment and carries + // no bytes that could corrupt the challenge. + expect( + oauthProtectedResourceMetadataDocumentUrl("https://api.example.com/mcp?"), + ).toBe("https://api.example.com/.well-known/oauth-protected-resource/mcp"); + }); + + it("carries a legal sub-delims query into the document URL byte-for-byte", () => { + expect( + oauthProtectedResourceMetadataDocumentUrl( + "https://api.example.com/mcp?a=b&c=(d)!$*+,;=:@/?x", + ), + ).toBe( + "https://api.example.com/.well-known/oauth-protected-resource/mcp?a=b&c=(d)!$*+,;=:@/?x", + ); + }); + + it("derives an accepted query without WHATWG re-encoding — ' stays '", () => { + // `'` is a legal sub-delim, but WHATWG's special-scheme query + // encode-set rewrites it to `%27` in `URL.search`. The derivation + // splices the raw configured query, so the configured, served and + // advertised identifiers are the same bytes. + expect( + oauthProtectedResourceMetadataDocumentUrl( + "https://api.example.com/mcp?a='b", + ), + ).toBe( + "https://api.example.com/.well-known/oauth-protected-resource/mcp?a='b", + ); + }); + + it("rejects a query byte outside RFC 3986 §3.4 instead of corrupting the challenge", () => { + // A raw `\` is out of the §3.4 grammar, and the header sanitiser + // would blank it to a space inside the quoted-string + // `resource_metadata` value — an advertised URL that no longer + // round-trips to the configured identifier. Construction-time gate. + expect(() => + oauthProtectedResourceMetadataDocumentUrl( + "https://api.example.com/mcp?path=a\\b", + ), + ).toThrow(/RFC 3986 §3\.4/u); + }); + + it("rejects a fragment ahead of any query handling (RFC 8707 §2)", () => { + // Ordering pin: the fragment gate runs before derivation looks at the + // query, so a query-and-fragment identifier reports the fragment. + expect(() => + oauthProtectedResourceMetadataDocumentUrl( + "https://api.example.com/mcp?tenant=a#frag", + ), + ).toThrow(/RFC 8707 §2/u); + }); }); describe("oauthProtectedResourceMetadataPath (RFC 9728 §3.1, path only)", () => { @@ -80,4 +214,14 @@ describe("oauthProtectedResourceMetadataPath (RFC 9728 §3.1, path only)", () => TypeError, ); }); + + it("excludes the resource query — routing stays path-keyed", () => { + // The path helper feeds route registration, and routes cannot carry a + // query. A request for the query-bearing document URL lands on this + // same path with the query ignored; per-query documents are not + // supported. + expect( + oauthProtectedResourceMetadataPath("https://rs.example.com/mcp?tenant=a"), + ).toBe("/.well-known/oauth-protected-resource/mcp"); + }); }); diff --git a/packages/sdk/tests/core/requestContext.test.ts b/packages/sdk/tests/core/requestContext.test.ts index 0908970..3a14fae 100644 --- a/packages/sdk/tests/core/requestContext.test.ts +++ b/packages/sdk/tests/core/requestContext.test.ts @@ -51,6 +51,24 @@ describe("buildRequestUrl — htu pinned to the configured resource origin", () }), ).toBe("https://api.example.com/mcp"); }); + + it("yields a resource-anchored htu when resourceOrigin follows the documented recipe", () => { + // `BuildRequestUrlParams.resourceOrigin` documents the recipe + // `${u.protocol}//${u.host}` from `new URL(resource)` — deliberately + // NOT `URL.origin`, which is the literal string "null" for a + // non-special scheme such as `mcp:` (an identifier the + // resource-indicator gate accepts) and would anchor the htu at + // "null/mcp". This pins the recipe as documented, computed by hand + // the way an external caller would. + const u = new URL("mcp://api.example.com/mcp"); + expect(u.origin).toBe("null"); // the trap the recipe avoids + expect( + buildRequestUrl({ + resourceOrigin: `${u.protocol}//${u.host}`, + pathAndQuery: "/mcp", + }), + ).toBe("mcp://api.example.com/mcp"); + }); }); describe("pathAndQueryOf", () => { diff --git a/packages/sdk/tests/core/resourceIndicator.test.ts b/packages/sdk/tests/core/resourceIndicator.test.ts new file mode 100644 index 0000000..a1ad363 --- /dev/null +++ b/packages/sdk/tests/core/resourceIndicator.test.ts @@ -0,0 +1,653 @@ +import { describe, expect, it } from "vitest"; + +import { + AuthplaneResource, + buildPrm, + oauthProtectedResourceMetadataDocumentUrl, + oauthProtectedResourceMetadataPath, + validateResourceIndicator, +} from "../../src/core/index.js"; + +/** + * Build an `AuthplaneResource` with the client-owned collaborators stubbed out. + * + * The indicator gate runs first in the constructor, before anything touches the + * metadata cache, the fetch settings or the JWKS accessor, so the stubs are + * never dereferenced on the rejection path — and on the accepted path this + * suite only reads `prmResponse()` / `prmDocumentUrl()`, which do not touch + * them either. `InternalResourceOptions` is module-private, hence the cast. + */ +function buildResource(resource: string): AuthplaneResource { + return new AuthplaneResource({ + resource, + scopes: ["read"], + issuer: "https://auth.example.com", + metadataCache: {}, + fetchSettings: {}, + getJwksCache: () => ({}), + } as unknown as ConstructorParameters[0]); +} + +describe("validateResourceIndicator (RFC 8707 §2)", () => { + it("accepts identifiers without a fragment", () => { + for (const resource of [ + "https://api.example.com", + "https://api.example.com/", + "https://api.example.com/mcp", + "https://api.example.com/mcp/", + "https://api.example.com/api/v1/mcp/stream", + "https://api.example.com:8443/mcp", + "https://[::1]:8443/mcp", + // A query is legal (RFC 8707 §2 states the SHOULD NOT and its + // exception in the same sentence) and preserved into the derived + // document URL; this gate must not start swallowing one. + "https://api.example.com/mcp?tenant=a", + // Deliberate profile relaxation: `http` hosts stay accepted for + // local development — the gate imposes no https-only narrowing. + "http://localhost:8080/mcp", + ]) { + expect(() => validateResourceIndicator(resource)).not.toThrow(); + } + }); + + it("rejects an identifier carrying a fragment", () => { + expect(() => + validateResourceIndicator("https://api.example.com/mcp#frag"), + ).toThrow(TypeError); + }); + + it("rejects an identifier carrying a bare empty fragment", () => { + // `#` alone is still a fragment delimiter, and `new URL(...).hash` is + // the empty string for it — exactly the case a `hash`-based check + // would wave through. + expect(() => + validateResourceIndicator("https://api.example.com/mcp#"), + ).toThrow(TypeError); + }); + + it("cites the RFC in the message", () => { + expect(() => + validateResourceIndicator("https://api.example.com/mcp#frag"), + ).toThrow(/must not contain a fragment component \(RFC 8707 §2\)/u); + }); + + it("does not echo the rejected fragment or a credential-shaped query", () => { + // The fragment value is deliberately not a substring of the word + // "fragment" in the message itself. + const resource = "https://api.example.com/mcp?token=secret#anchorvalue"; + expect(() => validateResourceIndicator(resource)).toThrow( + "https://api.example.com/mcp", + ); + expect(() => validateResourceIndicator(resource)).not.toThrow("secret"); + expect(() => validateResourceIndicator(resource)).not.toThrow( + "anchorvalue", + ); + }); + + it("does not echo userinfo embedded in the authority", () => { + // The positive half is load-bearing: `not.toThrow(substring)` also + // passes when the callback throws nothing at all, so on its own it + // would go green with the gate deleted. + expect(() => + validateResourceIndicator("https://svc:s3cr3t@api.example.com/mcp#f"), + ).toThrow("https://api.example.com/mcp"); + expect(() => + validateResourceIndicator("https://svc:s3cr3t@api.example.com/mcp#f"), + ).not.toThrow("s3cr3t"); + }); + + it("does not echo userinfo on the fallback path either", () => { + // The two shapes with no `origin` to redact through: `new URL` throws + // on the scheme-relative one, and the other parses to the literal + // `"null"` origin. Both land in `redactResourceIdentifier`'s + // hand-rolled strip, which is the only place the redaction is not + // delegated to the platform. + for (const resource of [ + "//svc:s3cr3t@api.example.com/mcp#anchorvalue", + "svc:s3cr3t@api.example.com#anchorvalue", + ]) { + expect(() => validateResourceIndicator(resource)).toThrow(/RFC 8707 §2/u); + expect(() => validateResourceIndicator(resource)).toThrow( + "api.example.com", + ); + expect(() => validateResourceIndicator(resource)).not.toThrow("s3cr3t"); + } + }); + + it("renders a blob: identifier once instead of doubling its origin", () => { + // `blob:` has no authority of its own: `origin` comes from the inner + // URL and `pathname` is that whole inner URL, so the parsed branch + // would emit the authority twice and lift the inner `userinfo@` out of + // the path. + const resource = "blob:https://svc:s3cr3t@example.com/uuid#anchorvalue"; + expect(() => validateResourceIndicator(resource)).toThrow( + "blob:https://example.com/uuid", + ); + expect(() => validateResourceIndicator(resource)).not.toThrow("s3cr3t"); + }); + + it("redacts userinfo in a scheme-relative authority", () => { + // The redaction fallback's optional prefix is the authority marker, not + // a scheme, precisely for this shape: `//user:pass@host/path` never + // parses, so the parsed branch cannot do the stripping. + expect(() => + validateResourceIndicator("//svc:s3cr3t@api.example.com/mcp"), + ).toThrow(TypeError); + expect(() => + validateResourceIndicator("//svc:s3cr3t@api.example.com/mcp"), + ).not.toThrow("s3cr3t"); + }); + + it("redacts an unparseable identifier without surfacing a parse failure", () => { + // `redactResourceIdentifier`'s fallback: no valid URL to take an + // `origin` from, so it truncates at the delimiter by hand and still + // reports the RFC 8707 reason rather than urllib-style parse noise. + expect(() => validateResourceIndicator("not a url#anchorvalue")).toThrow( + /RFC 8707 §2/u, + ); + expect(() => + validateResourceIndicator("not a url#anchorvalue"), + ).not.toThrow("anchorvalue"); + }); + + it("redacts a non-special scheme, whose origin is the literal 'null'", () => { + expect(() => + validateResourceIndicator("urn:example:api#anchorvalue"), + ).toThrow("urn:example:api"); + expect(() => + validateResourceIndicator("urn:example:api#anchorvalue"), + ).not.toThrow("null"); + }); +}); + +describe("validateResourceIndicator query grammar (RFC 3986 §3.4)", () => { + it("rejects a query byte outside the RFC 3986 §3.4 grammar", () => { + // A raw `\` is a quoted-pair escape inside the `WWW-Authenticate` + // quoted-string (RFC 9110 §11.2) — `sanitiseHeaderValue` would blank + // it to a space, so the advertised document URL would no longer + // round-trip to the configured identifier. Rejected at the same + // boundary that rejects `#`. + expect(() => + validateResourceIndicator("https://api.example.com/mcp?path=a\\b"), + ).toThrow(TypeError); + expect(() => + validateResourceIndicator("https://api.example.com/mcp?path=a\\b"), + ).toThrow(/RFC 3986 §3\.4/u); + }); + + it("rejects a malformed percent-escape", () => { + expect(() => + validateResourceIndicator("https://api.example.com/mcp?p=%zz"), + ).toThrow(/RFC 3986 §3\.4/u); + }); + + it("rejects the out-of-grammar octets [ ] | ^ ` { } — including bracketed params", () => { + // RFC 3986 §3.4 excludes these from `query`, and WHATWG normalisation + // leaves them raw in `URL.search`, so before this gate they reached + // the challenge verbatim. `filter[tenant]=a` is the shape that will + // actually reach an operator: bracketed query params are a common REST + // convention, and they now fail at startup — percent-encode the + // brackets instead. + for (const query of [ + "filter[tenant]=a", + "a=b|c", + "a=b^c", + "a=b`c", + "a={b}", + ]) { + expect(() => + validateResourceIndicator(`https://api.example.com/mcp?${query}`), + ).toThrow(/RFC 3986 §3\.4/u); + } + }); + + it("rejects a raw space, quote or angle bracket even though WHATWG would encode them away", () => { + // Deliberate flip from round 2 of review: these were previously pinned + // as *accepted*, because the gate ran on the WHATWG-normalised + // `URL.search`, which percent-encodes a space to `%20` (and `"` `<` + // `>` similarly) before the check ever saw it. The resource identifier + // is an identity compared byte-for-byte, so accepting `?a=b c` while + // serving `?a=b c` in the PRM document and advertising `?a=b%20c` in + // the challenge was the SDK silently deciding the operator meant a + // different identifier than the one they typed. The gate now judges + // the raw configured query, so these fail at construction instead. + for (const resource of [ + "https://api.example.com/mcp?a=b c", + 'https://api.example.com/mcp?a=b"c', + "https://api.example.com/mcp?a=b validateResourceIndicator(resource)).toThrow( + /RFC 3986 §3\.4/u, + ); + } + }); + + it("gates the raw query of a non-special-scheme identifier", () => { + // The gate runs on the raw configured string, so a scheme WHATWG does + // not treat as special has its query judged the same way `https` does + // — the fragment check already worked this way. + expect(() => + validateResourceIndicator("mcp://api.example.com/mcp?a=b c"), + ).toThrow(/RFC 3986 §3\.4/u); + }); + + it("names the offending codepoint and its offset in the query", () => { + // One byte of the query leaks — never the value. The offset is within + // the query (0 = the first byte after `?`), so the startup failure is + // a one-line fix instead of a guessing game. + expect(() => + validateResourceIndicator("https://api.example.com/mcp?path=a\\b"), + ).toThrow('invalid character "\\\\" at offset 6'); + expect(() => + validateResourceIndicator("https://api.example.com/mcp?p=%zz"), + ).toThrow('malformed percent-escape "%zz" at offset 2'); + }); + + it("does not echo the offending query in the message", () => { + // The positive assertion keeps this from passing vacuously: the same + // input must actually throw (with the citation) for the negative + // substring check on the throw to mean anything. + const resource = "https://api.example.com/mcp?token=s3cr3t\\x"; + expect(() => validateResourceIndicator(resource)).toThrow( + /RFC 3986 §3\.4/u, + ); + expect(() => validateResourceIndicator(resource)).not.toThrow("s3cr3t"); + }); + + it("accepts a legal query carrying every sub-delim unchanged", () => { + expect(() => + validateResourceIndicator( + "https://api.example.com/mcp?a=b&c=(d)!$*+,;=:@/?x", + ), + ).not.toThrow(); + }); + + it("gates AuthplaneResource construction", () => { + expect(() => + buildResource("https://api.example.com/mcp?path=a\\b"), + ).toThrow(/RFC 3986 §3\.4/u); + }); +}); + +describe("validateResourceIndicator absolute-URL requirement (RFC 8707 §2)", () => { + it("rejects a relative identifier", () => { + expect(() => validateResourceIndicator("/mcp")).toThrow(TypeError); + expect(() => validateResourceIndicator("/mcp")).toThrow( + /must be an absolute URL with a scheme and a host \(RFC 8707 §2\)/u, + ); + }); + + it("rejects a scheme-relative identifier", () => { + // `//api.example.com/mcp` parses with an authority but has no scheme + // (RFC 3986 §4.3: absolute-URI = scheme ":" hier-part [ "?" query ]). + // A guard phrased as "opaque or authority-less" would wrongly admit it. + expect(() => validateResourceIndicator("//api.example.com/mcp")).toThrow( + TypeError, + ); + expect(() => validateResourceIndicator("//api.example.com/mcp")).toThrow( + /must be an absolute URL with a scheme and a host/u, + ); + }); + + it("rejects an opaque identifier with a scheme but no host", () => { + // Previously accepted as an "opaque audience string"; it derived the + // garbage document URL + // `null/.well-known/oauth-protected-resourceexample:api`. The host is + // what RFC 9728 §3 inserts the well-known suffix after — no host, no + // derivable metadata URL. + expect(() => validateResourceIndicator("urn:example:api")).toThrow( + TypeError, + ); + expect(() => validateResourceIndicator("urn:example:api")).toThrow( + /must be an absolute URL with a scheme and a host/u, + ); + }); + + it("rejects an authority-less absolute URI that WHATWG parses with a host", () => { + // RFC 3986 `hier-part = path-rootless`: these carry no authority at + // all. WHATWG invents one for a special scheme — `new URL` reports + // `host === "example.com"` for the first — so a host read off the + // parse would admit them, and the served `resource` member would be a + // string no conformant client can insert the well-known suffix into + // (RFC 9728 §3.3). The last one is the backslash spelling WHATWG + // rewrites to forward slashes. + for (const resource of [ + "https:example.com/mcp", + "https:/example.com/mcp", + "http:localhost:8080/mcp", + "https:\\\\api.example.com\\mcp", + ]) { + expect(() => validateResourceIndicator(resource)).toThrow(TypeError); + expect(() => validateResourceIndicator(resource)).toThrow( + /must be an absolute URL with a scheme and a host/u, + ); + } + }); + + it("accepts an http host — deliberate relaxation for local development", () => { + expect(() => + validateResourceIndicator("http://localhost:8080/mcp"), + ).not.toThrow(); + }); + + it("accepts a non-special scheme with a host", () => { + // The gate is scheme + host, not WHATWG-special-scheme: an identifier + // like `mcp://api.example.com/mcp` satisfies RFC 8707 §2's absolute-URI + // grammar and has the host RFC 9728 §3 inserts the suffix after. Its + // WHATWG `origin` is the literal "null", which is why every derivation + // builds from `protocol` + `host` instead. + expect(() => + validateResourceIndicator("mcp://api.example.com/mcp"), + ).not.toThrow(); + }); + + it("redacts userinfo in a non-special scheme to protocol + host", () => { + // `URL.origin` is "null" here, so an origin-keyed redaction would fall + // through; the host-keyed branch still strips the credential. + expect(() => + validateResourceIndicator("mcp://svc:s3cr3t@api.example.com/mcp#f"), + ).toThrow("mcp://api.example.com/mcp"); + expect(() => + validateResourceIndicator("mcp://svc:s3cr3t@api.example.com/mcp#f"), + ).not.toThrow("s3cr3t"); + }); + + it("does not echo a credential from a missing-colon or scheme-only typo", () => { + // Neither shape parses with a usable authority, so both reach the raw + // fallback — which must not require the `//` a well-formed authority + // would carry before stripping through the `@`. + for (const resource of [ + "https//svc:s3cr3t@api.example.com/mcp", + "svc:s3cr3t@api.example.com/mcp", + ]) { + expect(() => validateResourceIndicator(resource)).toThrow(TypeError); + expect(() => validateResourceIndicator(resource)).not.toThrow("s3cr3t"); + } + }); + + it("reports the fragment first when an identifier is wrong in both ways", () => { + // Ordering pin: the fragment check runs ahead of the absolute-URL + // check, so a relative identifier carrying a fragment deterministically + // reports the fragment. Without this, reordering the checks would + // silently change which error a doubly-wrong config reports. + expect(() => validateResourceIndicator("/mcp#frag")).toThrow( + /must not contain a fragment component/u, + ); + expect(() => validateResourceIndicator("//api.example.com/mcp#frag")).toThrow( + /must not contain a fragment component/u, + ); + }); +}); + +describe("AuthplaneResource construction (RFC 8707 §2)", () => { + it("rejects a fragment-bearing resource at construction", () => { + expect(() => buildResource("https://api.example.com/mcp#frag")).toThrow( + TypeError, + ); + expect(() => buildResource("https://api.example.com/mcp#frag")).toThrow( + /RFC 8707 §2/u, + ); + }); + + it("rejects a non-absolute resource at construction", () => { + for (const resource of [ + "/mcp", + "//api.example.com/mcp", + "urn:example:api", + // Authority-less, but WHATWG parses it with a host — the gate reads + // the `//` off the raw string, so construction rejects it too. + "https:example.com/mcp", + "https:/example.com/mcp", + "http:localhost:8080/mcp", + "https:\\\\api.example.com\\mcp", + ]) { + expect(() => buildResource(resource)).toThrow(TypeError); + expect(() => buildResource(resource)).toThrow( + /must be an absolute URL with a scheme and a host/u, + ); + } + }); + + it("constructs normally for an http host", () => { + const resource = buildResource("http://localhost:8080/mcp"); + expect(resource.prmDocumentUrl()).toBe( + "http://localhost:8080/.well-known/oauth-protected-resource/mcp", + ); + }); + + it("rejects before any other constructor validation runs", () => { + // Ordering pin: the indicator gate is first, so an operator who got + // both wrong is told about the identifier rather than about the + // algorithm list. Without this, moving the gate below the + // dangerous-algorithm check would silently change which error a + // fragment-bearing config reports. + expect(() => + new AuthplaneResource({ + resource: "https://api.example.com/mcp#frag", + scopes: ["read"], + allowedAlgorithms: ["HS256"], + issuer: "https://auth.example.com", + metadataCache: {}, + fetchSettings: {}, + getJwksCache: () => ({}), + } as unknown as ConstructorParameters[0]), + ).toThrow(/RFC 8707 §2/u); + }); + + it("leaves a fragment-free resource unaffected", () => { + const resource = buildResource("https://api.example.com/mcp"); + expect(resource.prmResponse().resource).toBe("https://api.example.com/mcp"); + expect(resource.prmDocumentUrl()).toBe( + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ); + }); + + it("leaves a query-bearing resource unaffected and preserves the query in derivation", () => { + const resource = buildResource("https://api.example.com/mcp?tenant=a"); + expect(resource.prmResponse().resource).toBe( + "https://api.example.com/mcp?tenant=a", + ); + expect(resource.prmDocumentUrl()).toBe( + "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=a", + ); + }); + + it("constructs with a non-special scheme and derives its document URL from protocol + host", () => { + // End-to-end pin for the accepted set: the gate admits any scheme with + // a host, and the derivation must not leak WHATWG's literal "null" + // origin into the advertised URL. + const resource = buildResource("mcp://api.example.com/mcp"); + expect(resource.prmResponse().resource).toBe("mcp://api.example.com/mcp"); + expect(resource.prmDocumentUrl()).toBe( + "mcp://api.example.com/.well-known/oauth-protected-resource/mcp", + ); + }); +}); + +describe("PRM URL derivation rejects a fragment-bearing resource", () => { + it("oauthProtectedResourceMetadataDocumentUrl throws instead of dropping it", () => { + // Before the gate, `origin` + `pathname` silently produced the + // no-fragment URL, so the served document's `resource` member and the + // URL it was served at disagreed — which RFC 9728 §3.3 tells the + // client to discard, with no server-side signal. + expect(() => + oauthProtectedResourceMetadataDocumentUrl( + "https://api.example.com/mcp#frag", + ), + ).toThrow(TypeError); + }); + + it("oauthProtectedResourceMetadataPath throws instead of dropping it", () => { + expect(() => + oauthProtectedResourceMetadataPath("https://api.example.com/mcp#frag"), + ).toThrow(TypeError); + }); + + it("still derives fragment-free identifiers unchanged", () => { + expect( + oauthProtectedResourceMetadataDocumentUrl("https://api.example.com/mcp/"), + ).toBe("https://api.example.com/.well-known/oauth-protected-resource/mcp"); + expect(oauthProtectedResourceMetadataPath("https://api.example.com/mcp")).toBe( + "/.well-known/oauth-protected-resource/mcp", + ); + }); + + it("echoes the raw identifier when WHATWG would invent the authority", () => { + // The gate rejects on the raw string; the message has to describe the raw + // string too. For these four shapes WHATWG supplies the authority the gate + // exists to deny, so echoing the parse showed the operator a string that is + // an absolute URL with a scheme and a host — and that the gate accepts — + // with the missing `//` removed on the way out. + // The echo is quoted, so a backslash in the identifier reaches the + // message as the `\\` escape `JSON.stringify` writes it as — the raw + // byte is still what the operator configured, one escape away. + for (const resource of [ + "https:example.com/mcp", + "https:/example.com/mcp", + "http:localhost:8080/mcp", + ]) { + expect(() => validateResourceIndicator(resource)).toThrow(resource); + } + expect(() => + validateResourceIndicator("https:\\api.example.com\\mcp"), + ).toThrow("https:\\\\api.example.com\\\\mcp"); + }); + + it("does not echo userinfo carried behind a single-slash scheme", () => { + // The parsed branch used to mask this: routing the shape to the fallback + // exposes that `^([^/?#]*\/\/)?[^/?#]*@` cannot cross the single `/`, so + // the prefix alternative has to admit `scheme:/` as well. + const resource = "https:/svc:s3cr3t@example.com/mcp"; + expect(() => validateResourceIndicator(resource)).toThrow(/RFC 8707 §2/u); + expect(() => validateResourceIndicator(resource)).not.toThrow("s3cr3t"); + }); + + it("does not let the scheme alternative swallow a username", () => { + // `:\/` rather than `:\/*`: with `*`, `svc:pw@host` matches the scheme + // alternative and the username survives as a `svc:` prefix — the leak the + // superseded regex had. + expect(() => + validateResourceIndicator("svc:s3cr3t@api.example.com"), + ).not.toThrow("s3cr3t"); + expect(() => + validateResourceIndicator("svc:s3cr3t@api.example.com"), + ).not.toThrow("svc:"); + }); +}); + +describe("validateResourceIndicator userinfo requirement (RFC 9110 §4.2.4)", () => { + it("rejects an identifier carrying userinfo", () => { + const resource = "https://svc:s3cr3t@api.example.com/mcp"; + expect(() => validateResourceIndicator(resource)).toThrow(TypeError); + expect(() => validateResourceIndicator(resource)).toThrow( + /must not include a userinfo component/u, + ); + }); + + it("rejects a username-only userinfo", () => { + expect(() => + validateResourceIndicator("https://svc@api.example.com/mcp"), + ).toThrow(/must not include a userinfo component/u); + }); + + it("rejects an empty userinfo, which is still a userinfo component", () => { + expect(() => + validateResourceIndicator("https://@api.example.com/mcp"), + ).toThrow(/must not include a userinfo component/u); + }); + + it("cites the RFC in the message", () => { + expect(() => + validateResourceIndicator("https://svc:s3cr3t@api.example.com/mcp"), + ).toThrow(/RFC 9110 §4\.2\.4/u); + }); + + it("does not echo the credential it rejects", () => { + const resource = "https://svc:s3cr3t@api.example.com/mcp"; + expect(() => validateResourceIndicator(resource)).not.toThrow("s3cr3t"); + expect(() => validateResourceIndicator(resource)).not.toThrow("svc"); + }); + + it("accepts a host carrying a port and an IPv6 literal", () => { + expect(() => + validateResourceIndicator("https://api.example.com:8443/mcp"), + ).not.toThrow(); + expect(() => + validateResourceIndicator("https://[::1]:8443/mcp"), + ).not.toThrow(); + }); + + it("does not mistake an @ in the path or the query for userinfo", () => { + expect(() => + validateResourceIndicator("https://api.example.com/@handle"), + ).not.toThrow(); + expect(() => + validateResourceIndicator("https://api.example.com/mcp?to=a@b"), + ).not.toThrow(); + }); + + it("gates the authority of a non-special scheme too", () => { + expect(() => + validateResourceIndicator("mcp://svc:s3cr3t@api.example.com/mcp"), + ).toThrow(/must not include a userinfo component/u); + }); + + it("reports the fragment and the missing scheme ahead of the userinfo", () => { + // Checked last of the four, so an identifier wrong in more than one way + // reports the defect an operator fixes first. + expect(() => + validateResourceIndicator("https://svc:s3cr3t@api.example.com/mcp#f"), + ).toThrow(/must not contain a fragment component/u); + expect(() => + validateResourceIndicator("//svc:s3cr3t@api.example.com/mcp"), + ).toThrow(/must be an absolute URL with a scheme and a host/u); + }); + + it("gates AuthplaneResource construction", () => { + expect(() => + buildResource("https://svc:s3cr3t@api.example.com/mcp"), + ).toThrow(/must not include a userinfo component/u); + }); +}); + +describe("the PRM document and its derived URL cannot disagree", () => { + it("refuses an identifier whose derived URL would drop part of the resource member", () => { + // `buildPrm` copies the identifier into `doc.resource`; every derivation + // builds the document URL from scheme + host + path, which drops userinfo. + // Admitting this shape would serve a document whose `resource` names a + // different string than the URL it was fetched from — the RFC 9728 §3.3 + // mismatch a conformant client answers by discarding the document, leaving + // the resource server looking unreachable rather than misconfigured. + const resource = "https://svc:s3cr3t@api.example.com/mcp"; + expect(() => + buildPrm("https://auth.example.com", resource, ["read"]), + ).toThrow(/must not include a userinfo component/u); + expect(() => + oauthProtectedResourceMetadataDocumentUrl(resource), + ).toThrow(/must not include a userinfo component/u); + expect(() => oauthProtectedResourceMetadataPath(resource)).toThrow( + /must not include a userinfo component/u, + ); + }); + + it("keeps the resource member reconcilable with the derived URL for every accepted shape", () => { + for (const resource of [ + "https://api.example.com/mcp", + "https://api.example.com/mcp?tenant=a", + "http://localhost:8080/mcp", + "mcp://api.example.com/mcp", + "https://[::1]:8443/mcp", + ]) { + const doc = buildPrm("https://auth.example.com", resource, ["read"]); + expect(doc.resource).toBe(resource); + const parsed = new URL(resource); + // The document URL is built from the same scheme and authority the + // `resource` member names, so a client can reconcile the two. + expect( + oauthProtectedResourceMetadataDocumentUrl(resource).startsWith( + `${parsed.protocol}//${parsed.host}/`, + ), + ).toBe(true); + } + }); +}); diff --git a/packages/sdk/tests/core/resourceMetadataUrl.test.ts b/packages/sdk/tests/core/resourceMetadataUrl.test.ts new file mode 100644 index 0000000..e27a333 --- /dev/null +++ b/packages/sdk/tests/core/resourceMetadataUrl.test.ts @@ -0,0 +1,196 @@ +import { describe, expect, it } from "vitest"; + +import { AuthplaneResource } from "../../src/core/resource.js"; +import { validateResourceMetadataUrl } from "../../src/core/prm.js"; + +function buildResource( + resource: string, + resourceMetadataUrl?: string, +): AuthplaneResource { + return new AuthplaneResource({ + resource, + scopes: ["read"], + ...(resourceMetadataUrl !== undefined ? { resourceMetadataUrl } : {}), + issuer: "https://auth.example.com", + metadataCache: {}, + fetchSettings: {}, + getJwksCache: () => ({}), + } as unknown as ConstructorParameters[0]); +} + +describe("validateResourceMetadataUrl (RFC 9728 §3)", () => { + it("accepts absolute URLs with a scheme and a host", () => { + for (const url of [ + "https://auth.example.com/.well-known/oauth-protected-resource/mcp", + "https://auth.example.com/.well-known/oauth-protected-resource", + "https://auth.example.com:8443/.well-known/oauth-protected-resource/mcp", + // `http` stays accepted, as it does on the issuer and resource + // gates — local development, not a narrowing decision taken here. + "http://localhost:9000/.well-known/oauth-protected-resource/mcp", + // A query is legal: the derived URL carries the resource + // identifier's query through, so an override must be able to name + // the same document. + "https://auth.example.com/.well-known/oauth-protected-resource/mcp?tenant=a", + ]) { + expect(() => validateResourceMetadataUrl(url)).not.toThrow(); + } + }); + + it("rejects a fragment", () => { + expect(() => + validateResourceMetadataUrl("https://auth.example.com/prm#frag"), + ).toThrow(/fragment component/u); + }); + + it("rejects anything that is not an absolute URL with a scheme and a host", () => { + for (const url of [ + "/.well-known/oauth-protected-resource/mcp", + "//auth.example.com/prm", + "urn:example:prm", + "https:auth.example.com/prm", + "not a url", + // Leading whitespace defeats the scheme/authority match before the + // quoted-string scan ever runs; rejected either way. + " https://auth.example.com/prm", + ]) { + expect(() => validateResourceMetadataUrl(url)).toThrow( + /absolute URL with a scheme and a host/u, + ); + } + }); + + it("rejects a scheme the client cannot dereference", () => { + for (const url of [ + "mcp://auth.example.com/prm", + "ftp://auth.example.com/prm", + "ws://auth.example.com/prm", + ]) { + expect(() => validateResourceMetadataUrl(url)).toThrow( + /http or https scheme/u, + ); + } + }); + + it("rejects octets that cannot survive the quoted-string", () => { + // WHATWG accepts all of these in a path and the override is advertised + // verbatim — never re-derived — so without this gate the challenge + // sanitiser would silently emit a well-formed challenge naming an + // unfetchable URL. + for (const url of [ + 'https://auth.example.com/prm"x', + "https://auth.example.com/prm\\x", + "https://auth.example.com/prm doc", + "https://auth.example.com/prm\tx", + "https://auth.example.com/prm\nx", + "https://auth.example.com/prm\n", + ]) { + expect(() => validateResourceMetadataUrl(url)).toThrow( + /literal double quote|literal backslash|non-URI octet/u, + ); + } + }); + + it("rejects an out-of-grammar query", () => { + expect(() => + validateResourceMetadataUrl("https://auth.example.com/prm?filter[a]=b"), + ).toThrow(/RFC 3986 §3.4 query/u); + }); + + it("rejects a userinfo component and keeps the credential out of the message", () => { + expect(() => + validateResourceMetadataUrl("https://svc:pw@auth.example.com/prm"), + ).toThrow(/userinfo component/u); + expect(() => + validateResourceMetadataUrl("https://svc:pw@auth.example.com/prm"), + ).not.toThrow(/pw/u); + }); + + it("throws TypeError, like the sibling identifier gates", () => { + expect(() => validateResourceMetadataUrl("/prm")).toThrow(TypeError); + }); +}); + +describe("AuthplaneResource.resourceMetadataUrl()", () => { + it("returns the derived document URL when no override is configured", () => { + // The default is the behaviour every existing challenge assertion in + // this repo pins; this is the statement of it in one place. + const resource = buildResource("https://api.example.com/mcp"); + expect(resource.resourceMetadataUrl()).toBe( + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ); + expect(resource.resourceMetadataUrl()).toBe(resource.prmDocumentUrl()); + }); + + it("returns the configured override verbatim", () => { + // The AS-hosted topology: authserver >= 0.2.0 serves the document for + // every registered Resource at `/.well-known/ + // oauth-protected-resource/{ref}` and the resource server only points + // at it. + const resource = buildResource( + "https://api.example.com/mcp", + "https://auth.example.com/.well-known/oauth-protected-resource/mcp", + ); + expect(resource.resourceMetadataUrl()).toBe( + "https://auth.example.com/.well-known/oauth-protected-resource/mcp", + ); + }); + + it("leaves the derived URL and the served document alone", () => { + // Only the advertisement moves. `prmDocumentUrl()` is what the + // adapters mount their PRM route at, and the document's own `resource` + // member is what RFC 9728 §3.3 binds to the identifier the client + // used — neither may follow the override. + const resource = buildResource( + "https://api.example.com/mcp", + "https://auth.example.com/.well-known/oauth-protected-resource/mcp", + ); + expect(resource.prmDocumentUrl()).toBe( + "https://api.example.com/.well-known/oauth-protected-resource/mcp", + ); + expect(resource.prmResponse().resource).toBe( + "https://api.example.com/mcp", + ); + }); + + it("rejects an invalid override at construction", () => { + expect(() => + buildResource("https://api.example.com/mcp", "/.well-known/prm"), + ).toThrow(/absolute URL with a scheme and a host/u); + expect(() => + buildResource("https://api.example.com/mcp", "https://auth.example.com#f"), + ).toThrow(TypeError); + }); + + // The value is stored and advertised exactly as typed, so nothing downstream + // percent-encodes these the way WHATWG normalisation does for the resource + // identifier's path. Held to the whole non-URI set (RFC 3986 §2), not just + // the four octets the first cut covered — otherwise the same octet is + // refused inside the query, which has its own grammar check, and accepted in + // the path. + it.each([ + [ + "a raw non-ASCII path segment", + "https://auth.example.com/.well-known/oauth-protected-resource/münchen", + ], + ["an IDN host given as unicode", "https://café.example.com/prm"], + ["a pipe in the path", "https://auth.example.com/prm|x"], + ["a brace in the path", "https://auth.example.com/pr{m}"], + ["an angle bracket in the path", "https://auth.example.com/pr"], + ["a backtick in the path", "https://auth.example.com/pr`m"], + ["a caret in the path", "https://auth.example.com/pr^m"], + ])("rejects %s", (_label, url) => { + expect(() => buildResource("https://api.example.com/mcp", url)).toThrow( + TypeError, + ); + }); + + it("accepts the punycode spelling of an IDN host", () => { + const resource = buildResource( + "https://api.example.com/mcp", + "https://xn--caf-dma.example.com/prm", + ); + expect(resource.resourceMetadataUrl()).toBe( + "https://xn--caf-dma.example.com/prm", + ); + }); +}); diff --git a/packages/sdk/tests/core/ssrf.test.ts b/packages/sdk/tests/core/ssrf.test.ts index 0aea673..266fb79 100644 --- a/packages/sdk/tests/core/ssrf.test.ts +++ b/packages/sdk/tests/core/ssrf.test.ts @@ -74,6 +74,45 @@ describe("buildMetadataUrl", () => { ); }); + // Routing this function through the shared issuer gate widened what it + // rejects. Both additions are reachable here: the derivation sets `pathname` + // on the parsed URL and returns it, so a host-less issuer used to yield a + // string no client could fetch, and a credential-bearing one was carried + // verbatim into the fetch target. + it("rejects an issuer that is not an absolute URL with a scheme and a host", () => { + for (const issuer of ["/auth", "", "https:auth.example.com"]) { + expect(() => buildMetadataUrl(issuer)).toThrow( + /must be an absolute URL with a scheme and a host/ + ); + } + }); + + it("rejects an issuer carrying whitespace or a control character", () => { + // A trailing newline from an environment variable is the realistic + // trigger: `new URL` trims it, so every check that reads the parse passed + // and the derivation below produced the cleaned location while the raw + // string stayed the expected `iss`. `"not a url"` moved here from the + // absoluteness case above — its space is now the first defect reported. + for (const issuer of [ + "https://auth.example.com\n", + "https://auth.exa\tmple.com", + "not a url", + ]) { + expect(() => buildMetadataUrl(issuer)).toThrow( + /must not contain whitespace or control characters/ + ); + } + }); + + it("rejects an issuer carrying a userinfo component, without echoing it", () => { + expect(() => + buildMetadataUrl("https://svc:s3cr3t@auth.example.com") + ).toThrow(/must not include a userinfo component/); + expect(() => + buildMetadataUrl("https://svc:s3cr3t@auth.example.com") + ).not.toThrow(/s3cr3t/); + }); + it("does not leak the rejected query value into the error message", () => { expect(() => buildMetadataUrl("https://auth.example.com/t?token=secret") @@ -84,18 +123,30 @@ describe("buildMetadataUrl", () => { }); }); -describe("AuthplaneClient.create rejects a query-bearing issuer", () => { +describe("AuthplaneClient.create rejects a malformed issuer", () => { afterEach(() => { vi.restoreAllMocks(); }); - it("rejects before any network fetch", async () => { + it("rejects a query-bearing issuer before any network fetch", async () => { const fetchSpy = vi.spyOn(globalThis, "fetch"); await expect( AuthplaneClient.create({ issuer: "https://auth.example.com/t?x=1" }) ).rejects.toThrow(TypeError); expect(fetchSpy).not.toHaveBeenCalled(); }); + + it("rejects an issuer carrying whitespace before any network fetch", async () => { + // The released boundary the whitespace axis newly rejects: a trailing + // newline used to construct a client whose expected `iss` carried the byte + // while every fetch went to the cleaned location, so every token failed an + // identity comparison that looks identical in a log. + const fetchSpy = vi.spyOn(globalThis, "fetch"); + await expect( + AuthplaneClient.create({ issuer: "https://auth.example.com\n" }) + ).rejects.toThrow(/must not contain whitespace or control characters/); + expect(fetchSpy).not.toHaveBeenCalled(); + }); }); describe("isIpAllowed", () => { diff --git a/packages/sdk/tests/core/ssrfBrokenImport.test.ts b/packages/sdk/tests/core/ssrfBrokenImport.test.ts new file mode 100644 index 0000000..fc4d5d5 --- /dev/null +++ b/packages/sdk/tests/core/ssrfBrokenImport.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; + +// Simulates what Node's ESM loader hands the guard when the ipaddr.js import +// resolves to a shape without `parse` (the namespace-import regression): the +// call site raises a TypeError before any address is parsed. That must +// surface, not be translated into "blocked" — the malformed-address path +// (`ipaddr.parse` throwing a plain Error) is the only failure that may +// return false, and tests/core/ssrf.test.ts covers it. +vi.mock("ipaddr.js", () => ({ + default: { + parse: () => { + throw new TypeError("ipaddr.parse is not a function"); + }, + IPv6: class {}, + }, +})); + +describe("isIpAllowed with a broken ipaddr.js import", () => { + it("propagates the TypeError instead of rejecting every address", async () => { + const { isIpAllowed } = await import("../../src/core/fetching/ssrf.js"); + expect(() => isIpAllowed("8.8.8.8")).toThrow(TypeError); + }); +}); diff --git a/packages/sdk/tests/core/ssrfEsmInterop.test.ts b/packages/sdk/tests/core/ssrfEsmInterop.test.ts new file mode 100644 index 0000000..49fa1af --- /dev/null +++ b/packages/sdk/tests/core/ssrfEsmInterop.test.ts @@ -0,0 +1,68 @@ +import { execFileSync } from "node:child_process"; +import { createRequire } from "node:module"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { beforeAll, describe, expect, it } from "vitest"; + +// The SSRF guard is the one place the SDK imports a CommonJS dependency +// (ipaddr.js, no `exports` map). Vitest resolves that import through its own +// interop layer, which synthesises named exports a real `node` process never +// sees — so every in-process test of `isIpAllowed` passes regardless of the +// import form, and the regression this file guards against (a namespace +// import whose `.parse` is `undefined`, a swallowed TypeError, and a guard +// that rejects every address) is invisible to them. The only honest check is +// to build the package and load the emitted module under Node's own loader. + +const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); +const require = createRequire(import.meta.url); + +function runUnderNodeEsm(script: string): string { + return execFileSync(process.execPath, ["--input-type=module", "-e", script], { + cwd: PACKAGE_ROOT, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }).trim(); +} + +describe("ssrf built output under Node's ESM loader", () => { + // `tsc -b` is incremental: a no-op when dist is current, a full emit when a + // fresh checkout runs the suite before `npm run build`. Either way the + // module exercised below is the one the package ships. + beforeAll(() => { + execFileSync(process.execPath, [require.resolve("typescript/bin/tsc"), "-b"], { + cwd: PACKAGE_ROOT, + stdio: "inherit", + }); + }, 120_000); + + it("premise: a namespace import of ipaddr.js has no `parse` under node", () => { + // Pins the packaging fact the default import exists to work around. If + // ipaddr.js starts shipping named ESM exports this fails, and the comment + // on the import in src/shared/ssrf.ts is then the thing to revisit. + const shape = runUnderNodeEsm( + 'import * as ipaddr from "ipaddr.js"; process.stdout.write(typeof ipaddr.parse);' + ); + expect(shape).toBe("undefined"); + // Explicit budget: the body is a synchronous execFileSync, so it cannot be + // interrupted, and a cold `node` spawn on a contended runner would surface + // as a timeout failure rather than as a guard signal. + }, 60_000); + + it("isIpAllowed classifies addresses through the real loader", () => { + const verdicts = runUnderNodeEsm( + [ + 'import { isIpAllowed } from "./dist/shared/ssrf.js";', + "process.stdout.write(JSON.stringify([", + ' isIpAllowed("8.8.8.8"),', + ' isIpAllowed("2001:4860:4860::8888"),', + ' isIpAllowed("10.0.0.1"),', + ' isIpAllowed("127.0.0.1", { allowLocalhost: true }),', + ' isIpAllowed("not-an-ip"),', + "]));", + ].join("\n") + ); + // Public allowed, private blocked, loopback opt-in honoured, garbage + // rejected — a broken import collapses all five to `false`. + expect(JSON.parse(verdicts)).toEqual([true, true, false, true, false]); + }, 60_000); +}); diff --git a/packages/sdk/tests/core/verifier.test.ts b/packages/sdk/tests/core/verifier.test.ts index 6dbc630..c496aec 100644 --- a/packages/sdk/tests/core/verifier.test.ts +++ b/packages/sdk/tests/core/verifier.test.ts @@ -10,7 +10,7 @@ import { type JWK, type KeyLike, } from "jose"; -import { afterAll, assert, beforeAll, describe, expect, it } from "vitest"; +import { afterAll, afterEach, assert, beforeAll, describe, expect, it, vi } from "vitest"; import { AuthplaneClient, @@ -876,7 +876,12 @@ describe("AuthplaneResource with DPoP-bound tokens", () => { }); describe("AuthplaneResource with IntrospectionRevocation without asCredentials", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + it("warns and still validates token when AS introspection returns active=true", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const server = await startAuthServer({ introspectionActive: true }); try { const client = await AuthplaneClient.create({ issuer: server.issuer, devMode: true }); @@ -888,6 +893,16 @@ describe("AuthplaneResource with IntrospectionRevocation without asCredentials", // Intentionally omit asCredentials => triggers warning branch. }); + // The construction-time warning has to name the consequence: under + // authserver >= 0.1.2 the unauthenticated path answers active: false, + // so an operator reading the log learns every token will be rejected. + expect(warnSpy).toHaveBeenCalledTimes(1); + const [message] = warnSpy.mock.calls[0] as [string]; + expect(message).toContain("unauthenticated"); + expect(message).toContain("authserver >= 0.1.2"); + expect(message).toContain("active: false"); + expect(message).toContain("every token will be rejected"); + const token = await mintToken({ privateKey: server.privateKey, issuer: server.issuer, @@ -896,6 +911,134 @@ describe("AuthplaneResource with IntrospectionRevocation without asCredentials", const claims = await resource.verify(token); expect(claims.sub).toBe("user_1"); + expect(server.introspectionAuthorization).toEqual([undefined]); + } finally { + await client.close(); + } + } finally { + await new Promise((resolve, reject) => + server.server.close((err) => (err ? reject(err) : resolve())), + ); + } + }); + + it("warns when an IntrospectionConfig carries a clientId but an empty clientSecret", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const server = await startAuthServer({ introspectionActive: true }); + try { + const client = await AuthplaneClient.create({ issuer: server.issuer, devMode: true }); + try { + client.resource({ + resource: server.resource, + scopes: [], + revocationChecker: { clientId: "rs-client", clientSecret: "" }, + }); + expect(warnSpy).toHaveBeenCalledTimes(1); + expect(String(warnSpy.mock.calls[0]?.[0])).toContain("authserver >= 0.1.2"); + } finally { + await client.close(); + } + } finally { + await new Promise((resolve, reject) => + server.server.close((err) => (err ? reject(err) : resolve())), + ); + } + }); + + it("does not warn at construction when asCredentials are complete", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const server = await startAuthServer({ introspectionActive: true }); + try { + const client = await AuthplaneClient.create({ issuer: server.issuer, devMode: true }); + try { + client.resource({ + resource: server.resource, + scopes: [], + asCredentials: { clientId: "rs-client", clientSecret: "s3cret" }, + revocationChecker: IntrospectionRevocation.get(), + }); + expect(warnSpy).not.toHaveBeenCalled(); + } finally { + await client.close(); + } + } finally { + await new Promise((resolve, reject) => + server.server.close((err) => (err ? reject(err) : resolve())), + ); + } + }); +}); + +describe("AuthplaneResource introspection ownership warning", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("logs the runtime-client guidance once when active=false follows a valid JWT", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const server = await startAuthServer({ introspectionActive: false }); + try { + const client = await AuthplaneClient.create({ issuer: server.issuer, devMode: true }); + try { + const resource = client.resource({ + resource: server.resource, + scopes: [], + asCredentials: { clientId: "rs-client", clientSecret: "s3cret" }, + revocationChecker: IntrospectionRevocation.get(), + }); + // Credentials are complete, so nothing was logged at construction and + // every warning below is the ownership one. + expect(warnSpy).not.toHaveBeenCalled(); + + const token = await mintToken({ + privateKey: server.privateKey, + issuer: server.issuer, + audience: server.resource, + }); + + await expect(resource.verify(token)).rejects.toBeInstanceOf(TokenRevoked); + expect(warnSpy).toHaveBeenCalledTimes(1); + const [message] = warnSpy.mock.calls[0] as [string]; + expect(message).toContain("jti=jti_1"); + expect(message).toContain("did not recognise this resource server as the token's owner"); + expect(message).toContain("runtime-client"); + expect(message).toContain( + "authserver admin resource runtime-client add --client-id --slug ", + ); + + // Rate-limited: a second rejection on the same resource is silent. + await expect(resource.verify(token)).rejects.toBeInstanceOf(TokenRevoked); + expect(warnSpy).toHaveBeenCalledTimes(1); + } finally { + await client.close(); + } + } finally { + await new Promise((resolve, reject) => + server.server.close((err) => (err ? reject(err) : resolve())), + ); + } + }); + + it("does not log the ownership guidance for a custom RevocationChecker", async () => { + const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const server = await startAuthServer(); + try { + const client = await AuthplaneClient.create({ issuer: server.issuer, devMode: true }); + try { + const resource = client.resource({ + resource: server.resource, + scopes: [], + revocationChecker: async () => true, + }); + + const token = await mintToken({ + privateKey: server.privateKey, + issuer: server.issuer, + audience: server.resource, + }); + + await expect(resource.verify(token)).rejects.toBeInstanceOf(TokenRevoked); + expect(warnSpy).not.toHaveBeenCalled(); } finally { await client.close(); } diff --git a/scripts/manual-e2e-setup.sh b/scripts/manual-e2e-setup.sh index 82c2b65..46d0808 100755 --- a/scripts/manual-e2e-setup.sh +++ b/scripts/manual-e2e-setup.sh @@ -12,6 +12,8 @@ Usage: Environment (optional): AUTHSERVER_DIR Path to local authserver repo (default: ../authserver) + AUTHSERVER_REF Git ref of authserver to check out before building + (default: leave the checkout as is) EOF } @@ -25,9 +27,15 @@ if [ ! -d "${AUTHSERVER_DIR}" ]; then exit 1 fi -echo "==> Starting authserver demo server (client_credentials enabled)" +echo "==> Starting authserver demo server" ( cd "${AUTHSERVER_DIR}" + if [ -n "${AUTHSERVER_REF:-}" ]; then + echo "==> Checking out authserver ${AUTHSERVER_REF}" + git fetch --tags origin + git checkout "${AUTHSERVER_REF}" + rm -f bin/authserver + fi if [ ! -x "bin/authserver" ]; then if [ -d "cmd/authserver" ]; then go build -o bin/authserver ./cmd/authserver @@ -40,7 +48,7 @@ echo "==> Starting authserver demo server (client_credentials enabled)" echo "ERROR: authserver binary is not executable at ${AUTHSERVER_DIR}/bin/authserver" >&2 exit 1 fi - AUTHPLANE_CLIENT_CREDENTIALS_ENABLED=true ./demo/mcp-demo-server-start.sh + ./demo/mcp-demo-server-start.sh ) echo ""