From fac4e44a4416d2d23691b913a83e9d12e84efe33 Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Mon, 10 Aug 2026 11:37:06 -0300 Subject: [PATCH 1/5] ci: pin the conformance catalog by SHA instead of cloning its default branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI cloned github.com/AuthPlane/conformance at its default branch, so any change to the catalog reached this repo immediately — a case added there could turn an unrelated PR red here with nothing in this repo having changed. Pinning decouples them: a catalog change arrives only when this repo deliberately bumps the pin together with the coverage for it. The revision is single-sourced in a tracked .conformance-catalog-ref, guarded by a 40-hex shape check before the fetch so a branch or tag name cannot silently un-pin CI, and read by both ci.yml and release.yml. A weekly conformance-catalog-drift workflow clones the unpinned tip and runs the alignment assertion, so new cases surface as an early warning instead of a surprise at bump time. No SDK source changes. Verified locally against the pinned revision: the catalog alignment assertion passes, the conformance suite is green (104 passed, 1 xfailed — the xfail is pre-existing and unrelated), and the core suite is green (525). The pin adopts no new case ids, so it requires no coverage change. --- .conformance-catalog-ref | 1 + .github/workflows/ci.yml | 15 ++++- .../workflows/conformance-catalog-drift.yml | 64 +++++++++++++++++++ .github/workflows/release.yml | 15 ++++- 4 files changed, 93 insertions(+), 2 deletions(-) create mode 100644 .conformance-catalog-ref create mode 100644 .github/workflows/conformance-catalog-drift.yml diff --git a/.conformance-catalog-ref b/.conformance-catalog-ref new file mode 100644 index 0000000..efa9db0 --- /dev/null +++ b/.conformance-catalog-ref @@ -0,0 +1 @@ +b4c758a7dac698d7fcacd32dafcd4bb2f5dbddaf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 23a065a..747fe7f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,20 @@ jobs: - name: Clone shared conformance catalog (out of tree) if: matrix.package == 'root' run: | - git clone --depth 1 https://github.com/AuthPlane/conformance.git "$RUNNER_TEMP/conformance" + # Conformance catalog pinned by SHA, single-sourced from the tracked + # .conformance-catalog-ref at the repo root (read from the checked-out + # workspace, so the Checkout step above must precede this one). Bump + # that file when adopting new catalog cases, together with the SDK-side + # conformance coverage, so a catalog change can never break CI on its + # own. Source: github.com/AuthPlane/conformance. + CONFORMANCE_CATALOG_REF="$(cat "$GITHUB_WORKSPACE/.conformance-catalog-ref")" + grep -Eq '^[0-9a-f]{40}$' <<<"$CONFORMANCE_CATALOG_REF" \ + || { echo "::error::.conformance-catalog-ref must be a 40-hex commit SHA"; exit 1; } + git init -q "$RUNNER_TEMP/conformance" + git -C "$RUNNER_TEMP/conformance" \ + fetch --depth=1 https://github.com/AuthPlane/conformance.git "$CONFORMANCE_CATALOG_REF" \ + || { echo "::error::Pinned conformance catalog ref $CONFORMANCE_CATALOG_REF is unreachable"; exit 1; } + git -C "$RUNNER_TEMP/conformance" checkout -q FETCH_HEAD - name: Setup Python uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 diff --git a/.github/workflows/conformance-catalog-drift.yml b/.github/workflows/conformance-catalog-drift.yml new file mode 100644 index 0000000..09a48f5 --- /dev/null +++ b/.github/workflows/conformance-catalog-drift.yml @@ -0,0 +1,64 @@ +name: Conformance catalog drift + +# Weekly (plus on-demand) check that the SDK's @pytest.mark.conformance markers +# still cover the LATEST conformance catalog default branch, independent of the +# pinned SHA that gates PR CI (.conformance-catalog-ref). A newly added, +# uncovered catalog case FAILS this scheduled job so the drift is visible on the +# Actions dashboard; it never breaks PR CI, which has no pull_request trigger and +# runs against the pinned .conformance-catalog-ref. + +on: + schedule: + # Mondays 06:00 UTC + - cron: "0 6 * * 1" + workflow_dispatch: + +# Least-privilege default; this workflow only reads the repo. +permissions: + contents: read + +jobs: + drift: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Python 3.11 + uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 + with: + python-version: "3.11" + + - name: Install package dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + # Intentionally UNPINNED: track the catalog's default branch so newly + # added cases surface here. PR CI stays on the pinned .conformance-catalog-ref. + - name: Clone latest conformance catalog default branch (out of tree) + run: | + git clone --depth 1 https://github.com/AuthPlane/conformance.git "$RUNNER_TEMP/conformance" + + - name: Check catalog alignment against the latest catalog + id: align + env: + AUTHPLANE_CONFORMANCE_CATALOG: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml + run: | + pytest conformance-tests/test_catalog_alignment.py -v + + - name: Report drift + if: always() + run: | + if [ "${{ steps.align.outcome }}" = "success" ]; then + echo "Conformance markers cover the latest catalog default branch." >> "$GITHUB_STEP_SUMMARY" + else + echo "::warning::Conformance catalog drift detected: the SDK's @pytest.mark.conformance markers do not cover every case in the latest catalog default branch. Extend coverage in conformance-tests/, then bump .conformance-catalog-ref to adopt the new cases." + { + echo "## Conformance catalog drift detected" + echo "" + echo "The SDK's \`@pytest.mark.conformance\` markers do not cover every case in the **latest** conformance catalog default branch." + echo "PR CI is unaffected — it runs against the pinned \`.conformance-catalog-ref\`." + echo "Extend coverage in \`conformance-tests/\`, then bump \`.conformance-catalog-ref\` to adopt the new cases." + } >> "$GITHUB_STEP_SUMMARY" + fi diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 57d52aa..b3cd8b3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -96,7 +96,20 @@ jobs: # need its auth/persist-credentials features for a public read-only repo. - name: Clone shared conformance catalog (out of tree) run: | - git clone --depth 1 https://github.com/AuthPlane/conformance.git "$RUNNER_TEMP/conformance" + # Conformance catalog pinned by SHA, single-sourced from the tracked + # .conformance-catalog-ref at the repo root (read from the checked-out + # workspace, so the Checkout step above must precede this one). Bump + # that file when adopting new catalog cases, together with the SDK-side + # conformance coverage, so a catalog change can never break CI on its + # own. Source: github.com/AuthPlane/conformance. + CONFORMANCE_CATALOG_REF="$(cat "$GITHUB_WORKSPACE/.conformance-catalog-ref")" + grep -Eq '^[0-9a-f]{40}$' <<<"$CONFORMANCE_CATALOG_REF" \ + || { echo "::error::.conformance-catalog-ref must be a 40-hex commit SHA"; exit 1; } + git init -q "$RUNNER_TEMP/conformance" + git -C "$RUNNER_TEMP/conformance" \ + fetch --depth=1 https://github.com/AuthPlane/conformance.git "$CONFORMANCE_CATALOG_REF" \ + || { echo "::error::Pinned conformance catalog ref $CONFORMANCE_CATALOG_REF is unreachable"; exit 1; } + git -C "$RUNNER_TEMP/conformance" checkout -q FETCH_HEAD - name: Set up Python 3.11 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 From e98db960a9b2c70bbe23a17043453bf5b05ff070 Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Wed, 5 Aug 2026 08:43:25 -0300 Subject: [PATCH 2/5] fix(sdk,mcp,fastmcp): preserve issuer identity, serve PRM verbatim, raise mcp floor past PYSEC-2026-3483 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - authplane-sdk: issuer identifiers are stored and compared byte-for-byte (RFC 9068 iss, RFC 8414 §3.3) — no trailing-slash stripping on storage or comparison; query- or fragment-bearing issuers are rejected at AuthplaneClient.create() (RFC 8414 §2). Well-known derivation still strips the terminating slash (RFC 8414/9728 §3.1); build_prm_url keeps the resource query and rejects fragment-bearing resources (RFC 8707 §2). - authplane-mcp, authplane-fastmcp: the served Protected Resource Metadata advertises the configured issuer and resource byte-for-byte instead of the AnyHttpUrl-normalised form (RFC 8414/9728 §3.3). - authplane-mcp: mcp floor raised to >=1.28.1,<2 (PYSEC-2026-3483); the elicitation-id field name is resolved from the model schema instead of being hard-coded. authplane-fastmcp declares the same direct floor. - ci: single-source the conformance catalog pin in .conformance-catalog-ref (40-hex guarded) and add a scheduled drift check; pin ruff to >=0.16,<0.17 so formatting stays stable. --- CHANGELOG.md | 13 + CONTRIBUTING.md | 5 + authplane-fastmcp/README.md | 4 + authplane-fastmcp/authplane_fastmcp/_prm.py | 129 +++++++++ authplane-fastmcp/authplane_fastmcp/auth.py | 54 +++- .../authplane_fastmcp/url_elicitation.py | 92 ++++++- authplane-fastmcp/pyproject.toml | 6 + authplane-fastmcp/tests/conftest.py | 7 +- authplane-fastmcp/tests/test_auth_factory.py | 10 +- authplane-fastmcp/tests/test_integration.py | 31 ++- authplane-fastmcp/tests/test_prm.py | 59 +++++ .../tests/test_url_elicitation.py | 128 ++++++++- .../tests/test_verifier_dpop_cache.py | 6 +- authplane-mcp/README.md | 8 +- authplane-mcp/authplane_mcp/_prm.py | 129 +++++++++ authplane-mcp/authplane_mcp/auth.py | 123 ++++++--- .../authplane_mcp/url_elicitation.py | 92 ++++++- authplane-mcp/authplane_mcp/verifier.py | 11 + authplane-mcp/docs/user-guide.md | 6 +- authplane-mcp/pyproject.toml | 12 +- authplane-mcp/tests/test_integration.py | 94 +++++++ authplane-mcp/tests/test_prm.py | 59 +++++ authplane-mcp/tests/test_url_elicitation.py | 130 ++++++++- .../tests/test_verifier_dpop_cache.py | 6 +- authplane/client.py | 24 +- authplane/docs/user-guide.md | 3 +- authplane/internal/metadata.py | 19 +- authplane/internal/urls.py | 72 ++++- conformance-tests/README.md | 11 +- llm-full.txt | 5 +- llm.txt | 5 +- tests/internal/test_metadata.py | 13 +- tests/test_issuer_identity.py | 246 ++++++++++++++++++ 33 files changed, 1519 insertions(+), 93 deletions(-) create mode 100644 authplane-fastmcp/authplane_fastmcp/_prm.py create mode 100644 authplane-fastmcp/tests/test_prm.py create mode 100644 authplane-mcp/authplane_mcp/_prm.py create mode 100644 authplane-mcp/tests/test_integration.py create mode 100644 authplane-mcp/tests/test_prm.py create mode 100644 tests/test_issuer_identity.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 47358c7..0d29b0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,10 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +> **Versioning:** This entry contains breaking changes. The project is pre-1.0 (`0.x`); per SemVer, breaking changes on the `0.x` line ship in the next **minor** (targeting `0.4.0`), not a major bump. `RELEASE_POLICY.md`'s "major bump for breaking changes" rule takes effect once the project reaches `1.0.0`. + ### Added - `authplane-fastmcp`, `authplane-mcp`: `authplane_auth()` and `authplane_mcp_auth()` accept `fail_closed: bool = False` and forward it to `AuthplaneClient.resource(...)`. - `AuthplaneClient.resource(...)` logs a warning when `fail_closed=True` is set without a `revocation_checker`. +### Security +- `authplane-mcp`: the `mcp` dependency floor is now `>=1.28.1` (was `>=1.23.0`), pulling in the fix for [PYSEC-2026-3483](https://osv.dev/vulnerability/PYSEC-2026-3483), which affects `mcp <=1.28.0`. +- `authplane-fastmcp`: now declares a direct `mcp>=1.28.1,<2` dependency. The adapter imports the top-level `mcp` package directly (e.g. `mcp.shared.exceptions`, `mcp.types`), so the PYSEC-2026-3483 floor must be pinned here explicitly — the transitive `fastmcp>=3.2,<4` dependency does not guarantee it. + +### Fixed +- `authplane-fastmcp`, `authplane-mcp`: the Protected Resource Metadata now advertises the configured issuer (`authorization_servers`) and `resource` byte-for-byte. The adapters serve the PRM through upstream MCP's `pydantic.AnyHttpUrl` fields, which normalize an empty-path authority with a trailing slash (`https://auth.example.com` → `https://auth.example.com/`); after the core SDK began comparing these identifiers verbatim (RFC 8414 §3.3, RFC 9728 §3.3), a client that followed the advertised value literally was rejected by the strict comparison and tokens minted for the advertised `resource` failed the `aud` check. The served document is now rewritten so both identifiers match the configured strings, leaving every other PRM field untouched. `authplane-fastmcp` applies this automatically; `authplane-mcp` applies it inside `install_request_context(mcp)`, so call that after constructing `FastMCP`. + +### Changed +- **BREAKING (pre-1.0)** `authplane-mcp`: the supported `mcp` range is now `>=1.28.1, <2.0.0` (was `>=1.23.0, <1.28.0`). The adapter still targets the mcp 1.x server API (`mcp.server.fastmcp.FastMCP`) and the camelCase URL-elicitation field (`ElicitRequestURLParams(elicitationId=...)`), which are the current 1.x shape. As a belt-and-braces measure the adapter no longer hard-codes the field spelling: it resolves the elicitation-id field name from the model's own schema, so a rename within 1.x is picked up automatically. The upper bound excludes mcp 2.0, which removes `mcp.server.fastmcp` and renames the elicitation field to snake_case `elicitation_id`. **Migration:** projects on `mcp <1.28.1` must upgrade to at least `1.28.1`; projects on `mcp 2.0` are not yet supported by this adapter — track the mcp 2.0 port separately. +- Issuer identifiers are now stored and compared byte-for-byte (RFC 9068 `iss`, RFC 8414 §3.3). The configured issuer is no longer trailing-slash-stripped before storage, and the AS-metadata issuer comparison no longer strips either side — a metadata document whose `issuer` differs from the configured issuer only by a trailing slash is now correctly rejected. This fixes an outage for authorization servers whose issuer ends in `/`: such an AS mints tokens whose `iss` keeps the slash, and the SDK was comparing them against the stripped form, rejecting every token. Building `.well-known` discovery URLs still strips the terminating slash (RFC 8414/9728 §3.1) — that is derivation, not identity, and is unchanged. `build_prm_url` now also preserves the resource's query component in the derived Protected Resource Metadata URL (RFC 9728 §3.1), while a fragment-bearing resource — for which `build_prm_url` and the resource `prm_url()` previously returned a (fragment-stripped) URL — is now rejected with a `ValueError` (RFC 8707 §2 forbids a fragment in a resource indicator). A query-bearing **or fragment-bearing** issuer (RFC 8414 §2 forbids both a query and a fragment component in the issuer identifier) is now rejected at `AuthplaneClient.create()` with a clear `ValueError` instead of being silently stripped and later surfacing as a confusing "issuer mismatch". **Migration:** If your configured issuer differs from your authorization server's actual identifier by a trailing slash, correct the config — the SDK no longer silently reconciles them. If your configured issuer carries a query or fragment component, remove it. If your resource identifier carries a fragment component, remove it — `build_prm_url` / `prm_url()` now raise instead of returning a fragment-stripped URL. + ## [0.3.0] - 2026-07-21 ### Added diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 52d8543..55f246a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -82,8 +82,13 @@ The conformance suite in `conformance-tests/` validates the SDK against the shar ```bash # From the directory that contains your python-sdk/ clone git clone https://github.com/AuthPlane/conformance.git + +# Check out the same catalog revision CI pins, so local runs match CI exactly. +git -C conformance checkout "$(cat python-sdk/.conformance-catalog-ref)" ``` +CI runs the suite against the catalog revision pinned in `.conformance-catalog-ref` at the repo root, not the catalog's latest default branch — checking out that revision locally keeps your results aligned with CI. (A separate scheduled `conformance-catalog-drift` workflow tracks the latest catalog and fails when new cases need coverage.) + Expected layout: ``` diff --git a/authplane-fastmcp/README.md b/authplane-fastmcp/README.md index f061e60..7678991 100644 --- a/authplane-fastmcp/README.md +++ b/authplane-fastmcp/README.md @@ -11,6 +11,10 @@ Authplane JWT validation for servers built on [FastMCP](https://github.com/Prefe pip install authplane-fastmcp ``` +## Compatibility + +Supported `fastmcp` range: **`>=3.2, <4.0.0`**. This adapter also imports the top-level `mcp` package directly (`mcp.shared.exceptions`, `mcp.types`), so it carries its own `mcp` constraint: **`>=1.28.1, <2.0.0`**. The floor is `1.28.1` because earlier releases (`<=1.28.0`) are affected by [PYSEC-2026-3483](https://osv.dev/vulnerability/PYSEC-2026-3483), fixed in `1.28.1`; `fastmcp>=3.2` alone does not guarantee that floor. The adapter targets the mcp 1.x camelCase URL-elicitation field (`ElicitRequestURLParams(elicitationId=...)`), which is the shape of the current 1.x line. As a belt-and-braces measure the adapter does not hard-code that spelling: it resolves the elicitation-id field name from the model's own schema — a known spelling is checked at import, then resolved per call — so a rename within 1.x would be picked up automatically rather than breaking the consent path. mcp 2.0 is not yet supported: it renames the elicitation field to snake_case `elicitation_id`, which is a separate port. If your project needs mcp 2.0, please open an issue. + ## Quickstart ```python diff --git a/authplane-fastmcp/authplane_fastmcp/_prm.py b/authplane-fastmcp/authplane_fastmcp/_prm.py new file mode 100644 index 0000000..f660784 --- /dev/null +++ b/authplane-fastmcp/authplane_fastmcp/_prm.py @@ -0,0 +1,129 @@ +"""Serve the Protected Resource Metadata identifiers verbatim. + +The core SDK stores and compares the issuer / resource identifier byte-for-byte +(RFC 8414 §3.3, RFC 9728 §3.3). The upstream MCP machinery that serves the PRM +document types ``authorization_servers`` and ``resource`` as +``pydantic.AnyHttpUrl``, which normalizes an empty-path authority by appending a +trailing slash (``https://auth.example.com`` -> ``https://auth.example.com/``). +A client that follows that advertised value literally then does discovery and +audience checks against the slashed form and is rejected by the strict +comparison ("issuer mismatch"), and a token minted for the advertised +``resource`` fails the verbatim ``aud`` check. + +This module post-processes the served PRM response so the two identifier fields +carry exactly the operator-configured strings, without touching any other field +(scopes, bearer methods, cache headers, CORS) the upstream route emits. +""" + +import json +from collections.abc import Awaitable, Callable, MutableSequence +from typing import Any + +from starlette.routing import BaseRoute, Route + +_PRM_PATH_PREFIX = "/.well-known/oauth-protected-resource" + +_Scope = dict[str, Any] +_Message = dict[str, Any] +_Receive = Callable[[], Awaitable[_Message]] +_Send = Callable[[_Message], Awaitable[None]] +_ASGIApp = Callable[[_Scope, _Receive, _Send], Awaitable[None]] + + +def _rewrite_body(body: bytes, *, issuer: str, resource: str) -> bytes: + """Return the PRM JSON body with the configured identifiers set verbatim. + + Rewrites only the entries that match the configured identifier up to a + trailing-slash normalization: in ``authorization_servers`` the element equal + to ``issuer`` or ``issuer + "/"`` is swapped for the verbatim ``issuer`` and + every other entry is left in place, so a multi-AS advertisement keeps its + extra entries. ``resource`` is set verbatim. + + Any body that is not a JSON object (e.g. a CORS preflight with an empty + body) is returned unchanged. + """ + try: + doc = json.loads(body) + except (ValueError, TypeError): + return body + if not isinstance(doc, dict): + return body + changed = False + servers = doc.get("authorization_servers") + if isinstance(servers, list): + rewritten = [issuer if entry in (issuer, issuer + "/") else entry for entry in servers] + if rewritten != servers: + doc["authorization_servers"] = rewritten + changed = True + if "resource" in doc and doc["resource"] != resource: + doc["resource"] = resource + changed = True + if not changed: + return body + return json.dumps(doc, separators=(",", ":")).encode("utf-8") + + +def _wrap_app(inner: _ASGIApp, *, issuer: str, resource: str) -> _ASGIApp: + """Wrap an ASGI app so a JSON PRM body is rewritten before it is sent. + + The PRM document is small and always flushed in a single body frame, so + the wrapper buffers the whole body, rewrites it, then emits the (possibly + resized) response in one shot. + """ + + async def app(scope: _Scope, receive: _Receive, send: _Send) -> None: + if scope.get("type") != "http": + await inner(scope, receive, send) + return + + start: _Message | None = None + chunks: list[bytes] = [] + + async def capture(message: _Message) -> None: + nonlocal start + message_type = message["type"] + if message_type == "http.response.start": + # Defer the start frame until the body is assembled so the + # Content-Length header can be corrected for the rewrite. + start = message + return + if message_type == "http.response.body": + chunks.append(message.get("body", b"")) + if message.get("more_body", False): + return + if start is None: + # An ASGI server must send http.response.start before any + # http.response.body frame; guard explicitly rather than + # asserting, since ``assert`` is stripped under ``python -O``. + raise RuntimeError("http.response.body received before http.response.start") + new_body = _rewrite_body(b"".join(chunks), issuer=issuer, resource=resource) + headers = [ + (key, value) + for (key, value) in start.get("headers", []) + if key.lower() != b"content-length" + ] + headers.append((b"content-length", str(len(new_body)).encode("latin-1"))) + await send({**start, "headers": headers}) + await send({"type": "http.response.body", "body": new_body}) + return + await send(message) + + await inner(scope, receive, capture) + + return app + + +def rewrite_prm_routes_verbatim( + routes: MutableSequence[BaseRoute], *, issuer: str, resource: str +) -> None: + """Wrap, in place, every Protected Resource Metadata route in ``routes``. + + Matches routes registered under ``/.well-known/oauth-protected-resource`` + (RFC 9728 §3) and swaps their ASGI app for one that advertises ``issuer`` + and ``resource`` verbatim. + """ + for route in routes: + if isinstance(route, Route) and ( + route.path == _PRM_PATH_PREFIX or route.path.startswith(_PRM_PATH_PREFIX + "/") + ): + route.app = _wrap_app(route.app, issuer=issuer, resource=resource) diff --git a/authplane-fastmcp/authplane_fastmcp/auth.py b/authplane-fastmcp/authplane_fastmcp/auth.py index f491134..ce5169b 100644 --- a/authplane-fastmcp/authplane_fastmcp/auth.py +++ b/authplane-fastmcp/authplane_fastmcp/auth.py @@ -20,11 +20,50 @@ from authplane.oauth import TokenExchangeOptions, TokenResponse from fastmcp.server.auth import RemoteAuthProvider from pydantic import AnyHttpUrl +from starlette.routing import Route +from ._prm import rewrite_prm_routes_verbatim from .url_elicitation import to_url_elicitation_required_error from .verifier import AuthplaneTokenVerifier +class _VerbatimPRMRemoteAuthProvider(RemoteAuthProvider): + """``RemoteAuthProvider`` that advertises identifiers verbatim in the PRM. + + Upstream builds the Protected Resource Metadata document from + ``pydantic.AnyHttpUrl`` fields, which normalize an empty-path authority by + appending a trailing slash. Since the core SDK compares the issuer / + resource identifier byte-for-byte (RFC 8414 §3.3, RFC 9728 §3.3), the + normalized value the base class would serve no longer matches what the + verifier accepts. This subclass keeps the whole upstream route (CORS, + caching, path handling, field set) and only rewrites the served + ``authorization_servers`` and ``resource`` back to the configured strings. + """ + + def __init__( + self, + *args: Any, + verbatim_issuer: str, + verbatim_resource: str, + **kwargs: Any, + ) -> None: + super().__init__(*args, **kwargs) + self._verbatim_issuer = verbatim_issuer + self._verbatim_resource = verbatim_resource + + def get_routes(self, *args: Any, **kwargs: Any) -> list[Route]: + # Forward whatever positional/keyword args the framework passes so a + # future signature change in the base ``get_routes`` cannot TypeError + # at app-build time; only the verbatim PRM rewrite below is ours. + routes = super().get_routes(*args, **kwargs) + rewrite_prm_routes_verbatim( + routes, + issuer=self._verbatim_issuer, + resource=self._verbatim_resource, + ) + return routes + + def _wrap_client_for_elicitation(client: AuthplaneClient) -> AuthplaneClient: """Translate ``client.exchange`` consent errors into MCP ``-32042``. @@ -287,12 +326,23 @@ async def authplane_auth( # Note: FastMCP uses token_verifier.base_url for PRM generation if provided token_verifier = AuthplaneTokenVerifier(verifier, base_url=base_url) - # Wrap in RemoteAuthProvider to get PRM routes - auth_provider = RemoteAuthProvider( + # Wrap in RemoteAuthProvider to get PRM routes. + # + # ``authorization_servers`` and ``base_url`` must be ``AnyHttpUrl`` — the + # upstream framework requires the URL type internally. That construction + # normalizes an empty-path authority with a trailing slash, so the served + # PRM would otherwise advertise ``https://auth.example.com/`` for an issuer + # configured as ``https://auth.example.com``. ``_VerbatimPRMRemoteAuthProvider`` + # rewrites the served ``authorization_servers`` / ``resource`` back to the + # verbatim configured strings so they match the core SDK's byte-for-byte + # comparison (RFC 8414 §3.3, RFC 9728 §3.3). + auth_provider = _VerbatimPRMRemoteAuthProvider( token_verifier=token_verifier, authorization_servers=[AnyHttpUrl(issuer)], base_url=AnyHttpUrl(base_url), scopes_supported=resolved_scopes, + verbatim_issuer=issuer, + verbatim_resource=resource, ) return AuthplaneAuthResult( diff --git a/authplane-fastmcp/authplane_fastmcp/url_elicitation.py b/authplane-fastmcp/authplane_fastmcp/url_elicitation.py index 3d7308b..5cad994 100644 --- a/authplane-fastmcp/authplane_fastmcp/url_elicitation.py +++ b/authplane-fastmcp/authplane_fastmcp/url_elicitation.py @@ -6,16 +6,103 @@ tool code sees them. This module exposes the underlying conversion as a small primitive for unusual flows where users build a consent error outside the wrapped client and want to raise the MCP-shaped error themselves. + +NOTE: this module is mirrored byte-for-byte in +``authplane-mcp/authplane_mcp/url_elicitation.py`` except for the adapter name +in the docstrings and the package name in the error string below. Any fix here +— in particular the eventual mcp-2.0 elicitation-field port — must be applied to +both copies. """ from __future__ import annotations +import functools +from typing import TYPE_CHECKING, Any from uuid import uuid4 from authplane.errors import ConsentRequiredError from mcp.shared.exceptions import UrlElicitationRequiredError from mcp.types import ElicitRequestURLParams +if TYPE_CHECKING: + from pydantic import BaseModel + + +@functools.cache +def _resolve_elicitation_id_kwarg(model: type[BaseModel]) -> str: + """Resolve the constructor kwarg for the elicitation-id field from the + model's own schema. + + mcp 1.x spells the field camelCase ``elicitationId``; mcp 2.0 renames it to + snake_case ``elicitation_id``. We look the name up *positively* from the + model rather than trying ``elicitationId=`` and catching ``ValidationError``: + ``ElicitRequestURLParams`` is declared ``extra="allow"``, so if a future + release renamed the field to an *optional* one, the camelCase kwarg would be + silently absorbed into ``__pydantic_extra__``, the renamed field would stay + unset, and no ``ValidationError`` would be raised — the client would then get + a ``-32042`` with no id at all (a silent failure worse than the 500 the + try/except was meant to prevent). That guard also swallowed unrelated + validation errors (e.g. a malformed ``url``) and was a pyright-strict call + error. + + Cached (``functools.cache``) keyed by the model class, so resolution is cheap + enough to run per build call; that keeps it lazy — a test can patch the + module's ``ElicitRequestURLParams`` and exercise the resolve→build wiring + without patching any resolved module state. + """ + fields = model.model_fields + for name in ("elicitationId", "elicitation_id"): + field = fields.get(name) + if field is not None: + # A rename can arrive as an alias rather than a field rename. + # Pydantic resolves a validation kwarg by ``validation_alias`` when + # it is set, falling back to the generic ``alias``; mirror that order + # here. A non-str validation alias — AliasPath / AliasChoices, e.g. + # from ``validation_alias=AliasChoices(...)`` — is not a usable single + # kwarg, so fall through to the generic alias, else the field name. + if isinstance(field.validation_alias, str): + return field.validation_alias + if field.alias is not None: + return field.alias + return name + # Neither known spelling is a declared field. With ``extra="allow"`` a + # default kwarg would land silently in ``__pydantic_extra__`` (a ``-32042`` + # with no id). The ``mcp<2`` ceiling means this branch can only be reached + # inside mcp 1.x, so a third spelling is an unexpected schema change: fail + # loudly rather than emit a malformed elicitation. + raise ImportError( + f"authplane-fastmcp cannot resolve the elicitation-id field on {model.__name__!r}: " + "none of the known spellings (elicitationId, elicitation_id) is a declared " + "field. The installed mcp is not compatible; require mcp>=1.28.1,<2." + ) + + +# Fail fast at import: the installed mcp must expose a known elicitation-id +# spelling. Resolution is otherwise lazy (see _build_url_elicitation_params) so +# tests can patch the model without re-triggering this. The bare call exists +# only for its import-time validation side effect; no name is bound. +_resolve_elicitation_id_kwarg(ElicitRequestURLParams) + + +def _build_url_elicitation_params( + *, url: str, message: str, elicitation_id: str +) -> ElicitRequestURLParams: + """Construct ``ElicitRequestURLParams`` under the elicitation-id field name + the installed mcp uses (camelCase ``elicitationId`` on 1.x, snake_case + ``elicitation_id`` on 2.0), resolved from the model's own schema. + """ + # Resolve lazily from the module-level model so a test can patch only + # ``ElicitRequestURLParams`` and have this composition pick up the change. + # ``kwargs`` is typed ``dict[str, Any]`` because the model's params are not + # all ``str``; that silences the pyright-strict reportCallIssue on unpack. + kwargs: dict[str, Any] = { + _resolve_elicitation_id_kwarg(ElicitRequestURLParams): elicitation_id, + "mode": "url", + "url": url, + "message": message, + } + return ElicitRequestURLParams(**kwargs) + def to_url_elicitation_required_error( error: BaseException, @@ -31,11 +118,10 @@ def to_url_elicitation_required_error( return UrlElicitationRequiredError( elicitations=[ - ElicitRequestURLParams( - mode="url", + _build_url_elicitation_params( url=error.consent_url, - elicitationId=str(uuid4()), message=error.describe(), + elicitation_id=str(uuid4()), ) ], message=str(error), diff --git a/authplane-fastmcp/pyproject.toml b/authplane-fastmcp/pyproject.toml index f591ed9..9012bc7 100644 --- a/authplane-fastmcp/pyproject.toml +++ b/authplane-fastmcp/pyproject.toml @@ -30,6 +30,12 @@ dependencies = [ # re-verify the `FastMCP` constructor + `fastmcp.server.auth.require_scopes` # surface the adapter consumes. "fastmcp>=3.2,<4", + # This adapter imports the top-level `mcp` package directly (e.g. + # `mcp.shared.exceptions`, `mcp.types`), so it needs a direct floor of + # 1.28.1 to reach the PYSEC-2026-3483 fix — `fastmcp>=3.2,<4` alone does not + # guarantee it. Upper bound excludes mcp 2.0, which renames the URL + # elicitation field to snake_case `elicitation_id` (a separate port). + "mcp>=1.28.1,<2", "pydantic>=2.0", ] diff --git a/authplane-fastmcp/tests/conftest.py b/authplane-fastmcp/tests/conftest.py index 37fe958..a0922d2 100644 --- a/authplane-fastmcp/tests/conftest.py +++ b/authplane-fastmcp/tests/conftest.py @@ -8,11 +8,12 @@ from authplane import AuthplaneResource, VerifiedClaims from fastmcp import FastMCP from fastmcp.dependencies import CurrentAccessToken -from fastmcp.server.auth import AccessToken, RemoteAuthProvider, require_scopes +from fastmcp.server.auth import AccessToken, require_scopes from httpx import ASGITransport, AsyncClient from pydantic import AnyHttpUrl from authplane_fastmcp import AuthplaneTokenVerifier +from authplane_fastmcp.auth import _VerbatimPRMRemoteAuthProvider @pytest.fixture @@ -99,11 +100,13 @@ def fastmcp_app(token_verifier: AuthplaneTokenVerifier) -> FastMCP: Returns: FastMCP application instance """ - auth_provider = RemoteAuthProvider( + auth_provider = _VerbatimPRMRemoteAuthProvider( token_verifier=token_verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url=AnyHttpUrl("https://api.example.com"), scopes_supported=["tools/query", "tools/write", "tools/admin"], + verbatim_issuer="https://auth.example.com", + verbatim_resource="https://api.example.com/mcp", ) mcp = FastMCP("Test Server", auth=auth_provider) diff --git a/authplane-fastmcp/tests/test_auth_factory.py b/authplane-fastmcp/tests/test_auth_factory.py index d677eda..1349fa8 100644 --- a/authplane-fastmcp/tests/test_auth_factory.py +++ b/authplane-fastmcp/tests/test_auth_factory.py @@ -258,7 +258,7 @@ async def test_authplane_auth_returns_auth_result(): with ( patch("authplane_fastmcp.auth.AuthplaneClient") as mock_client_cls, - patch("authplane_fastmcp.auth.RemoteAuthProvider") as mock_auth_cls, + patch("authplane_fastmcp.auth._VerbatimPRMRemoteAuthProvider") as mock_auth_cls, ): mock_client_cls.create = AsyncMock(return_value=mock_client) result = await authplane_auth( @@ -271,6 +271,14 @@ async def test_authplane_auth_returns_auth_result(): assert result.token_verifier is not None assert result.client is mock_client + # Pin the verbatim keywords the factory forwards to the provider. The + # resource is base_url + mcp_path ("/mcp" by default), NOT base_url — + # asserting the exact value guards against a regression that passes + # base_url (or any wrong kwarg) as the verbatim resource/issuer. + provider_kwargs = mock_auth_cls.call_args.kwargs + assert provider_kwargs["verbatim_issuer"] == "https://auth.example.com" + assert provider_kwargs["verbatim_resource"] == "https://api.example.com/mcp" + def test_authplane_auth_result_keys(): """AuthplaneAuthResult.keys() returns only 'auth'.""" diff --git a/authplane-fastmcp/tests/test_integration.py b/authplane-fastmcp/tests/test_integration.py index 963510e..2b280b6 100644 --- a/authplane-fastmcp/tests/test_integration.py +++ b/authplane-fastmcp/tests/test_integration.py @@ -20,8 +20,15 @@ async def test_prm_endpoint(test_client: AsyncClient) -> None: # Verify PRM structure per RFC 9728 assert "resource" in prm assert "authorization_servers" in prm - # Pydantic AnyHttpUrl normalizes URLs with trailing slash - assert prm["authorization_servers"] == ["https://auth.example.com/"] + # The advertised issuer must be byte-for-byte the configured identifier. + # Upstream serializes it through pydantic AnyHttpUrl, which would append a + # trailing slash to the empty-path authority; the adapter rewrites the + # served value back to the verbatim form so it matches the core SDK's + # strict comparison (RFC 8414 §3.3, RFC 9728 §3.3). + assert prm["authorization_servers"] == ["https://auth.example.com"] + + # The advertised resource is likewise verbatim (no trailing slash added). + assert prm["resource"] == "https://api.example.com/mcp" assert "scopes_supported" in prm assert set(prm["scopes_supported"]) == { @@ -32,3 +39,23 @@ async def test_prm_endpoint(test_client: AsyncClient) -> None: assert "bearer_methods_supported" in prm assert "header" in prm["bearer_methods_supported"] + + +@pytest.mark.asyncio +async def test_prm_advertises_configured_issuer_without_trailing_slash( + test_client: AsyncClient, +) -> None: + """An issuer configured without a trailing slash is advertised verbatim. + + An empty-path authority is exactly where ``pydantic.AnyHttpUrl`` inserts a + trailing slash, so this pins the rewrite that keeps the advertised + identifier byte-for-byte the configured value. + """ + response = await test_client.get("/.well-known/oauth-protected-resource/mcp") + + assert response.status_code == 200 + prm = response.json() + assert prm["authorization_servers"] == ["https://auth.example.com"] + assert not prm["authorization_servers"][0].endswith("/") + # The resource keeps its exact configured form (path preserved, no slash added). + assert prm["resource"] == "https://api.example.com/mcp" diff --git a/authplane-fastmcp/tests/test_prm.py b/authplane-fastmcp/tests/test_prm.py new file mode 100644 index 0000000..3c7918d --- /dev/null +++ b/authplane-fastmcp/tests/test_prm.py @@ -0,0 +1,59 @@ +"""Unit tests for the verbatim-PRM body rewrite. + +``_rewrite_body`` swaps the configured identifiers back to their verbatim form +(the upstream MCP machinery serializes them through ``pydantic.AnyHttpUrl``, +which appends a trailing slash to an empty-path authority) without disturbing +any other advertised field. +""" + +import json + +from authplane_fastmcp._prm import _rewrite_body + +_ISSUER = "https://auth.example.com" +_RESOURCE = "https://api.example.com/mcp" + + +def _rewrite(doc: dict[str, object]) -> dict[str, object]: + out = _rewrite_body(json.dumps(doc).encode("utf-8"), issuer=_ISSUER, resource=_RESOURCE) + return json.loads(out) + + +def test_swaps_slashed_issuer_for_verbatim() -> None: + result = _rewrite({"authorization_servers": [_ISSUER + "/"], "resource": _RESOURCE + "/"}) + assert result["authorization_servers"] == [_ISSUER] + assert result["resource"] == _RESOURCE + + +def test_preserves_extra_authorization_server_entries() -> None: + other = "https://other-as.example.com/" + result = _rewrite({"authorization_servers": [_ISSUER + "/", other]}) + # Only the entry matching the configured issuer is rewritten; the extra AS + # entry is left exactly as advertised. + assert result["authorization_servers"] == [_ISSUER, other] + + +def test_preserves_unrelated_fields() -> None: + result = _rewrite( + { + "authorization_servers": [_ISSUER + "/"], + "resource": _RESOURCE, + "scopes_supported": ["tools/query"], + "bearer_methods_supported": ["header"], + } + ) + assert result["scopes_supported"] == ["tools/query"] + assert result["bearer_methods_supported"] == ["header"] + + +def test_non_json_body_returned_unchanged() -> None: + assert _rewrite_body(b"", issuer=_ISSUER, resource=_RESOURCE) == b"" + + +def test_body_untouched_when_nothing_to_rewrite() -> None: + # Already verbatim: the function returns the original bytes rather than + # re-serializing (so downstream Content-Length stays correct for a no-op). + original = json.dumps({"authorization_servers": [_ISSUER], "resource": _RESOURCE}).encode( + "utf-8" + ) + assert _rewrite_body(original, issuer=_ISSUER, resource=_RESOURCE) == original diff --git a/authplane-fastmcp/tests/test_url_elicitation.py b/authplane-fastmcp/tests/test_url_elicitation.py index 7eead95..23a201e 100644 --- a/authplane-fastmcp/tests/test_url_elicitation.py +++ b/authplane-fastmcp/tests/test_url_elicitation.py @@ -11,17 +11,19 @@ from __future__ import annotations from unittest.mock import AsyncMock +from uuid import UUID import pytest from authplane.errors import AuthError, ConsentRequiredError from authplane.oauth import TokenExchangeOptions from mcp.shared.exceptions import UrlElicitationRequiredError -from mcp.types import URL_ELICITATION_REQUIRED +from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams +from pydantic import BaseModel +import authplane_fastmcp.url_elicitation as url_elicitation from authplane_fastmcp.auth import ( _wrap_client_for_elicitation, # pyright: ignore[reportPrivateUsage] ) -from authplane_fastmcp.url_elicitation import to_url_elicitation_required_error _OPTIONS = TokenExchangeOptions(subject_token="test") @@ -40,7 +42,7 @@ def test_returns_url_elicitation_for_consent_with_url() -> None: status_code=400, ) - mapped = to_url_elicitation_required_error(error) + mapped = url_elicitation.to_url_elicitation_required_error(error) assert isinstance(mapped, UrlElicitationRequiredError) assert mapped.error.code == URL_ELICITATION_REQUIRED @@ -55,7 +57,7 @@ def test_returns_url_elicitation_for_consent_with_url() -> None: def test_returns_none_for_non_consent_error() -> None: assert ( - to_url_elicitation_required_error( + url_elicitation.to_url_elicitation_required_error( AuthError("bad request", code="invalid_request", status_code=400) ) is None @@ -69,7 +71,123 @@ def test_returns_none_for_consent_without_url() -> None: cause_detail="missing_user_consent", consent_url=None, ) - assert to_url_elicitation_required_error(error) is None + assert url_elicitation.to_url_elicitation_required_error(error) is None + + +def test_url_elicitation_builds_valid_params_under_installed_mcp() -> None: + # The consent-driven path must yield a genuine, schema-valid + # ``ElicitRequestURLParams`` with a fresh id under the installed mcp. + error = ConsentRequiredError( + "user must grant access", + service_id="calendar", + cause_detail="missing_user_consent", + consent_url="https://as.example.com/consent?service=calendar", + code="consent_required", + status_code=400, + ) + + mapped = url_elicitation.to_url_elicitation_required_error(error) + + assert isinstance(mapped, UrlElicitationRequiredError) + assert mapped.error.data is not None + elicitation = mapped.error.data["elicitations"][0] + UUID(elicitation["elicitationId"]) + rebuilt = ElicitRequestURLParams.model_validate(elicitation) + assert rebuilt.url == "https://as.example.com/consent?service=calendar" + assert rebuilt.mode == "url" + + +# --------------------------------------------------------------------------- +# Field-rename resilience (the argument for the `<2` ceiling) +# --------------------------------------------------------------------------- + + +class _StubRenamedElicit(BaseModel): + """Stand-in for a hypothetical mcp release that renamed the elicitation-id + field to snake_case ``elicitation_id`` (as mcp 2.0 does).""" + + mode: str + url: str + message: str + elicitation_id: str # required, snake_case + + +def test_schema_lookup_picks_snake_case_after_rename() -> None: + # The positive schema lookup resolves the constructor kwarg from the model + # itself, so a rename to ``elicitation_id`` is picked up rather than the + # camelCase kwarg silently landing in ``__pydantic_extra__``. + assert ( + url_elicitation._resolve_elicitation_id_kwarg( # pyright: ignore[reportPrivateUsage] + _StubRenamedElicit + ) + == "elicitation_id" + ) + + +class _NoElicitId(BaseModel): + """A model exposing neither known elicitation-id spelling.""" + + mode: str + url: str + message: str + + +def test_resolver_raises_when_no_known_spelling() -> None: + # With ``extra="allow"``, returning a default kwarg for a model that declares + # neither spelling would land it silently in ``__pydantic_extra__`` (a -32042 + # with no id). The resolver must instead raise, naming the unrecognized model. + with pytest.raises(ImportError, match="cannot resolve the elicitation-id field"): + url_elicitation._resolve_elicitation_id_kwarg( # pyright: ignore[reportPrivateUsage] + _NoElicitId + ) + + +def test_import_raises_when_model_lacks_known_spelling(monkeypatch: pytest.MonkeyPatch) -> None: + # The import-time resolution is the fail-fast: if the installed mcp exposes + # neither spelling, importing the module must raise (not defer a silent + # -32042). Patch the source model on ``mcp.types`` and reload the module. + import importlib + + import mcp.types + + monkeypatch.setattr(mcp.types, "ElicitRequestURLParams", _NoElicitId) + try: + with pytest.raises(ImportError, match="cannot resolve the elicitation-id field"): + importlib.reload(url_elicitation) + finally: + # Restore the real model and reload so the module (and its functools.cache) is + # left in a good state for the remaining tests. ``importlib.reload`` re-executes + # into the *same* module ``__dict__``, so this rebinds the module-level state + # left stale by the failed reload and the integration tests below resolve correctly. + monkeypatch.undo() + importlib.reload(url_elicitation) + + +def test_rename_path_still_yields_minus_32042_with_id(monkeypatch: pytest.MonkeyPatch) -> None: + # With the elicitation model renamed, the consent path must still produce a + # -32042 whose elicitation carries a populated id under the new field name — + # not a silent failure with a missing id. Patch ONLY the model: resolution is + # lazy, so `_build_url_elicitation_params` re-resolves the kwarg from the + # patched model. This exercises the resolve→build wiring end to end, rather + # than short-circuiting it by patching the resolved module state. + monkeypatch.setattr(url_elicitation, "ElicitRequestURLParams", _StubRenamedElicit) + + error = ConsentRequiredError( + "user must grant access", + service_id="calendar", + cause_detail="missing_user_consent", + consent_url="https://as.example.com/consent?service=calendar", + code="consent_required", + status_code=400, + ) + + mapped = url_elicitation.to_url_elicitation_required_error(error) + + assert isinstance(mapped, UrlElicitationRequiredError) + assert mapped.error.code == URL_ELICITATION_REQUIRED + assert mapped.error.data is not None + elicitation = mapped.error.data["elicitations"][0] + UUID(elicitation["elicitation_id"]) # --------------------------------------------------------------------------- diff --git a/authplane-fastmcp/tests/test_verifier_dpop_cache.py b/authplane-fastmcp/tests/test_verifier_dpop_cache.py index 26c294a..255ed01 100644 --- a/authplane-fastmcp/tests/test_verifier_dpop_cache.py +++ b/authplane-fastmcp/tests/test_verifier_dpop_cache.py @@ -296,9 +296,9 @@ async def test_htu_preserves_percent_encoded_path_from_raw_path() -> None: """htu uses ``scope['raw_path']`` so percent-encoding survives. ASGI populates ``scope['path']`` as the percent-decoded path, but the - DPoP proof was signed over the on-wire (still-encoded) URL. The TS - sibling reads ``IncomingMessage.url`` (raw bytes), so reading - ``raw_path`` here keeps cross-SDK proof binding identical. + DPoP proof was signed over the on-wire (still-encoded) URL. Reading + ``raw_path`` here keeps the DPoP ``htu`` binding byte-for-byte with the + proof's covered value (RFC 9449 §4.3). """ mock = _mock_verifier() # Decoded path: "/mcp/users/a/b" ; raw: "/mcp/users/a%2Fb" — a client diff --git a/authplane-mcp/README.md b/authplane-mcp/README.md index 428da67..1f93836 100644 --- a/authplane-mcp/README.md +++ b/authplane-mcp/README.md @@ -13,14 +13,14 @@ pip install authplane-mcp ## Compatibility -Supported `mcp` range: **`>=1.23.0, <1.28.0`**. MCP 1.28 renamed the elicitation field from `elicitationId` (camelCase) to `elicitation_id` (snake_case), which breaks this adapter's current wire handling. If your project needs MCP 1.28+, please open an issue — the adapter update is straightforward, we just haven't cut it yet. +Supported `mcp` range: **`>=1.28.1, <2.0.0`**. The floor is `1.28.1` because earlier releases (`<=1.28.0`) are affected by [PYSEC-2026-3483](https://osv.dev/vulnerability/PYSEC-2026-3483), fixed in `1.28.1`. The adapter targets the mcp 1.x server API (`mcp.server.fastmcp.FastMCP`) and the camelCase URL-elicitation field (`ElicitRequestURLParams(elicitationId=...)`), which are the shape of the current 1.x line. As a belt-and-braces measure the adapter does not hard-code that spelling: it resolves the elicitation-id field name from the model's own schema — a known spelling is checked at import, then resolved per call — so a rename within 1.x would be picked up automatically rather than breaking the consent path. mcp 2.0 is not yet supported: it removes `mcp.server.fastmcp` and renames the elicitation field to snake_case `elicitation_id`, which is a separate port. If your project needs mcp 2.0, please open an issue. ## Quickstart ```python import asyncio -from authplane_mcp import authplane_mcp_auth, require_scope +from authplane_mcp import authplane_mcp_auth, install_request_context, require_scope from mcp.server.fastmcp import FastMCP @@ -31,6 +31,10 @@ async def main() -> None: scopes=["tools/query", "tools/write"], ) mcp = FastMCP("My MCP Server", port=8080, json_response=True, **auth_result) + # Wires Authplane's per-app hooks onto the server: advertises the issuer / + # resource identifiers verbatim in the Protected Resource Metadata and + # installs the request-context middleware used by inbound DPoP enforcement. + install_request_context(mcp) @mcp.tool() async def query_database(query: str) -> str: diff --git a/authplane-mcp/authplane_mcp/_prm.py b/authplane-mcp/authplane_mcp/_prm.py new file mode 100644 index 0000000..f660784 --- /dev/null +++ b/authplane-mcp/authplane_mcp/_prm.py @@ -0,0 +1,129 @@ +"""Serve the Protected Resource Metadata identifiers verbatim. + +The core SDK stores and compares the issuer / resource identifier byte-for-byte +(RFC 8414 §3.3, RFC 9728 §3.3). The upstream MCP machinery that serves the PRM +document types ``authorization_servers`` and ``resource`` as +``pydantic.AnyHttpUrl``, which normalizes an empty-path authority by appending a +trailing slash (``https://auth.example.com`` -> ``https://auth.example.com/``). +A client that follows that advertised value literally then does discovery and +audience checks against the slashed form and is rejected by the strict +comparison ("issuer mismatch"), and a token minted for the advertised +``resource`` fails the verbatim ``aud`` check. + +This module post-processes the served PRM response so the two identifier fields +carry exactly the operator-configured strings, without touching any other field +(scopes, bearer methods, cache headers, CORS) the upstream route emits. +""" + +import json +from collections.abc import Awaitable, Callable, MutableSequence +from typing import Any + +from starlette.routing import BaseRoute, Route + +_PRM_PATH_PREFIX = "/.well-known/oauth-protected-resource" + +_Scope = dict[str, Any] +_Message = dict[str, Any] +_Receive = Callable[[], Awaitable[_Message]] +_Send = Callable[[_Message], Awaitable[None]] +_ASGIApp = Callable[[_Scope, _Receive, _Send], Awaitable[None]] + + +def _rewrite_body(body: bytes, *, issuer: str, resource: str) -> bytes: + """Return the PRM JSON body with the configured identifiers set verbatim. + + Rewrites only the entries that match the configured identifier up to a + trailing-slash normalization: in ``authorization_servers`` the element equal + to ``issuer`` or ``issuer + "/"`` is swapped for the verbatim ``issuer`` and + every other entry is left in place, so a multi-AS advertisement keeps its + extra entries. ``resource`` is set verbatim. + + Any body that is not a JSON object (e.g. a CORS preflight with an empty + body) is returned unchanged. + """ + try: + doc = json.loads(body) + except (ValueError, TypeError): + return body + if not isinstance(doc, dict): + return body + changed = False + servers = doc.get("authorization_servers") + if isinstance(servers, list): + rewritten = [issuer if entry in (issuer, issuer + "/") else entry for entry in servers] + if rewritten != servers: + doc["authorization_servers"] = rewritten + changed = True + if "resource" in doc and doc["resource"] != resource: + doc["resource"] = resource + changed = True + if not changed: + return body + return json.dumps(doc, separators=(",", ":")).encode("utf-8") + + +def _wrap_app(inner: _ASGIApp, *, issuer: str, resource: str) -> _ASGIApp: + """Wrap an ASGI app so a JSON PRM body is rewritten before it is sent. + + The PRM document is small and always flushed in a single body frame, so + the wrapper buffers the whole body, rewrites it, then emits the (possibly + resized) response in one shot. + """ + + async def app(scope: _Scope, receive: _Receive, send: _Send) -> None: + if scope.get("type") != "http": + await inner(scope, receive, send) + return + + start: _Message | None = None + chunks: list[bytes] = [] + + async def capture(message: _Message) -> None: + nonlocal start + message_type = message["type"] + if message_type == "http.response.start": + # Defer the start frame until the body is assembled so the + # Content-Length header can be corrected for the rewrite. + start = message + return + if message_type == "http.response.body": + chunks.append(message.get("body", b"")) + if message.get("more_body", False): + return + if start is None: + # An ASGI server must send http.response.start before any + # http.response.body frame; guard explicitly rather than + # asserting, since ``assert`` is stripped under ``python -O``. + raise RuntimeError("http.response.body received before http.response.start") + new_body = _rewrite_body(b"".join(chunks), issuer=issuer, resource=resource) + headers = [ + (key, value) + for (key, value) in start.get("headers", []) + if key.lower() != b"content-length" + ] + headers.append((b"content-length", str(len(new_body)).encode("latin-1"))) + await send({**start, "headers": headers}) + await send({"type": "http.response.body", "body": new_body}) + return + await send(message) + + await inner(scope, receive, capture) + + return app + + +def rewrite_prm_routes_verbatim( + routes: MutableSequence[BaseRoute], *, issuer: str, resource: str +) -> None: + """Wrap, in place, every Protected Resource Metadata route in ``routes``. + + Matches routes registered under ``/.well-known/oauth-protected-resource`` + (RFC 9728 §3) and swaps their ASGI app for one that advertises ``issuer`` + and ``resource`` verbatim. + """ + for route in routes: + if isinstance(route, Route) and ( + route.path == _PRM_PATH_PREFIX or route.path.startswith(_PRM_PATH_PREFIX + "/") + ): + route.app = _wrap_app(route.app, issuer=issuer, resource=resource) diff --git a/authplane-mcp/authplane_mcp/auth.py b/authplane-mcp/authplane_mcp/auth.py index a944584..f685757 100644 --- a/authplane-mcp/authplane_mcp/auth.py +++ b/authplane-mcp/authplane_mcp/auth.py @@ -5,6 +5,7 @@ to an official MCP Python SDK server in a single call. """ +import warnings from collections.abc import Iterator from typing import Any @@ -24,6 +25,7 @@ from pydantic import AnyHttpUrl from starlette.applications import Starlette +from ._prm import rewrite_prm_routes_verbatim from ._request_context import AuthplaneRequestContextMiddleware from .url_elicitation import to_url_elicitation_required_error from .verifier import AuthplaneTokenVerifier @@ -77,28 +79,40 @@ async def add(a: float, b: float) -> float: def install_request_context(mcp: FastMCP) -> None: - """Install :class:`AuthplaneRequestContextMiddleware` on a ``FastMCP`` server. - - Wraps ``mcp.streamable_http_app`` so the Starlette app it returns - runs :class:`AuthplaneRequestContextMiddleware` before MCP's - ``AuthenticationMiddleware``. That middleware publishes the active - :class:`starlette.requests.Request` on a ContextVar, which - :meth:`AuthplaneTokenVerifier.verify_token` reads to forward a - :class:`~authplane.DPoPRequestContext` to - :meth:`AuthplaneResource.verify`. - - The MCP SDK's ``FastMCP`` wires its middleware list internally with - no public hook for user middleware, so this is the least-invasive way - to slot ours in without subclassing or monkeypatching the SDK. - - Without this call, the verifier still works for non-DPoP flows, but - DPoP-bound requests fail closed: :func:`get_current_request` raises, - the verifier passes ``dpop_request=None``, and the core rejects bound - tokens with ``DPoPBindingMismatchError`` (and rejects bearer-only - tokens under ``inbound_dpop=InboundDPoPOptions(required=True)``). - The misconfiguration surfaces as a 401 on the first request rather - than as a silent bypass, so an operator who skips this call will - notice immediately. + """Wire Authplane's per-app hooks onto a ``FastMCP`` server. + + Wraps ``mcp.streamable_http_app`` so the Starlette app it returns is + post-processed with two Authplane concerns before it starts serving. + ``mcp.sse_app`` is wrapped with the second concern only — the SSE branch + applies just the verbatim-PRM rewrite, not the request-context middleware: + + 1. **Request context (DPoP).** :class:`AuthplaneRequestContextMiddleware` + is installed before MCP's ``AuthenticationMiddleware`` (streamable-HTTP + app only). That middleware publishes the active + :class:`starlette.requests.Request` on a ContextVar, which + :meth:`AuthplaneTokenVerifier.verify_token` reads to forward a + :class:`~authplane.DPoPRequestContext` to + :meth:`AuthplaneResource.verify`. + + 2. **Verbatim PRM identifiers.** The Protected Resource Metadata route the + MCP SDK auto-registers serves ``authorization_servers`` / ``resource`` + through ``pydantic.AnyHttpUrl``, which normalizes an empty-path + authority with a trailing slash. The core SDK compares the issuer / + resource identifier byte-for-byte (RFC 8414 §3.3, RFC 9728 §3.3), so the + served document is rewritten to advertise the operator-configured + identifiers verbatim — otherwise a client that follows the PRM literally + is rejected by the strict comparison ("issuer mismatch") and tokens + minted for the advertised ``resource`` fail the ``aud`` check. + + The MCP SDK's ``FastMCP`` builds its middleware list and its auth routes + internally with no public hook, so wrapping the app factory is the + least-invasive way to slot both concerns in without subclassing or + monkeypatching the SDK. + + Without this call the verifier still works for non-DPoP flows, but DPoP-bound + requests fail closed (``DPoPBindingMismatchError``) and the served PRM keeps + the slash-normalized identifiers. Call it right after constructing the + ``FastMCP`` instance. Args: mcp: A ``FastMCP`` instance (typically @@ -110,7 +124,7 @@ def install_request_context(mcp: FastMCP) -> None: async def main() -> None: result = await authplane_mcp_auth(issuer=..., resource=..., ...) mcp = FastMCP("My Server", **result) - install_request_context(mcp) # required for inbound DPoP + install_request_context(mcp) async with result: await mcp.run_streamable_http_async() @@ -126,6 +140,37 @@ async def main() -> None: if getattr(mcp, _INSTALLED_FLAG, False): return + # The verbatim identifiers ride on the AuthplaneTokenVerifier that + # ``authplane_mcp_auth`` stashed on the server. If a server was wired + # without the factory (no verbatim identifiers available), the PRM rewrite + # is skipped and the request-context middleware is still installed. + token_verifier = getattr(mcp, "_token_verifier", None) + if token_verifier is None: + # ``_token_verifier`` is an MCP SDK private attribute. If a future SDK + # release renames it, this lookup returns None and the verbatim PRM + # rewrite would quietly no-op, reverting the served document to the + # slash-normalized identifiers that break the strict comparison. Surface + # that loudly rather than silently regressing. + warnings.warn( + "FastMCP._token_verifier is absent; skipping the verbatim PRM " + "rewrite. The served Protected Resource Metadata will advertise " + "slash-normalized issuer/resource identifiers, which the core SDK's " + "byte-for-byte comparison rejects. This usually means the MCP SDK " + "renamed the private attribute the adapter reads.", + RuntimeWarning, + stacklevel=2, + ) + verbatim_issuer = getattr(token_verifier, "_verbatim_issuer", None) + verbatim_resource = getattr(token_verifier, "_verbatim_resource", None) + + def rewrite_prm(app: Starlette) -> None: + if verbatim_issuer is not None and verbatim_resource is not None: + rewrite_prm_routes_verbatim( + app.router.routes, + issuer=verbatim_issuer, + resource=verbatim_resource, + ) + original_streamable_http_app = mcp.streamable_http_app def streamable_http_app() -> Starlette: @@ -135,16 +180,28 @@ def streamable_http_app() -> Starlette: # invoked once at startup before serving begins, so wrapping is safe # here and runs before MCP's AuthenticationMiddleware on every call. app.add_middleware(AuthplaneRequestContextMiddleware) + rewrite_prm(app) + return app + + original_sse_app = mcp.sse_app + + def sse_app(*args: Any, **kwargs: Any) -> Starlette: + # Forward whatever positional/keyword args the SDK passes so a future + # signature change in ``sse_app`` cannot TypeError at app-build time; + # only the verbatim PRM rewrite below is ours. + app = original_sse_app(*args, **kwargs) + rewrite_prm(app) return app # Fragility: instance-attribute assignment works only because FastMCP - # exposes ``streamable_http_app`` as a plain method, not a ``@property`` - # or ``@cached_property``. If a future MCP SDK release changes that, the - # assignment will silently no-op (or raise AttributeError) and DPoP - # enforcement will fall back to ``dpop_request=None`` on every request. + # exposes ``streamable_http_app`` / ``sse_app`` as plain methods, not + # ``@property`` or ``@cached_property``. If a future MCP SDK release changes + # that, the assignment will silently no-op (or raise AttributeError) and + # both concerns above fall back to the SDK defaults. # Track https://github.com/modelcontextprotocol/python-sdk for a public # subclassing hook or per-app middleware API and migrate to it when available. mcp.streamable_http_app = streamable_http_app + mcp.sse_app = sse_app setattr(mcp, _INSTALLED_FLAG, True) @@ -399,8 +456,16 @@ async def authplane_mcp_auth( **verifier_kwargs, ) - # Wrap in AuthplaneTokenVerifier - token_verifier = AuthplaneTokenVerifier(verifier) + # Wrap in AuthplaneTokenVerifier. The verbatim issuer / resource ride + # along on the verifier so ``install_request_context`` can advertise them + # unchanged in the served PRM — the MCP SDK builds that document from + # ``AuthSettings`` ``AnyHttpUrl`` fields, which normalize an empty-path + # authority with a trailing slash (RFC 8414 §3.3, RFC 9728 §3.3). + token_verifier = AuthplaneTokenVerifier( + verifier, + verbatim_issuer=issuer, + verbatim_resource=resource, + ) # Create AuthSettings for FastMCP. # diff --git a/authplane-mcp/authplane_mcp/url_elicitation.py b/authplane-mcp/authplane_mcp/url_elicitation.py index fec4984..ab6e8d9 100644 --- a/authplane-mcp/authplane_mcp/url_elicitation.py +++ b/authplane-mcp/authplane_mcp/url_elicitation.py @@ -6,16 +6,103 @@ tool code sees them. This module exposes the underlying conversion as a small primitive for unusual flows where users build a consent error outside the wrapped client and want to raise the MCP-shaped error themselves. + +NOTE: this module is mirrored byte-for-byte in +``authplane-fastmcp/authplane_fastmcp/url_elicitation.py`` except for the +adapter name in the docstrings and the package name in the error string below. +Any fix here — in particular the eventual mcp-2.0 elicitation-field port — must +be applied to both copies. """ from __future__ import annotations +import functools +from typing import TYPE_CHECKING, Any from uuid import uuid4 from authplane.errors import ConsentRequiredError from mcp.shared.exceptions import UrlElicitationRequiredError from mcp.types import ElicitRequestURLParams +if TYPE_CHECKING: + from pydantic import BaseModel + + +@functools.cache +def _resolve_elicitation_id_kwarg(model: type[BaseModel]) -> str: + """Resolve the constructor kwarg for the elicitation-id field from the + model's own schema. + + mcp 1.x spells the field camelCase ``elicitationId``; mcp 2.0 renames it to + snake_case ``elicitation_id``. We look the name up *positively* from the + model rather than trying ``elicitationId=`` and catching ``ValidationError``: + ``ElicitRequestURLParams`` is declared ``extra="allow"``, so if a future + release renamed the field to an *optional* one, the camelCase kwarg would be + silently absorbed into ``__pydantic_extra__``, the renamed field would stay + unset, and no ``ValidationError`` would be raised — the client would then get + a ``-32042`` with no id at all (a silent failure worse than the 500 the + try/except was meant to prevent). That guard also swallowed unrelated + validation errors (e.g. a malformed ``url``) and was a pyright-strict call + error. + + Cached (``functools.cache``) keyed by the model class, so resolution is cheap + enough to run per build call; that keeps it lazy — a test can patch the + module's ``ElicitRequestURLParams`` and exercise the resolve→build wiring + without patching any resolved module state. + """ + fields = model.model_fields + for name in ("elicitationId", "elicitation_id"): + field = fields.get(name) + if field is not None: + # A rename can arrive as an alias rather than a field rename. + # Pydantic resolves a validation kwarg by ``validation_alias`` when + # it is set, falling back to the generic ``alias``; mirror that order + # here. A non-str validation alias — AliasPath / AliasChoices, e.g. + # from ``validation_alias=AliasChoices(...)`` — is not a usable single + # kwarg, so fall through to the generic alias, else the field name. + if isinstance(field.validation_alias, str): + return field.validation_alias + if field.alias is not None: + return field.alias + return name + # Neither known spelling is a declared field. With ``extra="allow"`` a + # default kwarg would land silently in ``__pydantic_extra__`` (a ``-32042`` + # with no id). The ``mcp<2`` ceiling means this branch can only be reached + # inside mcp 1.x, so a third spelling is an unexpected schema change: fail + # loudly rather than emit a malformed elicitation. + raise ImportError( + f"authplane-mcp cannot resolve the elicitation-id field on {model.__name__!r}: " + "none of the known spellings (elicitationId, elicitation_id) is a declared " + "field. The installed mcp is not compatible; require mcp>=1.28.1,<2." + ) + + +# Fail fast at import: the installed mcp must expose a known elicitation-id +# spelling. Resolution is otherwise lazy (see _build_url_elicitation_params) so +# tests can patch the model without re-triggering this. The bare call exists +# only for its import-time validation side effect; no name is bound. +_resolve_elicitation_id_kwarg(ElicitRequestURLParams) + + +def _build_url_elicitation_params( + *, url: str, message: str, elicitation_id: str +) -> ElicitRequestURLParams: + """Construct ``ElicitRequestURLParams`` under the elicitation-id field name + the installed mcp uses (camelCase ``elicitationId`` on 1.x, snake_case + ``elicitation_id`` on 2.0), resolved from the model's own schema. + """ + # Resolve lazily from the module-level model so a test can patch only + # ``ElicitRequestURLParams`` and have this composition pick up the change. + # ``kwargs`` is typed ``dict[str, Any]`` because the model's params are not + # all ``str``; that silences the pyright-strict reportCallIssue on unpack. + kwargs: dict[str, Any] = { + _resolve_elicitation_id_kwarg(ElicitRequestURLParams): elicitation_id, + "mode": "url", + "url": url, + "message": message, + } + return ElicitRequestURLParams(**kwargs) + def to_url_elicitation_required_error( error: BaseException, @@ -31,11 +118,10 @@ def to_url_elicitation_required_error( return UrlElicitationRequiredError( elicitations=[ - ElicitRequestURLParams( - mode="url", + _build_url_elicitation_params( url=error.consent_url, - elicitationId=str(uuid4()), message=error.describe(), + elicitation_id=str(uuid4()), ) ], message=str(error), diff --git a/authplane-mcp/authplane_mcp/verifier.py b/authplane-mcp/authplane_mcp/verifier.py index feed00f..be2fd27 100644 --- a/authplane-mcp/authplane_mcp/verifier.py +++ b/authplane-mcp/authplane_mcp/verifier.py @@ -92,6 +92,8 @@ def __init__( verifier: AuthplaneResource, *, get_http_request: Callable[[], Request] | None = None, + verbatim_issuer: str | None = None, + verbatim_resource: str | None = None, ) -> None: """Initialize the token verifier. @@ -105,9 +107,18 @@ def __init__( :class:`AuthplaneRequestContextMiddleware`). Tests inject a fake to drive the DPoP / per-request-cache paths without spinning up an ASGI app. + verbatim_issuer: Operator-configured issuer identifier, kept + byte-for-byte so :func:`install_request_context` can advertise + it unchanged in the served Protected Resource Metadata (the MCP + SDK otherwise serializes it through ``pydantic.AnyHttpUrl`` and + appends a trailing slash to an empty-path authority). + verbatim_resource: Operator-configured resource identifier, kept + byte-for-byte for the same PRM reason. """ self._verifier = verifier self._get_http_request = get_http_request or _default_get_http_request + self._verbatim_issuer = verbatim_issuer + self._verbatim_resource = verbatim_resource # ``AuthplaneResource.resource`` is operator-configured and must be a # string URI — guard against mis-wired mocks (a bare ``MagicMock`` with diff --git a/authplane-mcp/docs/user-guide.md b/authplane-mcp/docs/user-guide.md index 939859f..226c402 100644 --- a/authplane-mcp/docs/user-guide.md +++ b/authplane-mcp/docs/user-guide.md @@ -35,7 +35,7 @@ Requires Python 3.11+. import asyncio from mcp.server.fastmcp import FastMCP -from authplane_mcp import authplane_mcp_auth, require_scope +from authplane_mcp import authplane_mcp_auth, install_request_context, require_scope async def main() -> None: @@ -45,6 +45,10 @@ async def main() -> None: scopes=["tools/query", "tools/write"], ) mcp = FastMCP("My Server", port=8080, json_response=True, **auth_result) + # Advertises the issuer / resource identifiers verbatim in the Protected + # Resource Metadata and installs the request-context middleware used by + # inbound DPoP enforcement. + install_request_context(mcp) @mcp.tool() async def query(sql: str) -> str: diff --git a/authplane-mcp/pyproject.toml b/authplane-mcp/pyproject.toml index bbb3e0b..27ea7b3 100644 --- a/authplane-mcp/pyproject.toml +++ b/authplane-mcp/pyproject.toml @@ -26,11 +26,13 @@ classifiers = [ requires-python = ">=3.11" dependencies = [ "authplane-sdk", - # mcp 1.23–1.27 use camelCase `elicitationId`; 1.28 renamed it to - # snake_case `elicitation_id`, which breaks this adapter's wire handling - # (see url_elicitation.py + README). Keep the ceiling below 1.28 until the - # adapter is migrated. - "mcp>=1.23.0,<1.28.0", + # Floor is 1.28.1: mcp <=1.28.0 carries PYSEC-2026-3483, fixed in 1.28.1. + # This adapter targets the mcp 1.x server API (`mcp.server.fastmcp.FastMCP`) + # and the camelCase URL-elicitation field (`ElicitRequestURLParams( + # elicitationId=...)`), both of which hold through the 1.x line. The upper + # bound excludes mcp 2.0, which removes `mcp.server.fastmcp` and renames the + # field to snake_case `elicitation_id`; supporting it is a separate port. + "mcp>=1.28.1,<2", "pydantic>=2.0", ] diff --git a/authplane-mcp/tests/test_integration.py b/authplane-mcp/tests/test_integration.py new file mode 100644 index 0000000..c05ce8f --- /dev/null +++ b/authplane-mcp/tests/test_integration.py @@ -0,0 +1,94 @@ +"""Integration tests for authplane-mcp with a real MCP SDK ``FastMCP`` app. + +These tests verify that the Protected Resource Metadata (PRM) endpoint the MCP +SDK auto-registers advertises the operator-configured issuer / resource +identifiers byte-for-byte, matching the core SDK's strict comparison +(RFC 8414 §3.3, RFC 9728 §3.3). Upstream serializes those fields through +``pydantic.AnyHttpUrl``, which appends a trailing slash to an empty-path +authority; :func:`install_request_context` rewrites the served document back to +the verbatim form. +""" + +from unittest.mock import AsyncMock, PropertyMock + +import pytest +from authplane import AuthplaneResource +from httpx import ASGITransport, AsyncClient +from mcp.server.auth.settings import AuthSettings +from mcp.server.fastmcp import FastMCP +from pydantic import AnyHttpUrl + +from authplane_mcp import AuthplaneTokenVerifier, install_request_context + + +def _build_app(*, issuer: str, resource: str) -> FastMCP: + mock = AsyncMock(spec=AuthplaneResource) + type(mock).scopes = PropertyMock(return_value=["tools/query"]) + type(mock).resource = PropertyMock(return_value=resource) + + token_verifier = AuthplaneTokenVerifier( + mock, + verbatim_issuer=issuer, + verbatim_resource=resource, + ) + auth_settings = AuthSettings( + issuer_url=AnyHttpUrl(issuer), + resource_server_url=AnyHttpUrl(resource), + ) + mcp = FastMCP( + "Test Server", + json_response=True, + token_verifier=token_verifier, + auth=auth_settings, + ) + install_request_context(mcp) + return mcp + + +@pytest.mark.asyncio +async def test_prm_advertises_issuer_verbatim() -> None: + """The served PRM advertises the issuer without an added trailing slash.""" + mcp = _build_app( + issuer="https://auth.example.com", + resource="https://api.example.com/mcp", + ) + asgi_app = mcp.streamable_http_app() + + async with AsyncClient( + transport=ASGITransport(app=asgi_app), + base_url="http://testserver", + ) as client: + response = await client.get("/.well-known/oauth-protected-resource/mcp") + + assert response.status_code == 200 + prm = response.json() + assert prm["authorization_servers"] == ["https://auth.example.com"] + assert prm["resource"] == "https://api.example.com/mcp" + + +@pytest.mark.asyncio +async def test_prm_advertises_root_resource_verbatim() -> None: + """A resource configured with no trailing slash is advertised verbatim. + + An empty-path authority is exactly where ``pydantic.AnyHttpUrl`` inserts a + trailing slash (``AuthSettings.resource_server_url`` becomes + ``https://api.example.com/``), so this pins the rewrite of the served + ``resource`` back to the configured ``https://api.example.com``. + """ + mcp = _build_app( + issuer="https://auth.example.com", + resource="https://api.example.com", + ) + asgi_app = mcp.streamable_http_app() + + async with AsyncClient( + transport=ASGITransport(app=asgi_app), + base_url="http://testserver", + ) as client: + response = await client.get("/.well-known/oauth-protected-resource") + + assert response.status_code == 200 + prm = response.json() + assert prm["authorization_servers"] == ["https://auth.example.com"] + assert prm["resource"] == "https://api.example.com" + assert not prm["resource"].endswith("/") diff --git a/authplane-mcp/tests/test_prm.py b/authplane-mcp/tests/test_prm.py new file mode 100644 index 0000000..1b8adcf --- /dev/null +++ b/authplane-mcp/tests/test_prm.py @@ -0,0 +1,59 @@ +"""Unit tests for the verbatim-PRM body rewrite. + +``_rewrite_body`` swaps the configured identifiers back to their verbatim form +(the upstream MCP machinery serializes them through ``pydantic.AnyHttpUrl``, +which appends a trailing slash to an empty-path authority) without disturbing +any other advertised field. +""" + +import json + +from authplane_mcp._prm import _rewrite_body + +_ISSUER = "https://auth.example.com" +_RESOURCE = "https://api.example.com/mcp" + + +def _rewrite(doc: dict[str, object]) -> dict[str, object]: + out = _rewrite_body(json.dumps(doc).encode("utf-8"), issuer=_ISSUER, resource=_RESOURCE) + return json.loads(out) + + +def test_swaps_slashed_issuer_for_verbatim() -> None: + result = _rewrite({"authorization_servers": [_ISSUER + "/"], "resource": _RESOURCE + "/"}) + assert result["authorization_servers"] == [_ISSUER] + assert result["resource"] == _RESOURCE + + +def test_preserves_extra_authorization_server_entries() -> None: + other = "https://other-as.example.com/" + result = _rewrite({"authorization_servers": [_ISSUER + "/", other]}) + # Only the entry matching the configured issuer is rewritten; the extra AS + # entry is left exactly as advertised. + assert result["authorization_servers"] == [_ISSUER, other] + + +def test_preserves_unrelated_fields() -> None: + result = _rewrite( + { + "authorization_servers": [_ISSUER + "/"], + "resource": _RESOURCE, + "scopes_supported": ["tools/query"], + "bearer_methods_supported": ["header"], + } + ) + assert result["scopes_supported"] == ["tools/query"] + assert result["bearer_methods_supported"] == ["header"] + + +def test_non_json_body_returned_unchanged() -> None: + assert _rewrite_body(b"", issuer=_ISSUER, resource=_RESOURCE) == b"" + + +def test_body_untouched_when_nothing_to_rewrite() -> None: + # Already verbatim: the function returns the original bytes rather than + # re-serializing (so downstream Content-Length stays correct for a no-op). + original = json.dumps({"authorization_servers": [_ISSUER], "resource": _RESOURCE}).encode( + "utf-8" + ) + assert _rewrite_body(original, issuer=_ISSUER, resource=_RESOURCE) == original diff --git a/authplane-mcp/tests/test_url_elicitation.py b/authplane-mcp/tests/test_url_elicitation.py index cfca4f7..7cad5eb 100644 --- a/authplane-mcp/tests/test_url_elicitation.py +++ b/authplane-mcp/tests/test_url_elicitation.py @@ -11,18 +11,36 @@ from __future__ import annotations from unittest.mock import AsyncMock +from uuid import UUID import pytest from authplane.errors import AuthError, ConsentRequiredError from authplane.oauth import TokenExchangeOptions from mcp.shared.exceptions import UrlElicitationRequiredError -from mcp.types import URL_ELICITATION_REQUIRED +from mcp.types import URL_ELICITATION_REQUIRED, ElicitRequestURLParams +from pydantic import BaseModel +import authplane_mcp.url_elicitation as url_elicitation from authplane_mcp.auth import _wrap_client_for_elicitation # pyright: ignore[reportPrivateUsage] -from authplane_mcp.url_elicitation import to_url_elicitation_required_error _OPTIONS = TokenExchangeOptions(subject_token="test") +# --------------------------------------------------------------------------- +# Wire contract +# --------------------------------------------------------------------------- + + +def test_elicitation_id_wire_key_is_camelcase_under_installed_mcp() -> None: + # README-pinned contract: the mcp 1.x line serializes the elicitation-id + # field as camelCase ``elicitationId``. If a future 1.x release renames it, + # this fails with a real diff instead of masking the drift at collection + # time (as a dynamic probe would). + params = ElicitRequestURLParams( + mode="url", url="https://example.test", elicitationId="probe", message="m" + ) + assert "elicitationId" in params.model_dump(by_alias=True) + + # --------------------------------------------------------------------------- # Primitive: to_url_elicitation_required_error # --------------------------------------------------------------------------- @@ -38,7 +56,7 @@ def test_returns_url_elicitation_for_consent_with_url() -> None: status_code=400, ) - mapped = to_url_elicitation_required_error(error) + mapped = url_elicitation.to_url_elicitation_required_error(error) assert isinstance(mapped, UrlElicitationRequiredError) assert mapped.error.code == URL_ELICITATION_REQUIRED @@ -49,11 +67,19 @@ def test_returns_url_elicitation_for_consent_with_url() -> None: assert elicitations[0]["mode"] == "url" # describe() output is the canonical elicitation message — pin the format. assert elicitations[0]["message"] == "user must grant access (calendar: missing_user_consent)" + # The adapter must populate a fresh UUID under the camelCase + # ``elicitationId`` wire field the pinned mcp 1.x line uses. + UUID(elicitations[0]["elicitationId"]) + # ...and the dict must round-trip back into a schema-valid model, proving the + # consent-driven path yields a genuine ``ElicitRequestURLParams``. + rebuilt = ElicitRequestURLParams.model_validate(elicitations[0]) + assert rebuilt.url == "https://as.example.com/consent?service=calendar" + assert rebuilt.mode == "url" def test_returns_none_for_non_consent_error() -> None: assert ( - to_url_elicitation_required_error( + url_elicitation.to_url_elicitation_required_error( AuthError("bad request", code="invalid_request", status_code=400) ) is None @@ -67,7 +93,101 @@ def test_returns_none_for_consent_without_url() -> None: cause_detail="missing_user_consent", consent_url=None, ) - assert to_url_elicitation_required_error(error) is None + assert url_elicitation.to_url_elicitation_required_error(error) is None + + +# --------------------------------------------------------------------------- +# Field-rename resilience (the argument for the `<2` ceiling) +# --------------------------------------------------------------------------- + + +class _StubRenamedElicit(BaseModel): + """Stand-in for a hypothetical mcp release that renamed the elicitation-id + field to snake_case ``elicitation_id`` (as mcp 2.0 does).""" + + mode: str + url: str + message: str + elicitation_id: str # required, snake_case + + +def test_schema_lookup_picks_snake_case_after_rename() -> None: + # The positive schema lookup resolves the constructor kwarg from the model + # itself, so a rename to ``elicitation_id`` is picked up rather than the + # camelCase kwarg silently landing in ``__pydantic_extra__``. + assert ( + url_elicitation._resolve_elicitation_id_kwarg( # pyright: ignore[reportPrivateUsage] + _StubRenamedElicit + ) + == "elicitation_id" + ) + + +class _NoElicitId(BaseModel): + """A model exposing neither known elicitation-id spelling.""" + + mode: str + url: str + message: str + + +def test_resolver_raises_when_no_known_spelling() -> None: + # With ``extra="allow"``, returning a default kwarg for a model that declares + # neither spelling would land it silently in ``__pydantic_extra__`` (a -32042 + # with no id). The resolver must instead raise, naming the unrecognized model. + with pytest.raises(ImportError, match="cannot resolve the elicitation-id field"): + url_elicitation._resolve_elicitation_id_kwarg( # pyright: ignore[reportPrivateUsage] + _NoElicitId + ) + + +def test_import_raises_when_model_lacks_known_spelling(monkeypatch: pytest.MonkeyPatch) -> None: + # The import-time resolution is the fail-fast: if the installed mcp exposes + # neither spelling, importing the module must raise (not defer a silent + # -32042). Patch the source model on ``mcp.types`` and reload the module. + import importlib + + import mcp.types + + monkeypatch.setattr(mcp.types, "ElicitRequestURLParams", _NoElicitId) + try: + with pytest.raises(ImportError, match="cannot resolve the elicitation-id field"): + importlib.reload(url_elicitation) + finally: + # Restore the real model and reload so the module (and its functools.cache) is + # left in a good state for the remaining tests. ``importlib.reload`` re-executes + # into the *same* module ``__dict__``, so this rebinds the module-level state + # left stale by the failed reload and the integration tests below resolve correctly. + monkeypatch.undo() + importlib.reload(url_elicitation) + + +def test_rename_path_still_yields_minus_32042_with_id(monkeypatch: pytest.MonkeyPatch) -> None: + # With the elicitation model renamed, the consent path must still produce a + # -32042 whose elicitation carries a populated id under the new field name — + # not a silent failure with a missing id. Patch ONLY the model: resolution is + # lazy, so `_build_url_elicitation_params` re-resolves the kwarg from the + # patched model. This exercises the resolve→build wiring end to end, rather + # than short-circuiting it by patching the resolved module state. + monkeypatch.setattr(url_elicitation, "ElicitRequestURLParams", _StubRenamedElicit) + + error = ConsentRequiredError( + "user must grant access", + service_id="calendar", + cause_detail="missing_user_consent", + consent_url="https://as.example.com/consent?service=calendar", + code="consent_required", + status_code=400, + ) + + mapped = url_elicitation.to_url_elicitation_required_error(error) + + assert isinstance(mapped, UrlElicitationRequiredError) + assert mapped.error.code == URL_ELICITATION_REQUIRED + assert mapped.error.data is not None + elicitation = mapped.error.data["elicitations"][0] + # The id is populated under the renamed snake_case field, not dropped. + UUID(elicitation["elicitation_id"]) # --------------------------------------------------------------------------- diff --git a/authplane-mcp/tests/test_verifier_dpop_cache.py b/authplane-mcp/tests/test_verifier_dpop_cache.py index 5db1361..51e62d6 100644 --- a/authplane-mcp/tests/test_verifier_dpop_cache.py +++ b/authplane-mcp/tests/test_verifier_dpop_cache.py @@ -302,9 +302,9 @@ async def test_htu_preserves_percent_encoded_path_from_raw_path() -> None: """htu uses ``scope['raw_path']`` so percent-encoding survives. ASGI populates ``scope['path']`` as the percent-decoded path, but the - DPoP proof was signed over the on-wire (still-encoded) URL. The TS - sibling reads ``IncomingMessage.url`` (raw bytes), so reading - ``raw_path`` here keeps cross-SDK proof binding identical. + DPoP proof was signed over the on-wire (still-encoded) URL. Reading + ``raw_path`` here keeps the DPoP ``htu`` binding byte-for-byte with the + proof's covered value (RFC 9449 §4.3). """ mock = _mock_verifier() # Decoded path: "/mcp/users/a/b" ; raw: "/mcp/users/a%2Fb" — a client diff --git a/authplane/client.py b/authplane/client.py index 07010b4..eea8c5c 100644 --- a/authplane/client.py +++ b/authplane/client.py @@ -102,7 +102,8 @@ async def create( Args: issuer: Authorization-server issuer URL (the prefix RFC 8414 metadata - is fetched from). Trailing slash is stripped. + is fetched from). Stored verbatim and compared byte-for-byte; a + trailing slash is significant and is preserved. auth: Client authentication for OAuth endpoints. Accepts either a raw :class:`AuthProvider` or an :class:`ASCredentials` shorthand (which is materialised as :class:`ClientCredentialsProvider`). @@ -117,12 +118,9 @@ async def create( metadata_refresh_seconds: Background metadata refresh interval (must be > 0). cache_ttl_buffer_seconds: Safety margin subtracted from each token's - lifetime before the entry is considered expired. Same shape as - java-sdk ``TokenCacheConfig.ttlBufferSeconds`` and ts-sdk - ``TokenCache`` ctor. Default 30s. - default_ttl_seconds: Fallback lifetime applied when the AS response - omits ``expires_in``. Cross-SDK parity with java-sdk - ``TokenCacheConfig.defaultTtlSeconds``. Default 3600s. + lifetime before the entry is considered expired. Default 30s. + default_ttl_seconds: Fallback lifetime applied when the AS omits + ``expires_in``. Default 3600s. cache_max_entries: Maximum number of cached tokens before least-recently-used eviction kicks in. Default :attr:`TokenCache.DEFAULT_MAX_ENTRIES` (10_000). Must be a @@ -132,9 +130,19 @@ async def create( circuit opens. Default 5. circuit_breaker_cooldown_seconds: Half-open probe interval after the circuit trips. Default 30s. + + Raises: + ValueError: If ``issuer`` carries a query or fragment component + (RFC 8414 §2 forbids both). This fails fast at construction, + before any network fetch. """ client = cls() - client._issuer = issuer.rstrip("/") + # Identity: the issuer is an identifier (RFC 9068 `iss`), stored verbatim + # and compared byte-for-byte. Do NOT strip a trailing slash here — an AS + # whose issuer ends in `/` mints tokens whose `iss` keeps the slash, and + # normalizing it away rejects every token. Slash stripping belongs only + # to .well-known URL derivation (see build_metadata_url), not to identity. + client._issuer = issuer # Dev mode resolved_dev_mode = ( diff --git a/authplane/docs/user-guide.md b/authplane/docs/user-guide.md index 2ab36de..067b2d7 100644 --- a/authplane/docs/user-guide.md +++ b/authplane/docs/user-guide.md @@ -249,7 +249,6 @@ Important behavior: - set `fail_closed=True` to reject tokens when the revocation check fails - the client must have AS credentials configured - the AS metadata must expose `introspection_endpoint` -- `fail_closed` has no effect when `revocation_checker` is `None` — the flag is only consulted when a revocation check actually runs. The SDK logs a warning at resource construction when it detects this misconfiguration. ```python # Fail-closed: reject tokens when introspection is unavailable @@ -422,7 +421,7 @@ from authplane import DPoPKeyMaterial, DPoPNonceStore, DPoPProvider class MyNonceStore: - def get(self, key: str) -> str: ... + def get(self, key: str) -> str: ... # return "" on a miss, never None (DPoPNonceStore contract) def put(self, key: str, nonce: str) -> None: ... diff --git a/authplane/internal/metadata.py b/authplane/internal/metadata.py index cb26a68..6625290 100644 --- a/authplane/internal/metadata.py +++ b/authplane/internal/metadata.py @@ -30,7 +30,11 @@ def __init__( on_change=on_change, error_factory=lambda msg: MetadataFetchError(msg), ) - self._expected_issuer = expected_issuer.rstrip("/") + # Identity: the expected issuer is stored verbatim. RFC 8414 §3.3 + # requires the returned `issuer` to be identical to the configured one, + # so a trailing-slash difference is a genuine mismatch and must not be + # normalized away on either side of the comparison. + self._expected_issuer = expected_issuer self._allow_http = allow_http def _validate_endpoint_url(self, field: str, value: str) -> None: @@ -50,13 +54,18 @@ def _validate_endpoint_url(self, field: str, value: str) -> None: ) def _validate_metadata(self, metadata: dict[str, Any]) -> dict[str, Any]: - issuer = str(metadata.get("issuer", "")).rstrip("/") + issuer = str(metadata.get("issuer", "")) if not issuer: raise MetadataFetchError("AS metadata missing required 'issuer' field") if self._expected_issuer and issuer != self._expected_issuer: - raise MetadataFetchError( - f"AS metadata issuer mismatch: expected {self._expected_issuer!r}, got {issuer!r}" - ) + msg = f"AS metadata issuer mismatch: expected {self._expected_issuer!r}, got {issuer!r}" + # The comparison above stays byte-for-byte; only the hint is + # conditional. Append the trailing-slash note only when the two + # values are otherwise identical — a genuine wrong-host mismatch + # would be misleadingly blamed on a slash otherwise. + if issuer.rstrip("/") == self._expected_issuer.rstrip("/"): + msg += " (identifiers are compared byte-for-byte; a trailing slash is significant)" + raise MetadataFetchError(msg) for field in ( "jwks_uri", "token_endpoint", diff --git a/authplane/internal/urls.py b/authplane/internal/urls.py index 3b13669..fca1a89 100644 --- a/authplane/internal/urls.py +++ b/authplane/internal/urls.py @@ -1,17 +1,33 @@ """URL utilities for OAuth 2.0 metadata discovery (RFC 8414) and PRM (RFC 9728).""" -from urllib.parse import urlparse, urlunparse +from urllib.parse import ParseResult, urlparse, urlunparse + + +def _redact_authority(parsed: ParseResult) -> str: + """Return ``scheme://host[:port]/path`` for use in error messages. + + Uses ``hostname`` (never ``netloc``) so any userinfo embedded in the + authority — e.g. ``svc:s3cr3t@`` — is never echoed into a ``ValueError`` + that may be logged. The path is kept because it is not credential-shaped, + while any query/fragment is dropped by the caller before this is built. + """ + host = parsed.hostname or "" + if parsed.port is not None: + host = f"{host}:{parsed.port}" + return f"{parsed.scheme}://{host}{parsed.path}" def build_prm_url(resource: str) -> str: """Build the RFC 9728 well-known Protected Resource Metadata URL. The well-known URI is formed by inserting /.well-known/oauth-protected-resource - between the host and the path component of the resource URI. + between the host and the path and/or query components of the resource URI. - RFC 9728 Section 3: + RFC 9728 Section 3.1: https://{host}/.well-known/oauth-protected-resource/{path} + The resource's query component, if any, is preserved on the derived URL. + Examples: >>> build_prm_url("https://api.example.com") 'https://api.example.com/.well-known/oauth-protected-resource' @@ -22,13 +38,33 @@ def build_prm_url(resource: str) -> str: >>> build_prm_url("https://api.example.com/v2/mcp") 'https://api.example.com/.well-known/oauth-protected-resource/v2/mcp' + >>> build_prm_url("https://api.example.com/?x=1") + 'https://api.example.com/.well-known/oauth-protected-resource?x=1' + Args: resource: The resource server URI. Returns: The fully constructed PRM discovery URL. + + Raises: + ValueError: If the resource indicator carries a fragment component. + RFC 8707 §2 forbids a fragment in a resource indicator; it is + rejected here rather than silently discarded by ``urlunparse``. """ parsed = urlparse(resource) + + # RFC 8707 §2: a resource indicator MUST NOT contain a fragment component. + # (A query IS preserved below per RFC 9728 §3.1 — the two components are + # treated asymmetrically.) urlunparse silently drops a fragment, so gate on + # the raw string and reject it explicitly rather than letting a malformed + # indicator resolve to a document it does not actually name. + if "#" in resource: + safe = _redact_authority(parsed) + raise ValueError( + f"resource indicator must not contain a fragment component (RFC 8707 §2): {safe!r}" + ) + path = parsed.path.strip("/") if path: @@ -36,13 +72,17 @@ def build_prm_url(resource: str) -> str: else: well_known_path = "/.well-known/oauth-protected-resource" + # RFC 9728 §3.1 inserts the well-known segment between the host and "the path + # and/or query components" of the resource identifier — so the query survives + # into the derived PRM URL. (The path strip above is the correct §3.1 + # derivation behavior and is unrelated to identity comparison.) return urlunparse( ( parsed.scheme, parsed.netloc, well_known_path, "", - "", + parsed.query, "", ) ) @@ -72,9 +112,33 @@ def build_metadata_url(issuer: str) -> str: Returns: The fully constructed metadata discovery URL. + + Raises: + ValueError: If the issuer carries a query or fragment component. RFC + 8414 §2 requires the issuer identifier to have neither. This raises + at construction rather than silently discarding the component — that + reconciliation would let a malformed identifier resolve to a + document it does not actually name, and would later surface as a + confusing "issuer mismatch" instead of the real cause. (A fragment + is not preserved by ``urlunparse`` at all, so without this gate a + fragment-bearing issuer would be silently dropped.) """ parsed = urlparse(issuer) + # RFC 8414 §2: the issuer identifier MUST NOT contain a query OR fragment + # component. Gate on the raw string so a bare `?`/`#` (empty component, which + # urlparse reports as empty) is rejected too — the delimiter must never + # survive into the derived `.well-known` URL, and urlunparse silently drops a + # fragment entirely. Report only scheme://host/path (bare hostname, never + # netloc) so a credential-shaped query (e.g. `?token=...`) or embedded + # userinfo does not leak into the message. + if any(c in issuer for c in ("?", "#")): + safe = _redact_authority(parsed) + raise ValueError( + "issuer identifier must not contain a query or fragment component " + f"(RFC 8414 §2): {safe!r}" + ) + # Strip leading/trailing slashes from the path to normalize path = parsed.path.strip("/") diff --git a/conformance-tests/README.md b/conformance-tests/README.md index f518398..a0b6b97 100644 --- a/conformance-tests/README.md +++ b/conformance-tests/README.md @@ -58,7 +58,16 @@ These tests show up as `skipped` (with their `note` carried through) in both `co The suite needs the shared catalog YAML on disk. By default it looks for `../conformance/oauth-sdk-conformance-catalog.yaml` (i.e. `python-sdk` and [`conformance`](https://github.com/AuthPlane/conformance) checked out as -siblings). If your layout differs — e.g. nested inside another monorepo — +siblings). To match CI exactly, check out the catalog revision pinned in +`.conformance-catalog-ref` at the repo root rather than the latest default +branch: + +```bash +# From the python-sdk/ clone, with conformance/ checked out as a sibling +git -C ../conformance checkout "$(cat .conformance-catalog-ref)" +``` + +If your layout differs — e.g. nested inside another monorepo — point the suite at the catalog explicitly: ```bash diff --git a/llm-full.txt b/llm-full.txt index 90d6b8f..2d45b5b 100644 --- a/llm-full.txt +++ b/llm-full.txt @@ -184,7 +184,7 @@ asyncio.run(main()) ```python import asyncio -from authplane_mcp import authplane_mcp_auth, require_scope +from authplane_mcp import authplane_mcp_auth, install_request_context, require_scope from mcp.server.fastmcp import FastMCP @@ -195,6 +195,9 @@ async def main() -> None: scopes=["tools/query"], ) mcp = FastMCP("My Server", port=8080, json_response=True, **auth_result) + # Advertise issuer / resource verbatim in the PRM and install the + # request-context middleware used by inbound DPoP enforcement. + install_request_context(mcp) @mcp.tool() async def query(sql: str) -> str: diff --git a/llm.txt b/llm.txt index 2eaf4a9..14d55e3 100644 --- a/llm.txt +++ b/llm.txt @@ -50,7 +50,7 @@ asyncio.run(main()) ```python import asyncio -from authplane_mcp import authplane_mcp_auth, require_scope +from authplane_mcp import authplane_mcp_auth, install_request_context, require_scope from mcp.server.fastmcp import FastMCP async def main() -> None: @@ -60,6 +60,9 @@ async def main() -> None: scopes=["tools/query"], ) mcp = FastMCP("My Server", json_response=True, **auth_result) + # Advertise issuer / resource verbatim in the PRM and install the + # request-context middleware used by inbound DPoP enforcement. + install_request_context(mcp) @mcp.tool() async def query(sql: str) -> str: diff --git a/tests/internal/test_metadata.py b/tests/internal/test_metadata.py index dafe6d6..0acbdf3 100644 --- a/tests/internal/test_metadata.py +++ b/tests/internal/test_metadata.py @@ -88,17 +88,20 @@ async def test_get_jwks_uri_missing_field() -> None: await cache.get_jwks_uri() -async def test_expected_issuer_trailing_slash_is_normalized() -> None: - fetcher = TrackingFetcher(metadata=SAMPLE_METADATA) +async def test_expected_issuer_trailing_slash_is_significant() -> None: + # Identifiers are compared byte-for-byte (RFC 8414 §3.3). A + # configured issuer that differs from the advertised metadata issuer only + # by a trailing slash is a genuine mismatch and must be rejected, not + # silently reconciled. + fetcher = TrackingFetcher(metadata=SAMPLE_METADATA) # issuer has no trailing slash cache = MetadataCache( fetcher, expected_issuer="https://auth.example.com/", document_type="metadata", ) - metadata = await cache.get() - - assert metadata["issuer"] == "https://auth.example.com" + with pytest.raises(MetadataFetchError, match="issuer mismatch"): + await cache.get() # --------------------------------------------------------------------------- diff --git a/tests/test_issuer_identity.py b/tests/test_issuer_identity.py new file mode 100644 index 0000000..0888ac4 --- /dev/null +++ b/tests/test_issuer_identity.py @@ -0,0 +1,246 @@ +"""Regression tests: issuer identity is preserved byte-for-byte. + +Identifiers (the configured issuer and a token's RFC 9068 ``iss``) are STORED +and COMPARED verbatim — a trailing slash is significant. Only .well-known URL +DERIVATION (RFC 8414 / 9728 §3.1) strips the terminating slash. These two +behaviors are distinct and must not be fused. +""" + +from collections.abc import AsyncGenerator, Callable +from typing import Any + +import pytest +import respx + +from authplane import AuthplaneClient, FetchSettings +from authplane.errors import InvalidClaimsError, MetadataFetchError +from authplane.internal.fetch_result import FetchResult +from authplane.internal.metadata import MetadataCache +from authplane.internal.urls import build_metadata_url, build_prm_url + +# The issuer under test carries a trailing slash; an AS whose identifier ends in +# ``/`` mints tokens whose ``iss`` keeps the slash (RFC 9068). +ISSUER_WITH_SLASH = "https://auth.example.com/" +RESOURCE = "https://api.example.com" + + +@pytest.fixture +async def client_slash_issuer( + jwks_keypair: dict[str, Any], +) -> AsyncGenerator[AuthplaneClient]: + """Client configured with a trailing-slash issuer, backed by respx mocks. + + The AS metadata document advertises the SAME trailing-slash issuer, so the + RFC 8414 §3.3 comparison passes; the derived .well-known URL still strips the + slash (that is derivation, not identity). + """ + with respx.mock: + metadata_doc = { + "issuer": ISSUER_WITH_SLASH, + "token_endpoint": "https://auth.example.com/oauth/token", + "jwks_uri": "https://auth.example.com/.well-known/jwks.json", + } + respx.get("https://auth.example.com/.well-known/oauth-authorization-server").mock( + return_value=respx.MockResponse(status_code=200, json=metadata_doc) + ) + respx.get("https://auth.example.com/.well-known/jwks.json").mock( + return_value=respx.MockResponse(status_code=200, json=jwks_keypair["jwks"]) + ) + c = await AuthplaneClient.create( + issuer=ISSUER_WITH_SLASH, + fetch_settings=FetchSettings(ssrf_protection=False), + ) + yield c + await c.aclose() + + +# (a) A token whose `iss` carries the configured trailing slash verifies OK. +async def test_token_iss_with_trailing_slash_verifies( + client_slash_issuer: AuthplaneClient, + token_factory: Callable[..., str], +) -> None: + # The configured issuer is stored verbatim (with the slash), so a token whose + # `iss` matches it byte-for-byte must verify. Previously the stored issuer + # was slash-stripped, so this token's `iss` mismatched and every token was + # rejected (the outage). + verifier = client_slash_issuer.resource(resource=RESOURCE) + token = token_factory(iss=ISSUER_WITH_SLASH) + + claims = await verifier.verify(token) + + assert claims.issuer == ISSUER_WITH_SLASH + + +# (a1) The advertise leg is verbatim too: the PRM document advertises the +# configured trailing-slash issuer byte-for-byte in `authorization_servers` +# (build_prm passes the stored issuer straight through), symmetric with the +# verify leg above. +def test_prm_response_advertises_issuer_verbatim( + client_slash_issuer: AuthplaneClient, +) -> None: + res = client_slash_issuer.resource(resource=RESOURCE) + + assert res.prm_response()["authorization_servers"] == [ISSUER_WITH_SLASH] + + +# (a2) The configured issuer carries a trailing slash; a token whose `iss` drops +# it is rejected. This proves the `iss` comparison is verbatim rather than merely +# loosened — a slash-insensitive comparison would wrongly accept this token. +async def test_token_iss_without_trailing_slash_is_rejected( + client_slash_issuer: AuthplaneClient, + token_factory: Callable[..., str], +) -> None: + verifier = client_slash_issuer.resource(resource=RESOURCE) + token = token_factory(iss="https://auth.example.com") # `iss` WITHOUT the slash + + with pytest.raises(InvalidClaimsError): + await verifier.verify(token) + + +# (b) A metadata doc whose issuer differs only by a trailing slash is rejected. +async def test_metadata_issuer_off_by_trailing_slash_is_rejected() -> None: + metadata = { + "issuer": "https://auth.example.com/", # advertised WITH slash + "jwks_uri": "https://auth.example.com/.well-known/jwks.json", + "token_endpoint": "https://auth.example.com/oauth/token", + } + + async def fetcher() -> Any: + return FetchResult(document=metadata, expires_at=None) + + cache = MetadataCache( + fetcher, + expected_issuer="https://auth.example.com", # configured WITHOUT slash + document_type="metadata", + ) + + with pytest.raises(MetadataFetchError, match="issuer mismatch"): + await cache.get() + + +def _metadata_cache_expecting(expected: str, advertised: str) -> MetadataCache: + metadata = { + "issuer": advertised, + "jwks_uri": "https://auth.example.com/.well-known/jwks.json", + "token_endpoint": "https://auth.example.com/oauth/token", + } + + async def fetcher() -> Any: + return FetchResult(document=metadata, expires_at=None) + + return MetadataCache(fetcher, expected_issuer=expected, document_type="metadata") + + +# (b2) A trailing-slash-only mismatch gets the "trailing slash is significant" +# hint, since the two identifiers are otherwise identical. +async def test_trailing_slash_mismatch_includes_slash_hint() -> None: + cache = _metadata_cache_expecting( + expected="https://auth.example.com", + advertised="https://auth.example.com/", + ) + with pytest.raises(MetadataFetchError) as excinfo: + await cache.get() + assert "trailing slash is significant" in str(excinfo.value) + + +# (b3) A genuine wrong-host mismatch does NOT get the slash hint, which would be +# misleading — the values differ by more than a terminating slash. +async def test_wrong_host_mismatch_omits_slash_hint() -> None: + cache = _metadata_cache_expecting( + expected="https://auth.example.com", + advertised="https://evil.example.com", + ) + with pytest.raises(MetadataFetchError) as excinfo: + await cache.get() + assert "issuer mismatch" in str(excinfo.value) + assert "trailing slash is significant" not in str(excinfo.value) + + +# (c) A resource with a query component keeps the query in its derived PRM URL. +def test_prm_url_preserves_query_component() -> None: + assert ( + build_prm_url("https://api.example.com/?x=1") + == "https://api.example.com/.well-known/oauth-protected-resource?x=1" + ) + + +def test_prm_url_preserves_query_with_path() -> None: + assert ( + build_prm_url("https://api.example.com/mcp?tenant=acme") + == "https://api.example.com/.well-known/oauth-protected-resource/mcp?tenant=acme" + ) + + +# (d) A query-bearing issuer is rejected at construction (RFC 8414 §2), not +# silently stripped and later surfaced as a confusing "issuer mismatch". +def test_metadata_url_rejects_query_bearing_issuer() -> None: + with pytest.raises(ValueError, match="must not contain a query or fragment"): + build_metadata_url("https://auth.example.com/t?x=1") + + +def test_metadata_url_rejects_bare_empty_query_issuer() -> None: + # A bare `?` still carries the query delimiter and must not survive into the + # derived .well-known URL. + with pytest.raises(ValueError, match="must not contain a query or fragment"): + build_metadata_url("https://auth.example.com/t?") + + +# (d2) A fragment-bearing issuer is rejected too. RFC 8414 §2 forbids BOTH a +# query and a fragment; urlunparse silently drops a fragment, so without the +# gate `https://auth.example.com/t#x` would derive a fragment-free .well-known +# URL and later surface as a confusing "issuer mismatch". +def test_metadata_url_rejects_fragment_bearing_issuer() -> None: + with pytest.raises(ValueError, match="must not contain a query or fragment"): + build_metadata_url("https://auth.example.com/t#x") + + +def test_metadata_url_rejects_bare_empty_fragment_issuer() -> None: + with pytest.raises(ValueError, match="must not contain a query or fragment"): + build_metadata_url("https://auth.example.com/t#") + + +def test_metadata_url_query_rejection_does_not_leak_query_value() -> None: + with pytest.raises(ValueError) as excinfo: + build_metadata_url("https://auth.example.com/t?token=secret") + assert "secret" not in str(excinfo.value) + + +def test_metadata_url_rejection_does_not_leak_userinfo() -> None: + # `netloc` carries any embedded userinfo; the redacted message must use the + # bare hostname (plus port when set) so credentials in the authority — here + # `svc:s3cr3t@` — never reach a log line. + with pytest.raises(ValueError) as excinfo: + build_metadata_url("https://svc:s3cr3t@auth.example.com/t?x=1") + assert "s3cr3t" not in str(excinfo.value) + + +# (e) A fragment-bearing resource indicator is rejected in PRM derivation too. +# RFC 8707 §2 forbids a fragment in a resource indicator; the query is still +# preserved (that asymmetry is intentional). +def test_prm_url_rejects_fragment_bearing_resource() -> None: + with pytest.raises(ValueError, match="must not contain a fragment"): + build_prm_url("https://api.example.com/mcp#frag") + + +def test_prm_url_rejection_does_not_leak_userinfo() -> None: + with pytest.raises(ValueError) as excinfo: + build_prm_url("https://svc:s3cr3t@api.example.com/mcp#frag") + assert "s3cr3t" not in str(excinfo.value) + + +async def test_client_create_rejects_query_bearing_issuer() -> None: + # The guard triggers on the AuthplaneClient.create() path before any network + # fetch, so a misconfigured issuer fails fast with a clear message. + with pytest.raises(ValueError, match="must not contain a query or fragment"): + await AuthplaneClient.create( + issuer="https://auth.example.com/?x=1", + fetch_settings=FetchSettings(ssrf_protection=False), + ) + + +async def test_client_create_rejects_fragment_bearing_issuer() -> None: + with pytest.raises(ValueError, match="must not contain a query or fragment"): + await AuthplaneClient.create( + issuer="https://auth.example.com/t#x", + fetch_settings=FetchSettings(ssrf_protection=False), + ) From c89de968582b00890888d8780777847bc4526b96 Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Tue, 18 Aug 2026 19:11:38 -0300 Subject: [PATCH 3/5] fix(sdk): validate identifiers where every constructor converges, with matchable errors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The fragment check for a resource indicator lived only in `build_prm_url`, whose production caller is `AuthplaneResource.prm_url()` — a method reached from inside a 401 response path. A deployment configured with a fragment-bearing identifier therefore started normally and failed later, from the failure path, as a 500. It is now rejected at `AuthplaneClient.resource(...)`, symmetrically with the issuer check in `create()`, and again in `AuthplaneResource` itself so the gate holds for a resource built without the factory. The check in `build_prm_url` stays as a backstop. `InvalidIssuerError` and `InvalidResourceError` subclass both `AuthplaneError` and `ValueError`, so existing `except ValueError` handlers keep working while callers gain the ability to tell an identifier misconfiguration apart from any other `ValueError` the SDK raises — which matters now that the resource rejection is a behaviour change deployments have to react to. `build_prm_url` and `build_metadata_url` no longer strip slashes from the front of the path: `str.strip("/")` took them from both ends, so `//mcp` lost a segment and derived the same well-known URL as `/mcp` — two distinct identifiers collapsing onto one document. RFC 9728 §3.1 and RFC 8414 §3.1 speak only of the terminating slash. BREAKING CHANGE: a fragment-bearing resource identifier now raises `InvalidResourceError` at `client.resource(...)` instead of starting and failing later. Remove the fragment — RFC 8707 §2 forbids one in a resource indicator. --- authplane/__init__.py | 4 + authplane/client.py | 32 +++++- authplane/errors.py | 29 +++++ authplane/internal/__init__.py | 3 +- authplane/internal/metadata.py | 24 +++- authplane/internal/urls.py | 191 ++++++++++++++++++++++++-------- authplane/verifier/verifier.py | 35 +++++- tests/internal/test_metadata.py | 29 +++++ tests/internal/test_urls.py | 156 +++++++++++++++++++++++++- tests/test_issuer_identity.py | 74 ++++++++++++- 10 files changed, 520 insertions(+), 57 deletions(-) diff --git a/authplane/__init__.py b/authplane/__init__.py index a10629e..2693d96 100644 --- a/authplane/__init__.py +++ b/authplane/__init__.py @@ -44,7 +44,9 @@ InvalidClientError, InvalidDPoPProofError, InvalidGrantError, + InvalidIssuerError, InvalidRequestError, + InvalidResourceError, InvalidScopeError, InvalidSignatureError, JWKSFetchError, @@ -103,7 +105,9 @@ "InvalidClientError", "InvalidDPoPProofError", "InvalidGrantError", + "InvalidIssuerError", "InvalidRequestError", + "InvalidResourceError", "InvalidScopeError", "InvalidSignatureError", "JWKSFetchError", diff --git a/authplane/client.py b/authplane/client.py index eea8c5c..7d2235a 100644 --- a/authplane/client.py +++ b/authplane/client.py @@ -17,6 +17,7 @@ JWKSCache, MetadataCache, build_metadata_url, + validate_resource_indicator, ) from .net import FetchSettings from .net.ssrf import SSRFError @@ -132,9 +133,10 @@ async def create( circuit trips. Default 30s. Raises: - ValueError: If ``issuer`` carries a query or fragment component - (RFC 8414 §2 forbids both). This fails fast at construction, - before any network fetch. + InvalidIssuerError: If ``issuer`` carries a query or fragment + component (RFC 8414 §2 forbids both). This fails fast at + construction, before any network fetch. Subclasses ``ValueError``, + so an existing ``except ValueError`` still catches it. """ client = cls() # Identity: the issuer is an identifier (RFC 9068 `iss`), stored verbatim @@ -427,9 +429,33 @@ def resource( ``dpop_signing_alg_values_supported`` and ``dpop_bound_access_tokens_required``; omitting the argument keeps DPoP fields out of PRM entirely. + + Raises: + InvalidResourceError: If *resource* carries a fragment component. + RFC 8707 §2 forbids one in a resource indicator. Rejected here, + at construction, for the same reason ``create()`` rejects a + malformed issuer — the alternative is surfacing it from + ``prm_url()`` while composing an RFC 9728 challenge, i.e. from + inside a 401 response path. Subclasses ``ValueError``, so an + existing ``except ValueError`` still catches it. + ValueError: If *allowed_algorithms* contains an algorithm outside + ``("RS256", "ES256")``. Raised by + :class:`~authplane.verifier.AuthplaneResource`'s constructor, + which this method forwards to, and propagated unchanged. """ from .verifier import AuthplaneResource + # Deliberately duplicated: AuthplaneResource.__init__ runs this same + # gate, and it — not this call — is the authoritative one, since it also + # covers constructing the package-root export directly. What this call + # is load-bearing for is the traceback: it raises at the line the + # operator wrote, symmetrically with the issuer guard in create(), + # rather than one frame deeper in the constructor. Pinned by + # test_client_resource_rejects_fragment_at_construction, which asserts + # the invoking frame — deleting this line turns that test red rather + # than changing behaviour. + validate_resource_indicator(resource) + # fail_closed is only consulted when a revocation check runs; setting # it without a checker means no revocation check happens at all, which # is the opposite of what the operator asked for — make it observable. diff --git a/authplane/errors.py b/authplane/errors.py index 1e4c573..576b5c6 100644 --- a/authplane/errors.py +++ b/authplane/errors.py @@ -23,6 +23,35 @@ class AuthplaneError(Exception): pass +class InvalidIssuerError(AuthplaneError, ValueError): + """Raised when an issuer identifier is not the shape RFC 8414 §2 requires. + + Inherits ``ValueError`` as well as ``AuthplaneError`` so existing + ``except ValueError`` handlers keep working — the identifier guards raised a + bare ``ValueError`` before this class existed, and that is a public contract. + What it adds is the ability to tell *which* ``ValueError``: a configuration + error on the issuer was previously indistinguishable from, say, + ``jwks_refresh_seconds must be positive``. + + """ + + pass + + +class InvalidResourceError(AuthplaneError, ValueError): + """Raised when a resource indicator is not the shape RFC 8707 §2 requires. + + Same additive shape as :class:`InvalidIssuerError`, and for the same reason: + rejecting a fragment-bearing resource at ``AuthplaneClient.resource(...)`` is + a behaviour change for a deployment that used to start, and the only way to + catch it specifically was ``except ValueError`` — which is exactly the + undiscriminating handler the issuer half of this work set out to improve on. + Typing one identifier and not the other would have left that half-done. + """ + + pass + + class TokenMissingError(AuthplaneError): """Raised when no token is provided for validation.""" diff --git a/authplane/internal/__init__.py b/authplane/internal/__init__.py index a15075a..daf4281 100644 --- a/authplane/internal/__init__.py +++ b/authplane/internal/__init__.py @@ -10,7 +10,7 @@ from .document_fetcher import DocumentFetcher from .fetch_result import FetchResult from .metadata import MetadataCache -from .urls import build_metadata_url, build_prm_url +from .urls import build_metadata_url, build_prm_url, validate_resource_indicator __all__ = [ "DocumentCache", @@ -23,4 +23,5 @@ "build_metadata_url", "build_prm_url", "parse_expires_at", + "validate_resource_indicator", ] diff --git a/authplane/internal/metadata.py b/authplane/internal/metadata.py index 6625290..49e86e5 100644 --- a/authplane/internal/metadata.py +++ b/authplane/internal/metadata.py @@ -2,7 +2,7 @@ import logging from typing import Any -from urllib.parse import urlparse +from urllib.parse import urlsplit from ..errors import MetadataFetchError, MissingMetadataEndpointError from .document_cache import DocumentCache, DocumentChangeCallback, DocumentFetcherCallable @@ -43,7 +43,27 @@ def _validate_endpoint_url(self, field: str, value: str) -> None: In production mode (allow_http=False), endpoint URLs must be absolute HTTPS URLs. In dev mode (allow_http=True), HTTP is also permitted. """ - parsed = urlparse(value) + # urlsplit, guarded. Two urllib traps escape as a bare ValueError on a + # malformed authority — a netloc with `[` and no `]` ("Invalid IPv6 + # URL"), and a non-numeric port, which is parsed lazily and raises at + # attribute access — and this value is AS metadata, i.e. remote content. + # The MCP adapters catch only AuthplaneError, so an unwrapped ValueError + # here turns a metadata rejection into an unhandled 500. Same guard as + # `_split_dpop_url` and `internal/urls.py`; this call site was the one + # left out of that audit. + # + # urlsplit rather than urlparse for the module's one parse idiom: only + # scheme and netloc are read, so `;params` cannot reach anything here, + # but the safety of a urlparse should not have to be re-argued per site. + try: + parsed = urlsplit(value) + # Read, not discarded: SplitResult.port is parsed lazily, so an + # out-of-range or non-numeric port raises here rather than at split. + _ = parsed.port + except ValueError as exc: + raise MetadataFetchError( + f"AS metadata field {field!r} is not a valid URL: {value!r}" + ) from exc if not parsed.scheme or not parsed.netloc: raise MetadataFetchError( f"AS metadata field {field!r} is not an absolute URL: {value!r}" diff --git a/authplane/internal/urls.py b/authplane/internal/urls.py index fca1a89..b8bbd2e 100644 --- a/authplane/internal/urls.py +++ b/authplane/internal/urls.py @@ -1,22 +1,104 @@ """URL utilities for OAuth 2.0 metadata discovery (RFC 8414) and PRM (RFC 9728).""" -from urllib.parse import ParseResult, urlparse, urlunparse +from urllib.parse import urlsplit, urlunsplit +from ..errors import InvalidIssuerError, InvalidResourceError -def _redact_authority(parsed: ParseResult) -> str: + +def host_literal(hostname: str) -> str: + """Re-bracket an IPv6 literal for use in an authority component. + + ``SplitResult.hostname`` strips the brackets RFC 3986 §3.2.2 requires around + an IPv6 literal, so every place that reassembles an authority from it has to + put them back — otherwise ``https://[::1]:8080/x`` comes back out as + ``https://::1:8080/x``, which is not a valid URI and which collides with the + distinct endpoint ``https://[::1:8080]/x``. + + One definition rather than three: this expression lived inline in + ``net/ssrf.py``'s ``Host`` header reconstruction, was added to ``dpop.py`` + for the ``htu`` and the nonce origin, and was missing here — three copies + required to agree with none referencing the others, which is how the two + halves of one DPoP binding check came to bracket differently in the first + place. + """ + return f"[{hostname}]" if ":" in hostname else hostname + + +def _redact_authority(raw: str) -> str: """Return ``scheme://host[:port]/path`` for use in error messages. + Takes the raw string and parses defensively. Every caller is on an error + path handling a malformed identifier, and ``urlsplit`` itself raises on some + of them — it splits the netloc at the first of ``/?#`` and then + rejects a netloc containing ``[`` without ``]``, so + ``"https://[::1#frag"`` raises ``ValueError("Invalid IPv6 URL")`` before the + fragment is ever separated. Parsing outside a guard would surface urllib's + message in place of the RFC citation the caller wrote. + Uses ``hostname`` (never ``netloc``) so any userinfo embedded in the authority — e.g. ``svc:s3cr3t@`` — is never echoed into a ``ValueError`` - that may be logged. The path is kept because it is not credential-shaped, - while any query/fragment is dropped by the caller before this is built. + that may be logged. The path is kept because it is not credential-shaped; + the query and fragment are dropped here, since callers pass the raw + identifier and it is precisely a query- or fragment-bearing one that reaches + these guards. """ + try: + parsed = urlsplit(raw) + except ValueError: + return "(unparseable identifier)" host = parsed.hostname or "" - if parsed.port is not None: - host = f"{host}:{parsed.port}" + # ``ParseResult.port`` parses the port lazily and raises ValueError on a + # malformed authority — ``https://h:abc/`` is exactly the kind of input that + # reaches the guards below. Raising while *building* the error message would + # surface urllib's "Port could not be cast to integer value" instead of the + # RFC citation the caller wrote, so fall back to the bare hostname. + try: + port = parsed.port + except ValueError: + port = None + host = host_literal(host) + if port is not None: + host = f"{host}:{port}" return f"{parsed.scheme}://{host}{parsed.path}" +def validate_resource_indicator(resource: str) -> None: + """Raise ``InvalidResourceError`` if *resource* is not a usable indicator. + + RFC 8707 §2 forbids a fragment component. ``urlunsplit`` drops one silently, + so an indicator carrying a fragment would resolve to a document it does not + actually name. + + This is the construction-time gate. It has three call sites, and which one is + authoritative matters: + + * ``AuthplaneResource.__init__`` — the authoritative one. Every construction + path reaches it, including direct construction of the package-root export. + * ``AuthplaneClient.resource()`` — redundant for the guarantee, kept for the + traceback: it raises at the line the operator wrote rather than one frame + deeper in the constructor. + * ``build_prm_url`` — a defensive backstop, and it must not be the only one: + its production caller is ``AuthplaneResource.prm_url()``, which operators + invoke to build the ``resource_metadata`` parameter of an RFC 9728 + challenge — i.e. inside a 401 response path. Validating only there turns a + configuration error into a 500 on the failure path, which is the worst + place to discover it. + + Args: + resource: The resource indicator, as configured by the operator. + + Raises: + InvalidResourceError: If the indicator carries a fragment component. + Subclasses ``ValueError``, so an existing ``except ValueError`` still + catches it. + """ + if "#" in resource: + safe = _redact_authority(resource) + raise InvalidResourceError( + f"resource indicator must not contain a fragment component (RFC 8707 §2): {safe!r}" + ) + + def build_prm_url(resource: str) -> str: """Build the RFC 9728 well-known Protected Resource Metadata URL. @@ -48,40 +130,53 @@ def build_prm_url(resource: str) -> str: The fully constructed PRM discovery URL. Raises: - ValueError: If the resource indicator carries a fragment component. - RFC 8707 §2 forbids a fragment in a resource indicator; it is - rejected here rather than silently discarded by ``urlunparse``. + InvalidResourceError: If the resource indicator carries a fragment + component. RFC 8707 §2 forbids a fragment in a resource indicator; + it is rejected here rather than silently discarded by + ``urlunsplit``. Subclasses ``ValueError``, so an existing + ``except ValueError`` still catches it. """ - parsed = urlparse(resource) - - # RFC 8707 §2: a resource indicator MUST NOT contain a fragment component. - # (A query IS preserved below per RFC 9728 §3.1 — the two components are - # treated asymmetrically.) urlunparse silently drops a fragment, so gate on - # the raw string and reject it explicitly rather than letting a malformed - # indicator resolve to a document it does not actually name. - if "#" in resource: - safe = _redact_authority(parsed) - raise ValueError( - f"resource indicator must not contain a fragment component (RFC 8707 §2): {safe!r}" - ) - - path = parsed.path.strip("/") - - if path: - well_known_path = f"/.well-known/oauth-protected-resource/{path}" - else: - well_known_path = "/.well-known/oauth-protected-resource" + # Defensive backstop. The authoritative gate is validate_resource_indicator + # called from AuthplaneResource.__init__, which every construction path + # reaches — see its docstring for the full call-site map and for why this + # must not be the only check. (A query IS preserved below per RFC 9728 §3.1; + # the two components are treated asymmetrically.) + # + # Before parsing, for the same reason build_metadata_url checks first: + # urlsplit raises on some malformed inputs, so parsing above the guard + # surfaces urllib's message in place of the RFC citation. + validate_resource_indicator(resource) + + # urlsplit, not urlparse: urlparse peels an RFC 3986 ";params" segment off the + # last path segment, and urlunparse's params slot then has to be filled or the + # segment is dropped. Passing "" dropped it, so "/mcp;v=1" and "/mcp" derived + # the same document — the collapse this module exists to prevent. urlsplit + # keeps it in `path`, which is also what the two adapter copies do. + parsed = urlsplit(resource) + + # Keep the path's own leading slash and remove only terminating ones, then + # concatenate. ``strip("/")`` removed slashes from both ends, so a doubled + # leading slash ("//mcp") lost a segment and derived the same URL as "/mcp" — + # two distinct identifiers collapsing onto one document. §3.1 only speaks of + # the *terminating* slash. + # + # The separator is re-added when the parsed path has none: nothing upstream + # requires the identifier to be absolute, and for a scheme-less input + # urlsplit puts the whole authority in ``path`` with no leading slash. + path = parsed.path.rstrip("/") + if path and not path.startswith("/"): + path = "/" + path + well_known_path = "/.well-known/oauth-protected-resource" + path # RFC 9728 §3.1 inserts the well-known segment between the host and "the path # and/or query components" of the resource identifier — so the query survives # into the derived PRM URL. (The path strip above is the correct §3.1 # derivation behavior and is unrelated to identity comparison.) - return urlunparse( + return urlunsplit( ( parsed.scheme, parsed.netloc, well_known_path, - "", parsed.query, "", ) @@ -114,45 +209,49 @@ def build_metadata_url(issuer: str) -> str: The fully constructed metadata discovery URL. Raises: - ValueError: If the issuer carries a query or fragment component. RFC + InvalidIssuerError: If the issuer carries a query or fragment component. + Subclasses ``ValueError``, so existing ``except ValueError`` + handlers are unaffected. RFC 8414 §2 requires the issuer identifier to have neither. This raises at construction rather than silently discarding the component — that reconciliation would let a malformed identifier resolve to a document it does not actually name, and would later surface as a confusing "issuer mismatch" instead of the real cause. (A fragment - is not preserved by ``urlunparse`` at all, so without this gate a + is not preserved by ``urlunsplit`` at all, so without this gate a fragment-bearing issuer would be silently dropped.) """ - parsed = urlparse(issuer) - + # The raw-string check comes first: urlsplit itself raises on some malformed + # identifiers (an unclosed IPv6 bracket, for one), and this guard exists to + # report the RFC violation, not urllib's parse error. + # # RFC 8414 §2: the issuer identifier MUST NOT contain a query OR fragment # component. Gate on the raw string so a bare `?`/`#` (empty component, which - # urlparse reports as empty) is rejected too — the delimiter must never - # survive into the derived `.well-known` URL, and urlunparse silently drops a + # urlsplit reports as empty) is rejected too — the delimiter must never + # survive into the derived `.well-known` URL, and urlunsplit silently drops a # fragment entirely. Report only scheme://host/path (bare hostname, never # netloc) so a credential-shaped query (e.g. `?token=...`) or embedded # userinfo does not leak into the message. if any(c in issuer for c in ("?", "#")): - safe = _redact_authority(parsed) - raise ValueError( + safe = _redact_authority(issuer) + raise InvalidIssuerError( "issuer identifier must not contain a query or fragment component " f"(RFC 8414 §2): {safe!r}" ) - # Strip leading/trailing slashes from the path to normalize - path = parsed.path.strip("/") + parsed = urlsplit(issuer) - if path: - well_known_path = f"/.well-known/oauth-authorization-server/{path}" - else: - well_known_path = "/.well-known/oauth-authorization-server" + # Keep the path's own leading slash and remove only terminating ones, and + # re-add the separator when there is none — see the note in build_prm_url. + path = parsed.path.rstrip("/") + if path and not path.startswith("/"): + path = "/" + path + well_known_path = "/.well-known/oauth-authorization-server" + path - return urlunparse( + return urlunsplit( ( parsed.scheme, parsed.netloc, well_known_path, - "", # params "", # query "", # fragment ) diff --git a/authplane/verifier/verifier.py b/authplane/verifier/verifier.py index ebd7db2..7632a7d 100644 --- a/authplane/verifier/verifier.py +++ b/authplane/verifier/verifier.py @@ -36,7 +36,7 @@ VerifierRuntimeError, ) from ..internal.jwt import decode_jwt_header -from ..internal.urls import build_prm_url +from ..internal.urls import build_prm_url, validate_resource_indicator from ..oauth.prm import build_prm from ..oauth.types import IntrospectionRevocation from .claims import VerifiedClaims, freeze_value @@ -51,7 +51,23 @@ class AuthplaneResource: - """Verifies RFC 9068-style JWT access tokens.""" + """Verifies RFC 9068-style JWT access tokens. + + Usually built through :meth:`AuthplaneClient.resource`, but the class is + exported from the package root and constructing it directly is supported. + Either way the constructor validates the resource indicator, so the + construction-time guarantee does not depend on which path was taken. + + Raises: + InvalidResourceError: If *resource* carries a fragment component. + RFC 8707 §2 forbids one in a resource indicator. Rejected here, at + construction, rather than from ``prm_url()`` while composing an + RFC 9728 challenge — i.e. from inside a 401 response path. + Subclasses ``ValueError``, so an existing ``except ValueError`` + still catches it. + ValueError: If *allowed_algorithms* contains an algorithm outside + ``("RS256", "ES256")``. + """ def __init__( self, @@ -64,6 +80,21 @@ def __init__( fail_closed: bool = False, inbound_dpop: InboundDPoPOptions | None = None, ) -> None: + # THIS is the authoritative resource gate — every construction path + # goes through it. The class is exported from the package root, so + # constructing it directly is supported, and a gate living only in + # AuthplaneClient.resource() would let that path defer the rejection to + # prm_url(), i.e. to an RFC 9728 challenge on a 401 response path. That + # is the failure mode the check exists to prevent. + # + # The factory keeps a call of its own, deliberately: it is redundant + # for the guarantee and load-bearing for the traceback, raising at the + # line the operator wrote rather than one frame deeper in here. Pinned + # by test_client_resource_rejects_fragment_at_construction, which + # asserts the invoking frame — do not dedupe the pair without reading + # it. build_prm_url's call is the third, a defensive backstop. + validate_resource_indicator(resource) + invalid = [alg for alg in allowed_algorithms if alg not in _ALLOWED_ALGORITHMS] if invalid: raise ValueError( diff --git a/tests/internal/test_metadata.py b/tests/internal/test_metadata.py index 0acbdf3..406aa4f 100644 --- a/tests/internal/test_metadata.py +++ b/tests/internal/test_metadata.py @@ -285,3 +285,32 @@ async def test_get_token_endpoint_missing_field() -> None: with pytest.raises(MetadataFetchError, match="token_endpoint"): await cache.get_token_endpoint() + + +@pytest.mark.parametrize( + "bad_url", + [ + "https://[::1/jwks", # urlsplit itself: "Invalid IPv6 URL" + "https://auth.example.com:notaport/jwks", # port cast, at attribute access + "https://auth.example.com:99999/jwks", # port out of range + ], +) +async def test_malformed_endpoint_url_raises_the_sdk_error(bad_url: str) -> None: + """A malformed authority in AS metadata must not escape as a bare ValueError. + + This value is remote content, and the MCP adapters catch only + AuthplaneError — so an unwrapped urllib ValueError turns a metadata + rejection into an unhandled 500. Same guard as ``_split_dpop_url`` and + ``internal/urls.py``; this call site was the one left out of that audit. + """ + metadata = dict(SAMPLE_METADATA) + metadata["jwks_uri"] = bad_url + + class BadFetcher: + async def __call__(self, *_args: Any, **_kwargs: Any) -> FetchResult: + return FetchResult(document=metadata) + + cache = MetadataCache(BadFetcher(), document_type="metadata") + + with pytest.raises(MetadataFetchError): + await cache.get_jwks_uri() diff --git a/tests/internal/test_urls.py b/tests/internal/test_urls.py index 94e3552..4760eaa 100644 --- a/tests/internal/test_urls.py +++ b/tests/internal/test_urls.py @@ -1,6 +1,13 @@ """Tests for URL utilities (RFC 8414 metadata URL construction).""" -from authplane.internal.urls import build_metadata_url +import pytest + +from authplane.errors import InvalidIssuerError, InvalidResourceError +from authplane.internal.urls import ( + build_metadata_url, + build_prm_url, + validate_resource_indicator, +) class TestBuildMetadataUrl: @@ -54,3 +61,150 @@ def test_http_issuer_with_path(self) -> None: """HTTP issuer with path inserts .well-known correctly.""" result = build_metadata_url("http://localhost:3000/tenant1") assert result == "http://localhost:3000/.well-known/oauth-authorization-server/tenant1" + + +class TestResourceIndicatorValidation: + """RFC 8707 §2 — a resource indicator MUST NOT carry a fragment.""" + + def test_validate_rejects_fragment(self) -> None: + with pytest.raises(ValueError, match="must not contain a fragment"): + validate_resource_indicator("https://api.example.com/mcp#frag") + + def test_validate_accepts_query(self) -> None: + # A query is legal and is preserved by the derivation (RFC 9728 §3.1); + # only the fragment is forbidden. The two are treated asymmetrically. + validate_resource_indicator("https://api.example.com/mcp?tenant=a") + + def test_validate_does_not_echo_credentials(self) -> None: + with pytest.raises(ValueError) as exc: + validate_resource_indicator("https://svc:s3cr3t@api.example.com/mcp#frag") + assert "s3cr3t" not in str(exc.value) + assert "api.example.com" in str(exc.value) + + def test_malformed_port_does_not_mask_the_rfc_error(self) -> None: + # ParseResult.port raises ValueError on a non-integer port. Building the + # redacted authority for the error message must not surface urllib's + # "Port could not be cast to integer value" in place of the RFC citation. + with pytest.raises(ValueError) as exc: + validate_resource_indicator("https://h:abc/mcp#frag") + assert "must not contain a fragment" in str(exc.value) + assert "cast to integer" not in str(exc.value) + + +def test_unparseable_authority_does_not_mask_the_rfc_error() -> None: + # urlparse raises ValueError("Invalid IPv6 URL") on an unclosed bracket — + # before the fragment is ever split off. Both guards must still report the + # RFC violation they were written for, not urllib's parse failure. + with pytest.raises(ValueError) as exc: + validate_resource_indicator("https://[::1#frag") + assert "must not contain a fragment" in str(exc.value) + assert "IPv6" not in str(exc.value) + + with pytest.raises(ValueError) as exc: + build_metadata_url("https://[::1?x=1") + assert "must not contain a query or fragment" in str(exc.value) + assert "IPv6" not in str(exc.value) + + +def test_build_prm_url_also_reports_the_rfc_error_not_urllib_s() -> None: + # The third guard kept the original shape after the first two were fixed: + # build_prm_url parsed before calling validate_resource_indicator, so an + # unclosed IPv6 bracket surfaced "Invalid IPv6 URL" instead of the citation. + with pytest.raises(ValueError) as exc: + build_prm_url("https://[::1#frag") + assert "must not contain a fragment" in str(exc.value) + assert "IPv6" not in str(exc.value) + + +class TestLeadingSlashPreservation: + """A doubled leading slash is a distinct identifier, not a normalization.""" + + def test_prm_double_leading_slash_does_not_collapse(self) -> None: + assert build_prm_url("https://api.example.com//mcp") != build_prm_url( + "https://api.example.com/mcp" + ) + + def test_metadata_double_leading_slash_does_not_collapse(self) -> None: + assert build_metadata_url("https://auth.example.com//t") != build_metadata_url( + "https://auth.example.com/t" + ) + + def test_terminating_slash_still_stripped(self) -> None: + assert build_prm_url("https://api.example.com/mcp/") == build_prm_url( + "https://api.example.com/mcp" + ) + + +def test_issuer_error_is_matchable_and_still_a_value_error() -> None: + # Additive: the guards raised a bare ValueError before, and that is a public + # contract — but it was indistinguishable from any other ValueError the SDK + # raises (e.g. "jwks_refresh_seconds must be positive"). + with pytest.raises(InvalidIssuerError): + build_metadata_url("https://auth.example.com/?x=1") + with pytest.raises(ValueError): + build_metadata_url("https://auth.example.com/?x=1") + + +def test_scheme_less_identifier_keeps_the_path_separator() -> None: + # Nothing upstream requires the identifier to be absolute — create() gates + # only on ?/# and resource() only on #. For a scheme-less input urlparse + # puts the whole authority in `path` with no leading slash, so concatenating + # it directly mashed the segment onto the well-known suffix + # ("...oauth-authorization-serverapi.example.com/mcp"). + assert build_metadata_url("api.example.com/mcp").startswith( + "/.well-known/oauth-authorization-server/" + ) + assert build_prm_url("api.example.com/mcp").startswith("/.well-known/oauth-protected-resource/") + + +class TestParamsSegmentIsNotCollapsed: + """An RFC 3986 ";params" segment is part of the path, not a separate slot. + + ``urlparse`` peels it off the last path segment into ``ParseResult.params``; + ``urlunparse`` then needs it passed back or the segment is silently dropped. + Both builders passed "", so "/mcp;v=1" and "/mcp" derived the same document — + two distinct identifiers collapsing onto one, which is the whole class of bug + the sibling tests above cover for the leading and terminating slash. + """ + + def test_prm_params_segment_does_not_collapse(self) -> None: + assert build_prm_url("https://api.example.com/mcp;v=1") != build_prm_url( + "https://api.example.com/mcp" + ) + + def test_metadata_params_segment_does_not_collapse(self) -> None: + assert build_metadata_url("https://auth.example.com/t;jsessionid=1") != ( + build_metadata_url("https://auth.example.com/t") + ) + + def test_prm_params_segment_is_kept_verbatim(self) -> None: + # Not just "different" — the segment has to survive into the derived URL. + assert build_prm_url("https://api.example.com/mcp;v=1").endswith( + "/.well-known/oauth-protected-resource/mcp;v=1" + ) + + +def test_resource_error_is_matchable_and_still_a_value_error() -> None: + # The counterpart of test_issuer_error_is_matchable_and_still_a_value_error. + # Both guards raised a bare ValueError before, and that stays a public + # contract; what the class adds is telling an identifier misconfiguration + # apart from any other ValueError the SDK raises. + with pytest.raises(InvalidResourceError): + validate_resource_indicator("https://api.example.com/mcp#frag") + with pytest.raises(ValueError): + validate_resource_indicator("https://api.example.com/mcp#frag") + with pytest.raises(InvalidResourceError): + build_prm_url("https://api.example.com/mcp#frag") + + +def test_identifier_errors_are_exported_from_the_package_root() -> None: + # Both classes exist to be caught by name, which only works if they are + # importable from the root. Nothing else in the suite imports them from + # there — the tests above reach into authplane.errors — so without this the + # __all__ entries could rot without anything noticing. + import authplane + + assert "InvalidIssuerError" in authplane.__all__ + assert "InvalidResourceError" in authplane.__all__ + assert authplane.InvalidIssuerError is InvalidIssuerError + assert authplane.InvalidResourceError is InvalidResourceError diff --git a/tests/test_issuer_identity.py b/tests/test_issuer_identity.py index 0888ac4..3bad3a2 100644 --- a/tests/test_issuer_identity.py +++ b/tests/test_issuer_identity.py @@ -7,12 +7,13 @@ """ from collections.abc import AsyncGenerator, Callable +from pathlib import Path from typing import Any import pytest import respx -from authplane import AuthplaneClient, FetchSettings +from authplane import AuthplaneClient, AuthplaneResource, FetchSettings from authplane.errors import InvalidClaimsError, MetadataFetchError from authplane.internal.fetch_result import FetchResult from authplane.internal.metadata import MetadataCache @@ -186,7 +187,7 @@ def test_metadata_url_rejects_bare_empty_query_issuer() -> None: # (d2) A fragment-bearing issuer is rejected too. RFC 8414 §2 forbids BOTH a -# query and a fragment; urlunparse silently drops a fragment, so without the +# query and a fragment; urlunsplit silently drops a fragment, so without the # gate `https://auth.example.com/t#x` would derive a fragment-free .well-known # URL and later surface as a confusing "issuer mismatch". def test_metadata_url_rejects_fragment_bearing_issuer() -> None: @@ -244,3 +245,72 @@ async def test_client_create_rejects_fragment_bearing_issuer() -> None: issuer="https://auth.example.com/t#x", fetch_settings=FetchSettings(ssrf_protection=False), ) + + +async def test_client_resource_rejects_fragment_at_construction( + client: AuthplaneClient, +) -> None: + # Symmetric with the issuer guard on create(). Before this, the only check + # lived in build_prm_url, whose production caller is prm_url() — invoked + # while composing an RFC 9728 challenge on a 401 path — so a fragment in the + # configured resource turned a startup misconfiguration into a 500 emitted + # from the failure path. + with pytest.raises(ValueError, match="must not contain a fragment") as excinfo: + client.resource("https://api.example.com/mcp#frag") + + # AuthplaneResource.__init__ gates the indicator too, so the rejection would + # still happen with the factory's own call deleted — just one frame deeper, + # pointing at the constructor rather than at the line the operator wrote. + # That is the whole reason the duplicate call is kept, so pin it: the raise + # itself is always in urls.py (validate_resource_indicator), and what this + # asserts is which frame invoked it. + # ``TracebackEntry.path`` is typed ``Path | str``, hence the round-trip. + # + # The expected filename is read off the method itself rather than written + # as "client.py": the claim is "the factory's own call raised", not "the + # factory lives in a file of that name", and hardcoding the second turns a + # module rename into a red test with no behaviour change — the coupling this + # case says it does not want. Deleting the factory's call still reddens it, + # because the frame then reads verifier.py. + factory_module = Path(AuthplaneClient.resource.__code__.co_filename).name + frames = [Path(str(entry.path)).name for entry in excinfo.traceback] + assert frames[-2] == factory_module + + +async def test_authplane_resource_rejects_fragment_when_constructed_directly( + client: AuthplaneClient, +) -> None: + # AuthplaneResource is exported from the package root, so constructing it + # without the factory is a supported path — and it used to skip the gate + # entirely, which left the guarantee above one path short of true. Same + # rejection, at the constructor. + with pytest.raises(ValueError, match="must not contain a fragment"): + AuthplaneResource( + client, + resource="https://api.example.com/mcp#frag", + scopes=[], + allowed_algorithms=["RS256"], + ) + + +async def test_authplane_resource_accepts_query_when_constructed_directly( + client: AuthplaneClient, +) -> None: + # The other direction: the constructor gate must not reject what the + # factory accepts. A query is legal (RFC 9728 §3.1); only the fragment is not. + resource = AuthplaneResource( + client, + resource="https://api.example.com/mcp?tenant=a", + scopes=[], + allowed_algorithms=["RS256"], + ) + # Also pins that the gate is a check, not a normalization: the constructor + # stores the configured string byte-for-byte, query included. + assert resource.resource == "https://api.example.com/mcp?tenant=a" + + +async def test_client_resource_accepts_query(client: AuthplaneClient) -> None: + # RFC 9728 §3.1 derives over "the path and/or query components", so a query + # is legal on a resource indicator; only the fragment is forbidden. + resource = client.resource("https://api.example.com/mcp?tenant=a") + assert resource is not None From 1e86bfa97b4e72172539a3fb40cf1712ed3bf83e Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Tue, 18 Aug 2026 19:11:54 -0300 Subject: [PATCH 4/5] fix(dpop,mcp,fastmcp): keep the ;params segment in the htu binding, make the PRM hook public MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `urlparse` peels an RFC 3986 `;params` segment off the last path segment, so a request to `/orders;v=2` bound an `htu` of `/orders` — a proof computed over a different resource than the one being accessed, and RFC 3986 §3.3 puts `;params` squarely in the path. The htu construction uses `urlsplit`, which does not split it off, and the round-trip no longer has to fill `urlunparse`'s params slot. `VerbatimPRMRemoteAuthProvider` is now public (was `_VerbatimPRMRemoteAuthProvider`) and `rewrite_prm_routes_verbatim` is exported: building a `RemoteAuthProvider` by hand is a documented FastMCP pattern, and doing so silently lost the verbatim PRM. `install_request_context(mcp)` now detects a verifier carrying no verbatim identifiers and warns instead of skipping the rewrite in silence — a verifier built through the public `AuthplaneTokenVerifier(verifier)` constructor has none, so the previous `is None` check never fired for it and the served document kept advertising slash-normalized identifiers that this SDK's own byte-for-byte comparison rejects. `AuthplaneTokenVerifier.verbatim_identifiers()` exposes the pair without cross-module private attribute reads. `install_request_context` also stops touching `mcp.sse_app` unguarded: SSE is not on the streamable-HTTP path, so a future `mcp` 1.x that drops the attribute would have taken down servers that never touch SSE. --- authplane-fastmcp/README.md | 34 +++ .../authplane_fastmcp/__init__.py | 9 +- authplane-fastmcp/authplane_fastmcp/_prm.py | 89 +++++- authplane-fastmcp/authplane_fastmcp/auth.py | 37 ++- .../authplane_fastmcp/url_elicitation.py | 21 +- authplane-fastmcp/docs/user-guide.md | 30 +- authplane-fastmcp/pyrightconfig.json | 7 +- authplane-fastmcp/tests/conftest.py | 70 ++++- authplane-fastmcp/tests/test_auth_factory.py | 241 ++++++++++++++++- authplane-fastmcp/tests/test_integration.py | 256 +++++++++++++++++- authplane-fastmcp/tests/test_prm.py | 79 +++++- .../tests/test_url_elicitation.py | 32 ++- authplane-mcp/authplane_mcp/_prm.py | 89 +++++- authplane-mcp/authplane_mcp/auth.py | 90 ++++-- .../authplane_mcp/url_elicitation.py | 21 +- authplane-mcp/authplane_mcp/verifier.py | 42 ++- authplane-mcp/docs/user-guide.md | 2 +- authplane-mcp/pyproject.toml | 6 +- authplane-mcp/tests/test_integration.py | 105 +++++++ authplane-mcp/tests/test_prm.py | 79 +++++- authplane-mcp/tests/test_request_context.py | 116 +++++++- authplane-mcp/tests/test_url_elicitation.py | 30 +- authplane/docs/user-guide.md | 10 +- authplane/dpop.py | 60 +++- authplane/dpop_verification.py | 15 +- authplane/net/ssrf.py | 42 ++- tests/net/test_ssrf.py | 112 ++++++++ tests/net/test_ssrf_edge_cases.py | 9 + tests/test_dpop_and_security.py | 147 ++++++++++ tests/test_protocol_and_http_edges.py | 102 +++++++ 30 files changed, 1827 insertions(+), 155 deletions(-) diff --git a/authplane-fastmcp/README.md b/authplane-fastmcp/README.md index 7678991..12954e9 100644 --- a/authplane-fastmcp/README.md +++ b/authplane-fastmcp/README.md @@ -48,6 +48,40 @@ asyncio.run(main()) `authplane_auth()` holds background JWKS and metadata refresh tasks; call `aclose()` on the returned `client` during server shutdown. +## Hand-rolling the auth provider + +`authplane_auth()` returns a `VerbatimPRMRemoteAuthProvider`, a `RemoteAuthProvider` +subclass that serves the Protected Resource Metadata identifiers byte-for-byte. +It matters: upstream builds the PRM from `pydantic.AnyHttpUrl` fields, which append +a trailing slash to an empty-path authority, and the core SDK compares identifiers +verbatim — so a client that follows the advertised value literally is rejected. + +If you build a `RemoteAuthProvider` yourself instead of calling `authplane_auth()` +— a documented FastMCP pattern — use the subclass rather than the base class: + +```python +from authplane_fastmcp import VerbatimPRMRemoteAuthProvider +from pydantic import AnyHttpUrl + +provider = VerbatimPRMRemoteAuthProvider( + token_verifier=token_verifier, + authorization_servers=[AnyHttpUrl(issuer)], + base_url=AnyHttpUrl(base_url), + scopes_supported=scopes, + # The two that make it verbatim. Pass the identifiers exactly as configured, + # not the AnyHttpUrl forms above — that is the whole point: those normalize. + verbatim_issuer=issuer, + verbatim_resource=resource, +) +``` + +`base_url` is the server's base URL and `verbatim_resource` is the full resource +identifier; they are not the same value when the MCP server is mounted under a +path. + +If you cannot subclass, `rewrite_prm_routes_verbatim(routes, issuer=..., resource=...)` +is exported as a supported hook — apply it to the route list your provider returns. + ## Documentation PRM behavior, dev mode, revocation checking, manual setup, scope enforcement semantics, claim access, the full `authplane_auth` / `AuthplaneTokenVerifier` API, and error handling: **[User Guide](https://github.com/AuthPlane/python-sdk/blob/main/authplane-fastmcp/docs/user-guide.md)**. diff --git a/authplane-fastmcp/authplane_fastmcp/__init__.py b/authplane-fastmcp/authplane_fastmcp/__init__.py index 849e3f9..e96e130 100644 --- a/authplane-fastmcp/authplane_fastmcp/__init__.py +++ b/authplane-fastmcp/authplane_fastmcp/__init__.py @@ -16,14 +16,21 @@ except _PackageNotFoundError: # pragma: no cover - source tree without an install __version__ = "0.0.0+unknown" -from .auth import AuthplaneAuthResult, authplane_auth +from ._prm import rewrite_prm_routes_verbatim +from .auth import ( + AuthplaneAuthResult, + VerbatimPRMRemoteAuthProvider, + authplane_auth, +) from .url_elicitation import to_url_elicitation_required_error from .verifier import AuthplaneTokenVerifier __all__ = [ "AuthplaneAuthResult", "AuthplaneTokenVerifier", + "VerbatimPRMRemoteAuthProvider", "__version__", "authplane_auth", + "rewrite_prm_routes_verbatim", "to_url_elicitation_required_error", ] diff --git a/authplane-fastmcp/authplane_fastmcp/_prm.py b/authplane-fastmcp/authplane_fastmcp/_prm.py index f660784..65076b1 100644 --- a/authplane-fastmcp/authplane_fastmcp/_prm.py +++ b/authplane-fastmcp/authplane_fastmcp/_prm.py @@ -13,11 +13,18 @@ This module post-processes the served PRM response so the two identifier fields carry exactly the operator-configured strings, without touching any other field (scopes, bearer methods, cache headers, CORS) the upstream route emits. + +NOTE: this module is mirrored byte-for-byte in +``authplane-mcp/authplane_mcp/_prm.py``. It is the larger and subtler of +the two duplicated modules — the rewrite gating below is easy to get wrong in one +copy only. Any fix here must be applied to both. """ import json -from collections.abc import Awaitable, Callable, MutableSequence +import warnings +from collections.abc import Awaitable, Callable, Iterable from typing import Any +from urllib.parse import urlsplit from starlette.routing import BaseRoute, Route @@ -37,7 +44,8 @@ def _rewrite_body(body: bytes, *, issuer: str, resource: str) -> bytes: trailing-slash normalization: in ``authorization_servers`` the element equal to ``issuer`` or ``issuer + "/"`` is swapped for the verbatim ``issuer`` and every other entry is left in place, so a multi-AS advertisement keeps its - extra entries. ``resource`` is set verbatim. + extra entries. ``resource`` is replaced outright: the caller only routes this + function at the document belonging to the configured resource. Any body that is not a JSON object (e.g. a CORS preflight with an empty body) is returned unchanged. @@ -55,7 +63,13 @@ def _rewrite_body(body: bytes, *, issuer: str, resource: str) -> bytes: if rewritten != servers: doc["authorization_servers"] = rewritten changed = True - if "resource" in doc and doc["resource"] != resource: + # Unconditional: which document this is was already decided by route + # matching in rewrite_prm_routes_verbatim, so anything served here belongs to + # the configured resource. Gating on the value instead would only cover the + # trailing-slash normalization and silently skip every other one the URL + # layer can apply (host case, an explicit default port, a doubled slash, a + # dot segment) — which is precisely the mismatch this module exists to fix. + if doc.get("resource") != resource: doc["resource"] = resource changed = True if not changed: @@ -113,17 +127,64 @@ async def capture(message: _Message) -> None: return app -def rewrite_prm_routes_verbatim( - routes: MutableSequence[BaseRoute], *, issuer: str, resource: str -) -> None: - """Wrap, in place, every Protected Resource Metadata route in ``routes``. +def rewrite_prm_routes_verbatim(routes: Iterable[BaseRoute], *, issuer: str, resource: str) -> None: + """Wrap, in place, the Protected Resource Metadata route for ``resource``. + + Selects by route path, not by document contents: RFC 9728 §3.1 derives the + well-known path *from* the resource identifier, so the path is what says + which resource a document describes. An app serving PRM for several + resources registers one route each, and only the matching one is wrapped. + + Matching on the path rather than on the served ``resource`` value is what + keeps full normalization coverage. The served value has been through the + URL layer and can differ from the configured string by more than a trailing + slash; the path has not. - Matches routes registered under ``/.well-known/oauth-protected-resource`` - (RFC 9728 §3) and swaps their ASGI app for one that advertises ``issuer`` - and ``resource`` verbatim. + Emits a ``RuntimeWarning`` when routes exist under the well-known prefix but + none is the derivation of ``resource`` — that means the rewrite did nothing, + and a silent no-op here ships a PRM advertising identifiers the core SDK's + byte-for-byte comparison rejects. """ + target = _PRM_PATH_PREFIX + urlsplit(resource).path.rstrip("/") + seen_prefix = False + wrapped = False for route in routes: - if isinstance(route, Route) and ( - route.path == _PRM_PATH_PREFIX or route.path.startswith(_PRM_PATH_PREFIX + "/") - ): - route.app = _wrap_app(route.app, issuer=issuer, resource=resource) + if not isinstance(route, Route): + continue + if route.path == _PRM_PATH_PREFIX or route.path.startswith(_PRM_PATH_PREFIX + "/"): + seen_prefix = True + # Compare right-stripped: upstream keeps a trailing path slash when + # deriving the well-known path (its rule is "the path unless it is + # exactly /"), while `target` strips it. Everything else agrees. Left + # as an equality check, a resource configured as `/mcp/` matched no + # route — and skipping the wrap skips the *issuer* rewrite too, so + # `authorization_servers` kept the slash-normalized form the core + # SDK rejects. The prefix match this replaced covered that shape. + # + # What it trades away: an application serving `/mcp` and `/mcp/` as + # two distinct resources has both routes wrapped, which is the + # sibling clobber that route selection exists to prevent. Two + # identifiers differing only by a trailing slash collapse to one + # document under *this* SDK's derivation, and under the TS + # sibling's, which documents the same choice (`core/prm.ts`: + # "Trailing slashes on the resource path are dropped"). RFC 9728 + # §3.1 does not settle the case — it says to insert the well-known + # segment between the host and the path, and says nothing about + # normalizing a terminating slash — and upstream keeps it, deriving + # two documents. That divergence is where the trailing-slash bug + # came from, and it is why this comparison is right-stripped at + # all. Given our derivation, the pair is pathological rather than a + # case to support. + if route.path.rstrip("/") == target: + route.app = _wrap_app(route.app, issuer=issuer, resource=resource) + wrapped = True + if seen_prefix and not wrapped: + warnings.warn( + f"no Protected Resource Metadata route matches {target!r} (the RFC 9728 " + f"§3.1 derivation of {resource!r}), so the served document keeps the " + "slash-normalized identifiers the core SDK's byte-for-byte comparison " + "rejects. Routes under the well-known prefix were found, so the " + "derivation and the registered path disagree.", + RuntimeWarning, + stacklevel=2, + ) diff --git a/authplane-fastmcp/authplane_fastmcp/auth.py b/authplane-fastmcp/authplane_fastmcp/auth.py index ce5169b..fb71660 100644 --- a/authplane-fastmcp/authplane_fastmcp/auth.py +++ b/authplane-fastmcp/authplane_fastmcp/auth.py @@ -20,14 +20,14 @@ from authplane.oauth import TokenExchangeOptions, TokenResponse from fastmcp.server.auth import RemoteAuthProvider from pydantic import AnyHttpUrl -from starlette.routing import Route +from starlette.routing import BaseRoute from ._prm import rewrite_prm_routes_verbatim from .url_elicitation import to_url_elicitation_required_error from .verifier import AuthplaneTokenVerifier -class _VerbatimPRMRemoteAuthProvider(RemoteAuthProvider): +class VerbatimPRMRemoteAuthProvider(RemoteAuthProvider): """``RemoteAuthProvider`` that advertises identifiers verbatim in the PRM. Upstream builds the Protected Resource Metadata document from @@ -51,7 +51,7 @@ def __init__( self._verbatim_issuer = verbatim_issuer self._verbatim_resource = verbatim_resource - def get_routes(self, *args: Any, **kwargs: Any) -> list[Route]: + def get_routes(self, *args: Any, **kwargs: Any) -> list[BaseRoute]: # Forward whatever positional/keyword args the framework passes so a # future signature change in the base ``get_routes`` cannot TypeError # at app-build time; only the verbatim PRM rewrite below is ours. @@ -64,6 +64,18 @@ def get_routes(self, *args: Any, **kwargs: Any) -> list[Route]: return routes +def _derive_resource_url(base_url: str, mcp_path: str) -> str: + """Compose the canonical resource identifier (= JWT audience) from the mount. + + This must match exactly what ``RemoteAuthProvider`` advertises in the PRM, + which FastMCP computes as ``base_url`` joined with the transport mount path + via ``_get_resource_url()``. That agreement is pinned directly against + upstream's function by ``test_derive_resource_url_matches_fastmcp``, so it + is checked rather than only asserted here. + """ + return base_url.rstrip("/") + "/" + mcp_path.lstrip("/") + + def _wrap_client_for_elicitation(client: AuthplaneClient) -> AuthplaneClient: """Translate ``client.exchange`` consent errors into MCP ``-32042``. @@ -227,7 +239,15 @@ async def authplane_auth( mcp_path: Mount path of the MCP endpoint (default ``"/mcp"``). The JWT audience (resource) is derived as ``base_url + mcp_path``. Only set this if you changed - FastMCP's default HTTP mount path. + FastMCP's default HTTP mount path. Pass a real mount path — an + empty string is not one. Against a ``base_url`` that carries a + path, ``""`` derives an identifier FastMCP does not serve: + upstream short-circuits a falsy path and returns the base URL + untouched, while this join appends a slash. Accepted rather than + rejected, since raising would be a behaviour change to a public + factory for an input nothing passes; the divergence itself is + pinned by + ``test_derive_resource_url_diverges_from_fastmcp_on_an_empty_mount_path``. as_credentials: Client credentials for authenticating to the AS. Shared by introspection (RFC 7662) and token exchange (RFC 8693). Required when using ``IntrospectionRevocation`` for authenticated @@ -270,10 +290,7 @@ async def authplane_auth( """ resolved_scopes = scopes or [] - # Derive the canonical resource URL (= JWT audience) from base_url + mcp_path. - # This must match exactly what RemoteAuthProvider advertises in the PRM, which - # FastMCP computes as base_url + mcp_path via _get_resource_url(). - resource = base_url.rstrip("/") + "/" + mcp_path.lstrip("/") + resource = _derive_resource_url(base_url, mcp_path) # Prepare client-level kwargs, filtering out None to use SDK defaults client_kwargs_raw: dict[str, Any] = { @@ -332,11 +349,11 @@ async def authplane_auth( # upstream framework requires the URL type internally. That construction # normalizes an empty-path authority with a trailing slash, so the served # PRM would otherwise advertise ``https://auth.example.com/`` for an issuer - # configured as ``https://auth.example.com``. ``_VerbatimPRMRemoteAuthProvider`` + # configured as ``https://auth.example.com``. ``VerbatimPRMRemoteAuthProvider`` # rewrites the served ``authorization_servers`` / ``resource`` back to the # verbatim configured strings so they match the core SDK's byte-for-byte # comparison (RFC 8414 §3.3, RFC 9728 §3.3). - auth_provider = _VerbatimPRMRemoteAuthProvider( + auth_provider = VerbatimPRMRemoteAuthProvider( token_verifier=token_verifier, authorization_servers=[AnyHttpUrl(issuer)], base_url=AnyHttpUrl(base_url), diff --git a/authplane-fastmcp/authplane_fastmcp/url_elicitation.py b/authplane-fastmcp/authplane_fastmcp/url_elicitation.py index 5cad994..23e88fa 100644 --- a/authplane-fastmcp/authplane_fastmcp/url_elicitation.py +++ b/authplane-fastmcp/authplane_fastmcp/url_elicitation.py @@ -70,7 +70,12 @@ def _resolve_elicitation_id_kwarg(model: type[BaseModel]) -> str: # with no id). The ``mcp<2`` ceiling means this branch can only be reached # inside mcp 1.x, so a third spelling is an unexpected schema change: fail # loudly rather than emit a malformed elicitation. - raise ImportError( + # RuntimeError, not ImportError: this resolver runs both at import time (where + # ImportError is the right shape) and lazily from + # ``_build_url_elicitation_params``, where the installed package imported + # fine and the failure is a runtime schema mismatch. ImportError from a + # non-import call site sends the reader looking for a missing dependency. + raise RuntimeError( f"authplane-fastmcp cannot resolve the elicitation-id field on {model.__name__!r}: " "none of the known spellings (elicitationId, elicitation_id) is a declared " "field. The installed mcp is not compatible; require mcp>=1.28.1,<2." @@ -79,9 +84,17 @@ def _resolve_elicitation_id_kwarg(model: type[BaseModel]) -> str: # Fail fast at import: the installed mcp must expose a known elicitation-id # spelling. Resolution is otherwise lazy (see _build_url_elicitation_params) so -# tests can patch the model without re-triggering this. The bare call exists -# only for its import-time validation side effect; no name is bound. -_resolve_elicitation_id_kwarg(ElicitRequestURLParams) +# tests can patch the model without re-triggering this. The name below is never +# read — it is bound only so this validation runs as an import-time side effect. +# +# The resolver raises RuntimeError because it is also called lazily, where the +# package imported fine and the failure is a runtime schema mismatch. At *this* +# call site the failure really is "the installed distribution is unusable", so +# translate it to the shape a reader expects from a failing import. +try: + _ELICITATION_ID_KWARG = _resolve_elicitation_id_kwarg(ElicitRequestURLParams) +except RuntimeError as exc: # pragma: no cover - exercised via importlib.reload + raise ImportError(str(exc)) from exc def _build_url_elicitation_params( diff --git a/authplane-fastmcp/docs/user-guide.md b/authplane-fastmcp/docs/user-guide.md index 0b62c80..56c11b4 100644 --- a/authplane-fastmcp/docs/user-guide.md +++ b/authplane-fastmcp/docs/user-guide.md @@ -244,7 +244,7 @@ Trade-offs to understand before enabling `fail_closed=True`: - **Availability**: an authorization server or introspection outage makes every request fail with 401 until the outage resolves. Once the client's circuit breaker opens, checks fail fast and all tokens are rejected until the cooldown elapses. - **Credentials**: authorization servers commonly require authenticated introspection; without valid `as_credentials` the introspection call fails, which under `fail_closed=True` means every token is rejected. Verify credentials as part of deployment, not just at rollout. -- **Metadata**: an AS whose metadata document does not advertise `introspection_endpoint` fails every introspection attempt. Under the default that check is silently skipped; under `fail_closed=True` every token is rejected — and unlike an outage this never self-recovers, because the missing endpoint is a permanent property of the AS configuration. Confirm the endpoint is present in AS metadata before enabling. +- **Metadata**: an AS whose metadata document does not advertise `introspection_endpoint` fails every introspection attempt. Under the default that check is skipped and every request logs a `Revocation check failed (fail-open)` warning — for a missing endpoint that is every request, permanently, since the condition never clears; under `fail_closed=True` every token is rejected — and unlike an outage this never self-recovers, because the missing endpoint is a permanent property of the AS configuration. Confirm the endpoint is present in AS metadata before enabling. - `fail_closed` has no effect when `revocation_checker` is `None` — the flag is only consulted when a revocation check actually runs. The SDK logs a warning at resource construction when it detects this misconfiguration. ### Custom Revocation Checker @@ -529,10 +529,36 @@ Returned by `authplane_auth()`. Supports `**` unpacking into `FastMCP()` — the | Attribute | Type | Description | |-----------|------|-------------| -| `auth` | `RemoteAuthProvider` | Auth provider for FastMCP | +| `auth` | `RemoteAuthProvider` (a `VerbatimPRMRemoteAuthProvider` in practice) | Auth provider for FastMCP. `authplane_auth()` always constructs the subclass — see below — but the attribute is typed as the base class, so a checker will not offer subclass members without a narrowing check | | `token_verifier` | `AuthplaneTokenVerifier` | Token verifier (for advanced / manual setup) | | `client` | `AuthplaneClient` | Underlying SDK client (use `client.exchange()` for RFC 8693) | +### `VerbatimPRMRemoteAuthProvider` + +`RemoteAuthProvider` subclass that serves the Protected Resource Metadata identifiers +byte-for-byte. Upstream builds the PRM from `pydantic.AnyHttpUrl` fields, which append a +trailing slash to an empty-path authority; the core SDK compares identifiers verbatim, so a +client following the advertised value literally is rejected. + +`authplane_auth()` returns one already configured. Construct it directly only when you build +the provider yourself — a documented FastMCP pattern — since using the base class instead +loses the verbatim PRM silently. + +| Constructor argument | Description | +|---|---| +| `verbatim_issuer` | The issuer exactly as configured, not the `AnyHttpUrl` form | +| `verbatim_resource` | The resource identifier exactly as configured | + +Everything else is forwarded to `RemoteAuthProvider` — note `base_url` is the server base, +which is not the same value as `verbatim_resource` when the server is mounted under a path. + +### `rewrite_prm_routes_verbatim(routes, *, issuer, resource)` + +The rewrite itself, exported for the case where you cannot subclass. Apply it to the route +list your provider returns. It wraps the single route whose path is the RFC 9728 §3.1 +derivation of `resource`, and emits a `RuntimeWarning` when routes exist under the well-known +prefix but none is that derivation — meaning the rewrite did nothing. + ### `AuthplaneTokenVerifier` FastMCP `TokenVerifier` implementation. diff --git a/authplane-fastmcp/pyrightconfig.json b/authplane-fastmcp/pyrightconfig.json index 1241af7..61331e8 100644 --- a/authplane-fastmcp/pyrightconfig.json +++ b/authplane-fastmcp/pyrightconfig.json @@ -12,5 +12,8 @@ "reportUnusedVariable": "warning", "reportUnnecessaryTypeIgnoreComment": "error", "reportUnnecessaryIsInstance": "warning", - "reportUnnecessaryComparison": "warning" -} + "reportUnnecessaryComparison": "warning", + "extraPaths": [ + "tests" + ] +} \ No newline at end of file diff --git a/authplane-fastmcp/tests/conftest.py b/authplane-fastmcp/tests/conftest.py index a0922d2..60171a5 100644 --- a/authplane-fastmcp/tests/conftest.py +++ b/authplane-fastmcp/tests/conftest.py @@ -2,6 +2,7 @@ import time from collections.abc import AsyncGenerator +from typing import Protocol from unittest.mock import AsyncMock, PropertyMock import pytest @@ -13,7 +14,7 @@ from pydantic import AnyHttpUrl from authplane_fastmcp import AuthplaneTokenVerifier -from authplane_fastmcp.auth import _VerbatimPRMRemoteAuthProvider +from authplane_fastmcp.auth import VerbatimPRMRemoteAuthProvider @pytest.fixture @@ -82,6 +83,14 @@ async def verify_side_effect( def token_verifier(mock_verifier: AsyncMock) -> AuthplaneTokenVerifier: """AuthplaneTokenVerifier with mocked AuthplaneResource. + Deliberately not built on ``build_token_verifier`` below, despite the shape + overlapping. This one exists to drive ``verify()`` — it carries a side effect + distinguishing a valid token from an invalid one, and a fixed resource — while + ``build_token_verifier`` serves the PRM and derivation tests, which never call + ``verify`` and need the resource to follow their parameters. Folding them + together would mean one constructor with a `verify` argument nobody in the + second group passes. + Returns: AuthplaneTokenVerifier(mock_verifier) """ @@ -100,7 +109,7 @@ def fastmcp_app(token_verifier: AuthplaneTokenVerifier) -> FastMCP: Returns: FastMCP application instance """ - auth_provider = _VerbatimPRMRemoteAuthProvider( + auth_provider = VerbatimPRMRemoteAuthProvider( token_verifier=token_verifier, authorization_servers=[AnyHttpUrl("https://auth.example.com")], base_url=AnyHttpUrl("https://api.example.com"), @@ -146,3 +155,60 @@ async def test_client(fastmcp_app: FastMCP) -> AsyncGenerator[AsyncClient, None] base_url="http://testserver", ) as client: yield client + + +# Shared by test_integration.py and test_auth_factory.py. +# +# It lives in conftest rather than in a module of its own because this package +# runs pytest with `--import-mode=importlib`: the test directory is not put on +# `sys.path`, so `import _helpers` does not resolve, and making `tests/` a +# package to allow `from ._helpers import ...` names it `tests` — which collides +# with the repo-root `tests/` package in release.yml's combined invocation and +# takes the whole run down with "Plugin already registered under a different +# name". conftest is the one module pytest guarantees is importable from every +# test module in the tree, via the fixture below. +def build_token_verifier( + base_url: str, resource: str, *, scopes: list[str] | None = None +) -> AuthplaneTokenVerifier: + """A production ``AuthplaneTokenVerifier`` over a mocked ``AuthplaneResource``. + + One definition rather than three. ``test_integration.py`` had two + byte-identical copies of this construction and ``test_auth_factory.py`` a + third variant, which is the same "two expressions required to agree, neither + referencing the other" shape that motivated extracting + ``_derive_resource_url`` in the first place. + + The verifier itself is the production class, deliberately: it is the + argument ``auth.py``'s comment says PRM generation can read + (``token_verifier.base_url``), and upstream's ``__init__`` reads + ``required_scopes`` off it. A bare mock there would collapse the two + coercion paths production uses — a raw ``str`` ``base_url`` into the + verifier, an ``AnyHttpUrl`` into the provider — into one. + """ + resource_mock = AsyncMock(spec=AuthplaneResource) + type(resource_mock).resource = PropertyMock(return_value=resource) + if scopes is not None: + type(resource_mock).scopes = PropertyMock(return_value=scopes) + return AuthplaneTokenVerifier(resource_mock, base_url=base_url) + + +class TokenVerifierFactory(Protocol): + """The shared constructor's signature, preserved across the fixture. + + `Callable[..., AuthplaneTokenVerifier]` erases exactly the parameter checking + that importing `build_token_verifier` directly used to provide — and + `base_url` and `resource` are both `str`, so swapping them type-checks and + silently builds a verifier whose resource origin comes from the wrong string. + That is the class of mis-wiring `verifier.py`'s `isinstance` guard exists to + catch, so the indirection should not be what reintroduces it. + """ + + def __call__( + self, base_url: str, resource: str, *, scopes: list[str] | None = None + ) -> AuthplaneTokenVerifier: ... + + +@pytest.fixture +def token_verifier_factory() -> TokenVerifierFactory: + """`build_token_verifier`, for tests that cannot import across modules.""" + return build_token_verifier diff --git a/authplane-fastmcp/tests/test_auth_factory.py b/authplane-fastmcp/tests/test_auth_factory.py index 1349fa8..da161ec 100644 --- a/authplane-fastmcp/tests/test_auth_factory.py +++ b/authplane-fastmcp/tests/test_auth_factory.py @@ -2,13 +2,32 @@ from __future__ import annotations +from typing import TYPE_CHECKING from unittest.mock import AsyncMock, MagicMock, patch import pytest -from authplane import DPoPProvider, FetchSettings, IntrospectionRevocation, VerifiedClaims - -from authplane_fastmcp import authplane_auth -from authplane_fastmcp.auth import AuthplaneAuthResult +from authplane import ( + DPoPProvider, + FetchSettings, + IntrospectionRevocation, + VerifiedClaims, +) +from pydantic import AnyHttpUrl + +from authplane_fastmcp import AuthplaneTokenVerifier, authplane_auth +from authplane_fastmcp.auth import ( + AuthplaneAuthResult, + VerbatimPRMRemoteAuthProvider, + _derive_resource_url, +) + +# Below both first-party imports, so the isort group stays contiguous — ruff only +# sorts contiguous blocks, so a TYPE_CHECKING block wedged between them splits +# the group without I001 firing. Type-only: pytest runs this package with +# --import-mode=importlib and never executes it, while pyright resolves it +# through `extraPaths: ["tests"]`. +if TYPE_CHECKING: + from conftest import TokenVerifierFactory @pytest.mark.asyncio @@ -225,6 +244,203 @@ async def test_authplane_auth_resource_derivation(): ) +def _provider_for( + base_url: str, mcp_path: str, *, make_verifier: TokenVerifierFactory +) -> VerbatimPRMRemoteAuthProvider: + """A provider built as :func:`authplane_auth` builds it, for this mount. + + Every collaborator is the production one. ``token_verifier`` in particular: + a bare ``MagicMock`` would satisfy the constructor, but it is the argument + ``auth.py``'s own comment says the PRM generation can read + (``token_verifier.base_url``), and upstream's ``__init__`` already reads + ``token_verifier.required_scopes`` off it. A mock there means the object + under test is not the object production builds, and the two coercion paths + production uses — a raw ``str`` ``base_url`` into the verifier, an + ``AnyHttpUrl`` into the provider — collapse into one. + + ``verbatim_resource`` follows the parameters rather than being hardcoded. + Nothing here calls ``get_routes``, so it is inert either way, but it is the + field a reader will assume the derivation comparison involves. + """ + resource = _derive_resource_url(base_url, mcp_path) + return VerbatimPRMRemoteAuthProvider( + token_verifier=make_verifier(base_url, resource), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + base_url=AnyHttpUrl(base_url), + scopes_supported=[], + verbatim_issuer="https://auth.example.com", + verbatim_resource=resource, + ) + + +def _upstream_resource_url(provider: VerbatimPRMRemoteAuthProvider, mcp_path: str) -> str: + """Upstream's own derivation for ``mcp_path``, or a legible failure. + + Reaching for a private symbol across an unpinned ``fastmcp>=3.2,<4`` range + is deliberate — a ``skip`` here would silently unpin the claim, so its + removal has to be red. What this adds is that the redness explains itself: + without the guard the removal lands as a bare ``AttributeError`` on every + parametrization, on whatever unrelated PR happens to run next, and in + ``release.yml``'s pre-publish suite. + """ + derive = getattr(provider, "_get_resource_url", None) + if derive is None: + pytest.fail( + "fastmcp no longer exposes RemoteAuthProvider._get_resource_url; re-pin " + "_derive_resource_url against whatever now derives the advertised PRM resource" + ) + return str(derive(mcp_path)) + + +@pytest.mark.parametrize( + ("base_url", "mcp_path"), + [ + ("https://api.example.com", "/mcp"), + ("https://api.example.com/", "/mcp"), + ("https://api.example.com", "mcp"), + ("https://api.example.com", "api/v1/mcp"), + ("https://api.example.com", "/mcp/"), + ("https://api.example.com/base", "/mcp"), + ("https://api.example.com/base", "api/v1/mcp"), + # Root mount: the input neither this SDK nor the TS sibling pinned. + ("https://api.example.com", "/"), + ("https://api.example.com/", "/"), + ("https://api.example.com/base", "/"), + ], +) +def test_derive_resource_url_matches_fastmcp( + base_url: str, mcp_path: str, token_verifier_factory: TokenVerifierFactory +) -> None: + """Our derivation reproduces FastMCP's own ``_get_resource_url``. + + ``_derive_resource_url``'s docstring claims the identifier we advertise as + the JWT audience equals the one ``RemoteAuthProvider`` derives from the + mount. Nothing pinned that claim: the PRM integration tests cannot witness a + divergence, because ``rewrite_prm_routes_verbatim`` overwrites the served + ``resource`` with our string before anything reads it. This compares the two + derivations head-on, so a change on either side is what fails. + + **What the parameters cover, and what they do not.** Every input below + varies slash placement, and slash placement is the one axis on which the + two derivations are the same kind of operation. They differ in kind + elsewhere: ours concatenates strings, upstream re-parses the join through + ``AnyHttpUrl``. Each normalization that constructor applies is a divergence + class no amount of slash shuffling can reach, and they are enumerated — + with the inputs that provoke them — by + ``test_derive_resource_url_diverges_from_fastmcp_on_url_normalization``. + Read the two together: this one says where we agree, that one says where we + do not. + + Reaching for upstream's private ``_get_resource_url`` is the point: it is + the function whose output must match ours, and a rename or a rewrite there + should break this loudly instead of silently unpinning the claim. + """ + provider = _provider_for(base_url, mcp_path, make_verifier=token_verifier_factory) + assert _derive_resource_url(base_url, mcp_path) == _upstream_resource_url(provider, mcp_path) + + +@pytest.mark.parametrize( + ("base_url", "mcp_path", "ours", "upstream"), + [ + # Host case: the URL parser behind ``AnyHttpUrl`` lowercases the + # authority; string concatenation preserves whatever was configured. + ( + "https://API.example.com", + "/mcp", + "https://API.example.com/mcp", + "https://api.example.com/mcp", + ), + # An explicitly written default port is dropped for the scheme. + ( + "https://api.example.com:443", + "/mcp", + "https://api.example.com:443/mcp", + "https://api.example.com/mcp", + ), + # Dot segments are collapsed. This is the one that moves the *path*. + ( + "https://api.example.com", + "/a/./mcp", + "https://api.example.com/a/./mcp", + "https://api.example.com/a/mcp", + ), + # Characters illegal in a path are percent-encoded. + ( + "https://api.example.com", + "/mcp path", + "https://api.example.com/mcp path", + "https://api.example.com/mcp%20path", + ), + ], +) +def test_derive_resource_url_diverges_from_fastmcp_on_url_normalization( + base_url: str, + mcp_path: str, + ours: str, + upstream: str, + token_verifier_factory: TokenVerifierFactory, +) -> None: + """The divergence classes that follow from re-parsing versus concatenating. + + ``_prm.py:68-71`` already names them — "host case, an explicit default + port, a doubled slash, a dot segment" — as the reason the verbatim rewrite + cannot gate on the served value. They apply here for the same reason: only + upstream's side goes through ``AnyHttpUrl``. Pinned as divergences rather + than reconciled, on the same grounds as the empty-mount-path case below — + matching upstream would mean a second, hand-written copy of that + constructor's normalization, which is the duplication + :func:`_derive_resource_url` exists to avoid. + + The doubled slash from that list is *not* here: it is the one entry that + agrees, because ``lstrip("/")`` and upstream's own ``lstrip("/")`` remove + it on both sides before either joins. + + The rows that *move the path* have consequences beyond a mismatched audience + string: the dot segment and the percent-encoded space, by the same + mechanism. ``rewrite_prm_routes_verbatim`` selects routes by comparing + ``urlsplit(resource).path`` against ``route.path`` (``_prm.py:148``), so + when upstream registers the route under its own normalization — ``/a/mcp`` + for ``/a/./mcp``, ``/mcp%20path`` for ``/mcp path``, both asserted in the + parameters above — our target misses it and the wrap silently does not + happen. ``_prm.py:181-190`` emits a ``RuntimeWarning``, which is the only + signal. See + ``test_integration.py::test_upstream_registers_the_route_our_rewrite_targets`` + for the registered paths this is measured against. + + Host case and the explicit ``:443`` genuinely are inert: neither moves the + path, and our audience stays self-consistent between the verifier and the + served document. + """ + provider = _provider_for(base_url, mcp_path, make_verifier=token_verifier_factory) + + assert _derive_resource_url(base_url, mcp_path) == ours + assert _upstream_resource_url(provider, mcp_path) == upstream + assert ours != upstream + + +def test_derive_resource_url_diverges_from_fastmcp_on_an_empty_mount_path( + token_verifier_factory: TokenVerifierFactory, +) -> None: + """The single input where the two derivations disagree, pinned deliberately. + + FastMCP short-circuits a falsy path and returns the base URL untouched + (``if path:``); we always join. The two still agree when the authority has + no path of its own, because ``AnyHttpUrl`` normalises ``https://host`` to + ``https://host/`` — so provoking the difference needs both an empty + ``mcp_path`` and a ``base_url`` that carries a path. + + Documented rather than reconciled: ``mcp_path`` defaults to ``"/mcp"`` and + an empty string is not a mount path, while matching upstream here would mean + reimplementing ``AnyHttpUrl`` normalisation — a second copy of an expression + that agrees only by inspection, which is what this helper exists to avoid. + """ + base_url = "https://api.example.com/base" + provider = _provider_for(base_url, "", make_verifier=token_verifier_factory) + + assert _derive_resource_url(base_url, "") == "https://api.example.com/base/" + assert _upstream_resource_url(provider, "") == "https://api.example.com/base" + + @pytest.mark.asyncio async def test_authplane_auth_as_credentials_passthrough(): """as_credentials is forwarded to AuthplaneClient.create as auth.""" @@ -258,7 +474,7 @@ async def test_authplane_auth_returns_auth_result(): with ( patch("authplane_fastmcp.auth.AuthplaneClient") as mock_client_cls, - patch("authplane_fastmcp.auth._VerbatimPRMRemoteAuthProvider") as mock_auth_cls, + patch("authplane_fastmcp.auth.VerbatimPRMRemoteAuthProvider") as mock_auth_cls, ): mock_client_cls.create = AsyncMock(return_value=mock_client) result = await authplane_auth( @@ -369,7 +585,6 @@ async def test_authplane_auth_resource_matches_default_mcp_path(): @pytest.mark.asyncio async def test_verify_token_non_authplane_error_propagates(): """Unexpected exceptions from AuthplaneResource.verify() propagate (HTTP 500).""" - from authplane_fastmcp import AuthplaneTokenVerifier mock_verifier = AsyncMock() mock_verifier.resource = "https://api.example.com/mcp" @@ -378,3 +593,17 @@ async def test_verify_token_non_authplane_error_propagates(): tv = AuthplaneTokenVerifier(mock_verifier) with pytest.raises(RuntimeError, match="unexpected"): await tv.verify_token("some_token") + + +def test_public_names_are_importable_from_the_package_root() -> None: + # Both were made public in this change so a user who hand-rolls a + # RemoteAuthProvider does not silently lose the verbatim PRM. Nothing else + # imports them from the root — conftest reaches into .auth — so without this + # the __all__ entries could rot without a test noticing. + import authplane_fastmcp + from authplane_fastmcp import VerbatimPRMRemoteAuthProvider, rewrite_prm_routes_verbatim + + assert "VerbatimPRMRemoteAuthProvider" in authplane_fastmcp.__all__ + assert "rewrite_prm_routes_verbatim" in authplane_fastmcp.__all__ + assert VerbatimPRMRemoteAuthProvider is not None + assert rewrite_prm_routes_verbatim is not None diff --git a/authplane-fastmcp/tests/test_integration.py b/authplane-fastmcp/tests/test_integration.py index 2b280b6..e79dadb 100644 --- a/authplane-fastmcp/tests/test_integration.py +++ b/authplane-fastmcp/tests/test_integration.py @@ -5,8 +5,207 @@ exposes when an auth provider is configured. """ +from __future__ import annotations + +from typing import TYPE_CHECKING +from urllib.parse import urlsplit + import pytest -from httpx import AsyncClient +from fastmcp import FastMCP +from fastmcp.server.auth.auth import RemoteAuthProvider +from httpx import ASGITransport, AsyncClient +from pydantic import AnyHttpUrl +from starlette.routing import Route + +from authplane_fastmcp._prm import _PRM_PATH_PREFIX +from authplane_fastmcp.auth import VerbatimPRMRemoteAuthProvider, _derive_resource_url + +# Type-only: pytest never executes it under --import-mode=importlib, and +# pyright resolves it through `extraPaths: ["tests"]`. +if TYPE_CHECKING: + from conftest import TokenVerifierFactory + + +def _build_app( + *, + issuer: str, + base_url: str, + mount_path: str, + make_verifier: TokenVerifierFactory, +) -> tuple[FastMCP, str]: + """A FastMCP app whose PRM route comes from ``VerbatimPRMRemoteAuthProvider``. + + Returns the app and the resource identifier upstream derives for it. + + Upstream composes the resource URL as ``base_url`` joined with the transport + mount path (``RemoteAuthProvider.get_routes(mcp_path)``) and derives the + well-known route from that — so the mount path, not a hand-passed string, is + how a trailing-slash resource actually arises on this side. The shared + ``test_client`` fixture only ever exercises the default ``/mcp`` mount. + + The identifier comes from :func:`_derive_resource_url`, the same call + ``authplane_auth`` makes, rather than from a second copy of the expression: + the claim above is that this helper reproduces what production derives, so + it has to be production's derivation and not one that merely agrees on the + inputs the tests happen to use. + + Which half the tests below check: they check that *we* serve our own + identifier verbatim, not that our identifier equals upstream's. They cannot + check the latter — ``rewrite_prm_routes_verbatim`` forces the served + ``resource`` to our string, route selection compares right-stripped, and + ``follow_redirects=True`` absorbs the 307 a differing registered path would + produce, so a divergence would leave them green. That half is pinned + separately, by ``test_derive_resource_url_matches_fastmcp`` in + ``test_auth_factory.py``, which compares the two derivations directly. + """ + resource = _derive_resource_url(base_url, mount_path) + + auth_provider = VerbatimPRMRemoteAuthProvider( + token_verifier=make_verifier(base_url, resource, scopes=["tools/query"]), + authorization_servers=[AnyHttpUrl(issuer)], + base_url=AnyHttpUrl(base_url), + scopes_supported=["tools/query"], + verbatim_issuer=issuer, + verbatim_resource=resource, + ) + return FastMCP("Test Server", auth=auth_provider), resource + + +def _under_prm_prefix(path: str) -> bool: + """The prefix test `_prm.py:148` uses, not a looser `startswith`. + + `startswith(_PRM_PATH_PREFIX)` also matches + `/.well-known/oauth-protected-resource-other`, which production does not + count. Harmless in this fixture, but the docstring below says this helper + narrows "for the same reason `_prm.py:143` narrows" — so it should narrow + the same way rather than approximately. + """ + return path == _PRM_PATH_PREFIX or path.startswith(_PRM_PATH_PREFIX + "/") + + +def _registered_prm_paths(provider: RemoteAuthProvider, mount_path: str) -> list[str]: + """Right-stripped paths of the provider's routes under the PRM prefix. + + Filtered by prefix, not "every route the provider returns": comparing the + whole list against a one-element expectation made any unrelated route + upstream adds fail these tests illegibly — the failure mode + ``_upstream_resource_url``'s guard goes out of its way to avoid. + + ``isinstance(route, Route)`` for the same reason ``_prm.py:143`` narrows: + ``BaseRoute`` has no ``.path``, and a ``Host`` route would land as an + ``AttributeError`` rather than as a result. + + Right-stripping mirrors ``_prm.py:158``: upstream keeps a terminating path + slash when deriving the well-known path while our target strips it, which is + the documented, deliberate half-slash of divergence. + """ + return [ + route.path.rstrip("/") + for route in provider.get_routes(mount_path) + if isinstance(route, Route) and _under_prm_prefix(route.path) + ] + + +@pytest.mark.parametrize( + ("base_url", "mount_path"), + [ + ("https://api.example.com", "/mcp"), + ("https://api.example.com", "/mcp/"), + ("https://api.example.com", "mcp"), + ("https://api.example.com/base", "api/v1/mcp"), + ("https://api.example.com", "/"), + ], +) +def test_upstream_registers_the_route_our_rewrite_targets( + base_url: str, mount_path: str, token_verifier_factory: TokenVerifierFactory +) -> None: + """A plain upstream provider registers the PRM route ``_prm.py`` looks for. + + ``test_auth_factory.py::test_derive_resource_url_matches_fastmcp`` pins a + *helper*: it asserts our derivation equals ``_get_resource_url``'s output. + What can break a deployment is one step further out — the path upstream + actually **registers** a PRM route on, because that is what + ``rewrite_prm_routes_verbatim`` matches against (``_prm.py:148``, + ``:158``). A future fastmcp could keep ``_get_resource_url`` and stop + routing the advertised resource through it, and the helper pin would stay + green through exactly the change it exists to catch. + + So this asserts the composition end to end: the RFC 9728 §3.1 derivation of + our resource is the path on the route upstream registers for the same + mount. It survives a rename of the private helper, and it is the assumption + whose only other signal is the ``RuntimeWarning`` at ``_prm.py:181-190``. + + Deliberately a **plain** ``RemoteAuthProvider``, not + :class:`VerbatimPRMRemoteAuthProvider`: the subclass wraps the matching + route's ``app``, which is the behaviour under test here, and its + ``RuntimeWarning`` would report a mismatch that this assertion should be + the one to show. + + Both sides are right-stripped, mirroring ``_prm.py:158``: upstream keeps a + terminating path slash when deriving the well-known path while our target + strips it, which is the documented, deliberate half-slash of divergence. + """ + resource = _derive_resource_url(base_url, mount_path) + + provider = RemoteAuthProvider( + token_verifier=token_verifier_factory(base_url, resource, scopes=["tools/query"]), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + base_url=AnyHttpUrl(base_url), + scopes_supported=["tools/query"], + ) + + target = _PRM_PATH_PREFIX + urlsplit(resource).path.rstrip("/") + + assert _registered_prm_paths(provider, mount_path) == [target] + + +@pytest.mark.parametrize( + ("base_url", "mount_path"), + [ + # Upstream registers /a/mcp; our target is /a/./mcp. + ("https://api.example.com", "/a/./mcp"), + # Upstream percent-encodes; our target carries the literal space. + ("https://api.example.com", "/mcp path"), + ], +) +def test_upstream_registers_a_different_route_when_the_path_moves( + base_url: str, mount_path: str, token_verifier_factory: TokenVerifierFactory +) -> None: + """The divergences with a registration-level consequence, pinned as such. + + ``test_auth_factory.py::test_derive_resource_url_diverges_from_fastmcp`` + argues in prose that these two rows do more than mismatch an audience + string — they move ``urlsplit(resource).path``, which is what + ``rewrite_prm_routes_verbatim`` selects on (``_prm.py:148``), so the wrap + silently does not happen and a ``RuntimeWarning`` is the only signal. That + argument was unpinned: the case above is parametrized only over slash + placement, so nothing asserted the claim it makes about what upstream + registers. + + Asserting the *inequality* rather than upstream's exact normalized path is + deliberate. The consequence is "our target misses the registered route", + which is a property of the pair; pinning `/a/mcp` and `/mcp%20path` as + literals would additionally pin upstream's normalization, which is not this + SDK's to guarantee and is the thing free to change across `fastmcp>=3.2,<4`. + """ + resource = _derive_resource_url(base_url, mount_path) + + provider = RemoteAuthProvider( + token_verifier=token_verifier_factory(base_url, resource, scopes=["tools/query"]), + authorization_servers=[AnyHttpUrl("https://auth.example.com")], + base_url=AnyHttpUrl(base_url), + scopes_supported=["tools/query"], + ) + + target = _PRM_PATH_PREFIX + urlsplit(resource).path.rstrip("/") + + # `!= [target]` alone is green when the helper returns `[]` too, so it would + # survive upstream dropping the prefix entirely — asserting nothing about the + # divergence this case is named for while the sibling positive case above + # carries the redness. Pin that exactly one route was registered first. + registered = _registered_prm_paths(provider, mount_path) + assert len(registered) == 1 + assert registered != [target] @pytest.mark.asyncio @@ -59,3 +258,58 @@ async def test_prm_advertises_configured_issuer_without_trailing_slash( assert not prm["authorization_servers"][0].endswith("/") # The resource keeps its exact configured form (path preserved, no slash added). assert prm["resource"] == "https://api.example.com/mcp" + + +@pytest.mark.asyncio +async def test_prm_advertises_trailing_slash_resource_verbatim( + token_verifier_factory: TokenVerifierFactory, +) -> None: + """A resource whose path ends in a slash is advertised verbatim. + + The mirror of ``authplane-mcp``'s test of the same name, and the gap is + strictly larger on this side. ``_prm.py`` is duplicated byte-for-byte + between the two adapters, but the PRM *route* is not: this one comes from + ``fastmcp.server.auth.RemoteAuthProvider.get_routes()`` via + :class:`VerbatimPRMRemoteAuthProvider`, a different upstream project from + ``modelcontextprotocol/python-sdk``, so its registration rule can drift + independently. The hand-written-route unit test + (``test_prm.py::test_matches_the_route_upstream_registers_for_a_trailing_slash_resource``) + encodes an assumption about that rule; this one does not. + + ``follow_redirects=True`` for the reason the mcp copy documents: the request + path is fixed, so a change in upstream's registration would otherwise show + up as a 307 and a false failure on ``status_code == 200``. + + **The load-bearing assertion is the one on ``authorization_servers``, not + the one this test is named for.** ``pydantic.AnyHttpUrl`` only appends a + slash to an *empty-path* authority, so ``https://api.example.com/mcp/`` + round-trips through it unchanged: both ``resource`` assertions below hold + with the rewrite entirely absent. The issuer *is* the empty-path case + (``https://auth.example.com`` becomes ``https://auth.example.com/``), so it + is the only served field that moves when the route is not wrapped, and + therefore the only one that can witness route selection. Do not prune it as + an unrelated issuer check in a resource-named test — that guts the test + while leaving it green. + """ + mcp, resource = _build_app( + make_verifier=token_verifier_factory, + issuer="https://auth.example.com", + base_url="https://api.example.com", + mount_path="/mcp/", + ) + assert resource == "https://api.example.com/mcp/" + asgi_app = mcp.http_app(transport="streamable-http", path="/mcp/") + + async with AsyncClient( + transport=ASGITransport(app=asgi_app), + base_url="http://testserver", + follow_redirects=True, + ) as client: + response = await client.get("/.well-known/oauth-protected-resource/mcp/") + + assert response.status_code == 200 + prm = response.json() + # This is the assertion that pins route selection — see the docstring. + assert prm["authorization_servers"] == ["https://auth.example.com"] + assert prm["resource"] == "https://api.example.com/mcp/" + assert prm["resource"].endswith("/") diff --git a/authplane-fastmcp/tests/test_prm.py b/authplane-fastmcp/tests/test_prm.py index 3c7918d..0c7bf19 100644 --- a/authplane-fastmcp/tests/test_prm.py +++ b/authplane-fastmcp/tests/test_prm.py @@ -7,8 +7,13 @@ """ import json +import warnings -from authplane_fastmcp._prm import _rewrite_body +import pytest +from starlette.responses import Response +from starlette.routing import Route + +from authplane_fastmcp._prm import _rewrite_body, rewrite_prm_routes_verbatim _ISSUER = "https://auth.example.com" _RESOURCE = "https://api.example.com/mcp" @@ -57,3 +62,75 @@ def test_body_untouched_when_nothing_to_rewrite() -> None: "utf-8" ) assert _rewrite_body(original, issuer=_ISSUER, resource=_RESOURCE) == original + + +def test_swaps_the_resource_whatever_the_normalization() -> None: + # The swap is unconditional by design: route matching already decided this + # document belongs to the configured resource. Gating on the served value + # would only cover the trailing slash and skip every other normalization the + # URL layer can apply — the mismatch this module exists to fix. + for served in ( + _RESOURCE + "/", + "https://API.example.com/mcp", + "https://api.example.com:443/mcp", + "https://api.example.com/mcp/./", + ): + out = _rewrite({"resource": served}) + assert out["resource"] == _RESOURCE, served + + +def _prm_route(path: str) -> Route: + async def endpoint(request: object) -> Response: # pragma: no cover - never called + return Response(b"{}") + + return Route(path, endpoint) + + +def test_only_the_route_for_this_resource_is_wrapped() -> None: + # RFC 9728 §3.1 derives the well-known path from the identifier, so the path + # is what says which resource a document describes. An app serving PRM for + # two resources registers one route each; wrapping both would make the + # sibling advertise this resource's identifier. + mine = _prm_route("/.well-known/oauth-protected-resource/mcp") + theirs = _prm_route("/.well-known/oauth-protected-resource/other") + original_theirs = theirs.app + + rewrite_prm_routes_verbatim([mine, theirs], issuer=_ISSUER, resource=_RESOURCE) + + assert theirs.app is original_theirs + assert mine.app is not None + + +def test_warns_when_no_route_matches_the_derivation() -> None: + # A silent no-op here ships a PRM advertising identifiers the core SDK's + # byte-for-byte comparison rejects, so a derivation/registration mismatch has + # to be audible rather than skipped. + stray = _prm_route("/.well-known/oauth-protected-resource/somewhere-else") + with pytest.warns(RuntimeWarning, match="no Protected Resource Metadata route matches"): + rewrite_prm_routes_verbatim([stray], issuer=_ISSUER, resource=_RESOURCE) + + +def test_no_warning_when_there_are_no_prm_routes_at_all() -> None: + # Nothing under the prefix means there is nothing to rewrite — not a + # mismatch. Warning here would fire on every app without a PRM route. + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + rewrite_prm_routes_verbatim([_prm_route("/health")], issuer=_ISSUER, resource=_RESOURCE) + + +def test_matches_the_route_upstream_registers_for_a_trailing_slash_resource() -> None: + # Upstream derives the well-known path keeping a trailing path slash — its + # rule is "the path unless it is exactly /" — while the derivation here + # strips it. Every other shape agrees; only this one diverges. An equality + # check matched nothing for a resource configured as `/mcp/`, and skipping + # the wrap skips the issuer rewrite too, so authorization_servers kept the + # slash-normalized form the core SDK rejects. + resource = "https://api.example.com/mcp/" + route = _prm_route("/.well-known/oauth-protected-resource/mcp/") + original = route.app + + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + rewrite_prm_routes_verbatim([route], issuer=_ISSUER, resource=resource) + + assert route.app is not original diff --git a/authplane-fastmcp/tests/test_url_elicitation.py b/authplane-fastmcp/tests/test_url_elicitation.py index 23a201e..e97f2c1 100644 --- a/authplane-fastmcp/tests/test_url_elicitation.py +++ b/authplane-fastmcp/tests/test_url_elicitation.py @@ -24,6 +24,10 @@ from authplane_fastmcp.auth import ( _wrap_client_for_elicitation, # pyright: ignore[reportPrivateUsage] ) +from authplane_fastmcp.url_elicitation import ( + _resolve_elicitation_id_kwarg, # pyright: ignore[reportPrivateUsage] + to_url_elicitation_required_error, +) _OPTIONS = TokenExchangeOptions(subject_token="test") @@ -42,7 +46,7 @@ def test_returns_url_elicitation_for_consent_with_url() -> None: status_code=400, ) - mapped = url_elicitation.to_url_elicitation_required_error(error) + mapped = to_url_elicitation_required_error(error) assert isinstance(mapped, UrlElicitationRequiredError) assert mapped.error.code == URL_ELICITATION_REQUIRED @@ -57,7 +61,7 @@ def test_returns_url_elicitation_for_consent_with_url() -> None: def test_returns_none_for_non_consent_error() -> None: assert ( - url_elicitation.to_url_elicitation_required_error( + to_url_elicitation_required_error( AuthError("bad request", code="invalid_request", status_code=400) ) is None @@ -71,7 +75,7 @@ def test_returns_none_for_consent_without_url() -> None: cause_detail="missing_user_consent", consent_url=None, ) - assert url_elicitation.to_url_elicitation_required_error(error) is None + assert to_url_elicitation_required_error(error) is None def test_url_elicitation_builds_valid_params_under_installed_mcp() -> None: @@ -86,7 +90,7 @@ def test_url_elicitation_builds_valid_params_under_installed_mcp() -> None: status_code=400, ) - mapped = url_elicitation.to_url_elicitation_required_error(error) + mapped = to_url_elicitation_required_error(error) assert isinstance(mapped, UrlElicitationRequiredError) assert mapped.error.data is not None @@ -116,12 +120,7 @@ def test_schema_lookup_picks_snake_case_after_rename() -> None: # The positive schema lookup resolves the constructor kwarg from the model # itself, so a rename to ``elicitation_id`` is picked up rather than the # camelCase kwarg silently landing in ``__pydantic_extra__``. - assert ( - url_elicitation._resolve_elicitation_id_kwarg( # pyright: ignore[reportPrivateUsage] - _StubRenamedElicit - ) - == "elicitation_id" - ) + assert _resolve_elicitation_id_kwarg(_StubRenamedElicit) == "elicitation_id" class _NoElicitId(BaseModel): @@ -136,10 +135,13 @@ def test_resolver_raises_when_no_known_spelling() -> None: # With ``extra="allow"``, returning a default kwarg for a model that declares # neither spelling would land it silently in ``__pydantic_extra__`` (a -32042 # with no id). The resolver must instead raise, naming the unrecognized model. - with pytest.raises(ImportError, match="cannot resolve the elicitation-id field"): - url_elicitation._resolve_elicitation_id_kwarg( # pyright: ignore[reportPrivateUsage] - _NoElicitId - ) + # + # RuntimeError, not ImportError: called directly like this — as + # _build_url_elicitation_params does — the package imported fine and the + # failure is a runtime schema mismatch. The import-time call site translates + # it to ImportError, which the next test pins. + with pytest.raises(RuntimeError, match="cannot resolve the elicitation-id field"): + _resolve_elicitation_id_kwarg(_NoElicitId) def test_import_raises_when_model_lacks_known_spelling(monkeypatch: pytest.MonkeyPatch) -> None: @@ -181,7 +183,7 @@ def test_rename_path_still_yields_minus_32042_with_id(monkeypatch: pytest.Monkey status_code=400, ) - mapped = url_elicitation.to_url_elicitation_required_error(error) + mapped = to_url_elicitation_required_error(error) assert isinstance(mapped, UrlElicitationRequiredError) assert mapped.error.code == URL_ELICITATION_REQUIRED diff --git a/authplane-mcp/authplane_mcp/_prm.py b/authplane-mcp/authplane_mcp/_prm.py index f660784..47e9e1a 100644 --- a/authplane-mcp/authplane_mcp/_prm.py +++ b/authplane-mcp/authplane_mcp/_prm.py @@ -13,11 +13,18 @@ This module post-processes the served PRM response so the two identifier fields carry exactly the operator-configured strings, without touching any other field (scopes, bearer methods, cache headers, CORS) the upstream route emits. + +NOTE: this module is mirrored byte-for-byte in +``authplane-fastmcp/authplane_fastmcp/_prm.py``. It is the larger and subtler of +the two duplicated modules — the rewrite gating below is easy to get wrong in one +copy only. Any fix here must be applied to both. """ import json -from collections.abc import Awaitable, Callable, MutableSequence +import warnings +from collections.abc import Awaitable, Callable, Iterable from typing import Any +from urllib.parse import urlsplit from starlette.routing import BaseRoute, Route @@ -37,7 +44,8 @@ def _rewrite_body(body: bytes, *, issuer: str, resource: str) -> bytes: trailing-slash normalization: in ``authorization_servers`` the element equal to ``issuer`` or ``issuer + "/"`` is swapped for the verbatim ``issuer`` and every other entry is left in place, so a multi-AS advertisement keeps its - extra entries. ``resource`` is set verbatim. + extra entries. ``resource`` is replaced outright: the caller only routes this + function at the document belonging to the configured resource. Any body that is not a JSON object (e.g. a CORS preflight with an empty body) is returned unchanged. @@ -55,7 +63,13 @@ def _rewrite_body(body: bytes, *, issuer: str, resource: str) -> bytes: if rewritten != servers: doc["authorization_servers"] = rewritten changed = True - if "resource" in doc and doc["resource"] != resource: + # Unconditional: which document this is was already decided by route + # matching in rewrite_prm_routes_verbatim, so anything served here belongs to + # the configured resource. Gating on the value instead would only cover the + # trailing-slash normalization and silently skip every other one the URL + # layer can apply (host case, an explicit default port, a doubled slash, a + # dot segment) — which is precisely the mismatch this module exists to fix. + if doc.get("resource") != resource: doc["resource"] = resource changed = True if not changed: @@ -113,17 +127,64 @@ async def capture(message: _Message) -> None: return app -def rewrite_prm_routes_verbatim( - routes: MutableSequence[BaseRoute], *, issuer: str, resource: str -) -> None: - """Wrap, in place, every Protected Resource Metadata route in ``routes``. +def rewrite_prm_routes_verbatim(routes: Iterable[BaseRoute], *, issuer: str, resource: str) -> None: + """Wrap, in place, the Protected Resource Metadata route for ``resource``. + + Selects by route path, not by document contents: RFC 9728 §3.1 derives the + well-known path *from* the resource identifier, so the path is what says + which resource a document describes. An app serving PRM for several + resources registers one route each, and only the matching one is wrapped. + + Matching on the path rather than on the served ``resource`` value is what + keeps full normalization coverage. The served value has been through the + URL layer and can differ from the configured string by more than a trailing + slash; the path has not. - Matches routes registered under ``/.well-known/oauth-protected-resource`` - (RFC 9728 §3) and swaps their ASGI app for one that advertises ``issuer`` - and ``resource`` verbatim. + Emits a ``RuntimeWarning`` when routes exist under the well-known prefix but + none is the derivation of ``resource`` — that means the rewrite did nothing, + and a silent no-op here ships a PRM advertising identifiers the core SDK's + byte-for-byte comparison rejects. """ + target = _PRM_PATH_PREFIX + urlsplit(resource).path.rstrip("/") + seen_prefix = False + wrapped = False for route in routes: - if isinstance(route, Route) and ( - route.path == _PRM_PATH_PREFIX or route.path.startswith(_PRM_PATH_PREFIX + "/") - ): - route.app = _wrap_app(route.app, issuer=issuer, resource=resource) + if not isinstance(route, Route): + continue + if route.path == _PRM_PATH_PREFIX or route.path.startswith(_PRM_PATH_PREFIX + "/"): + seen_prefix = True + # Compare right-stripped: upstream keeps a trailing path slash when + # deriving the well-known path (its rule is "the path unless it is + # exactly /"), while `target` strips it. Everything else agrees. Left + # as an equality check, a resource configured as `/mcp/` matched no + # route — and skipping the wrap skips the *issuer* rewrite too, so + # `authorization_servers` kept the slash-normalized form the core + # SDK rejects. The prefix match this replaced covered that shape. + # + # What it trades away: an application serving `/mcp` and `/mcp/` as + # two distinct resources has both routes wrapped, which is the + # sibling clobber that route selection exists to prevent. Two + # identifiers differing only by a trailing slash collapse to one + # document under *this* SDK's derivation, and under the TS + # sibling's, which documents the same choice (`core/prm.ts`: + # "Trailing slashes on the resource path are dropped"). RFC 9728 + # §3.1 does not settle the case — it says to insert the well-known + # segment between the host and the path, and says nothing about + # normalizing a terminating slash — and upstream keeps it, deriving + # two documents. That divergence is where the trailing-slash bug + # came from, and it is why this comparison is right-stripped at + # all. Given our derivation, the pair is pathological rather than a + # case to support. + if route.path.rstrip("/") == target: + route.app = _wrap_app(route.app, issuer=issuer, resource=resource) + wrapped = True + if seen_prefix and not wrapped: + warnings.warn( + f"no Protected Resource Metadata route matches {target!r} (the RFC 9728 " + f"§3.1 derivation of {resource!r}), so the served document keeps the " + "slash-normalized identifiers the core SDK's byte-for-byte comparison " + "rejects. Routes under the well-known prefix were found, so the " + "derivation and the registered path disagree.", + RuntimeWarning, + stacklevel=2, + ) diff --git a/authplane-mcp/authplane_mcp/auth.py b/authplane-mcp/authplane_mcp/auth.py index f685757..6174ab0 100644 --- a/authplane-mcp/authplane_mcp/auth.py +++ b/authplane-mcp/authplane_mcp/auth.py @@ -84,7 +84,9 @@ def install_request_context(mcp: FastMCP) -> None: Wraps ``mcp.streamable_http_app`` so the Starlette app it returns is post-processed with two Authplane concerns before it starts serving. ``mcp.sse_app`` is wrapped with the second concern only — the SSE branch - applies just the verbatim-PRM rewrite, not the request-context middleware: + applies just the verbatim-PRM rewrite, not the request-context middleware — + and only when the attribute exists, since SSE is not on the streamable-HTTP + path and a future 1.x could drop it: 1. **Request context (DPoP).** :class:`AuthplaneRequestContextMiddleware` is installed before MCP's ``AuthenticationMiddleware`` (streamable-HTTP @@ -141,16 +143,29 @@ async def main() -> None: return # The verbatim identifiers ride on the AuthplaneTokenVerifier that - # ``authplane_mcp_auth`` stashed on the server. If a server was wired - # without the factory (no verbatim identifiers available), the PRM rewrite - # is skipped and the request-context middleware is still installed. + # ``authplane_mcp_auth`` stashed on the server. Four states are possible + # here — collapsing them onto "is the attribute None?" both misses the + # regression this warning exists to catch and fires spuriously on a server + # that simply has no auth: + # + # 1. ``_token_verifier`` missing *as an attribute* — the MCP SDK renamed + # its private attribute. The rewrite would silently no-op forever. + # 2. present but ``None`` — the server has no auth configured at all. + # Nothing to rewrite and nothing wrong; stay quiet. + # 3. present, but built without ``authplane_mcp_auth`` — a supported + # public constructor (``AuthplaneTokenVerifier(verifier)``) carries no + # verbatim identifiers. This is the case that used to pass unnoticed: + # a verifier *is* present, so no warning fired, and the served PRM kept + # advertising the slash-normalized identifiers that this SDK's own + # byte-for-byte comparison rejects. + # 4. present, non-None, but not an AuthplaneTokenVerifier — someone else's + # verifier. Nothing to rewrite and the byte-for-byte comparison is not + # in play, so stay quiet, same as (2). + has_attr = hasattr(mcp, "_token_verifier") token_verifier = getattr(mcp, "_token_verifier", None) - if token_verifier is None: - # ``_token_verifier`` is an MCP SDK private attribute. If a future SDK - # release renames it, this lookup returns None and the verbatim PRM - # rewrite would quietly no-op, reverting the served document to the - # slash-normalized identifiers that break the strict comparison. Surface - # that loudly rather than silently regressing. + verbatim: tuple[str, str] | None = None + + if not has_attr: warnings.warn( "FastMCP._token_verifier is absent; skipping the verbatim PRM " "rewrite. The served Protected Resource Metadata will advertise " @@ -160,15 +175,27 @@ async def main() -> None: RuntimeWarning, stacklevel=2, ) - verbatim_issuer = getattr(token_verifier, "_verbatim_issuer", None) - verbatim_resource = getattr(token_verifier, "_verbatim_resource", None) + elif isinstance(token_verifier, AuthplaneTokenVerifier): + verbatim = token_verifier.verbatim_identifiers() + if verbatim is None: + warnings.warn( + "AuthplaneTokenVerifier carries no verbatim issuer/resource; " + "skipping the verbatim PRM rewrite. The served Protected " + "Resource Metadata will advertise slash-normalized identifiers, " + "which the core SDK's byte-for-byte comparison rejects. Build " + "the server with authplane_mcp_auth(...) so the operator's " + "identifiers reach the served document.", + RuntimeWarning, + stacklevel=2, + ) def rewrite_prm(app: Starlette) -> None: - if verbatim_issuer is not None and verbatim_resource is not None: + if verbatim is not None: + issuer, resource = verbatim rewrite_prm_routes_verbatim( app.router.routes, - issuer=verbatim_issuer, - resource=verbatim_resource, + issuer=issuer, + resource=resource, ) original_streamable_http_app = mcp.streamable_http_app @@ -183,15 +210,12 @@ def streamable_http_app() -> Starlette: rewrite_prm(app) return app - original_sse_app = mcp.sse_app - - def sse_app(*args: Any, **kwargs: Any) -> Starlette: - # Forward whatever positional/keyword args the SDK passes so a future - # signature change in ``sse_app`` cannot TypeError at app-build time; - # only the verbatim PRM rewrite below is ours. - app = original_sse_app(*args, **kwargs) - rewrite_prm(app) - return app + # Guarded like the ``_token_verifier`` lookup above rather than accessed + # directly: ``sse_app`` is not part of the streamable-HTTP path, so a future + # 1.x that drops it would otherwise take down servers that never touch SSE + # at import of this helper. Everything else in this function is defensive; + # this was the one bare attribute access. + original_sse_app = getattr(mcp, "sse_app", None) # Fragility: instance-attribute assignment works only because FastMCP # exposes ``streamable_http_app`` / ``sse_app`` as plain methods, not @@ -201,7 +225,23 @@ def sse_app(*args: Any, **kwargs: Any) -> Starlette: # Track https://github.com/modelcontextprotocol/python-sdk for a public # subclassing hook or per-app middleware API and migrate to it when available. mcp.streamable_http_app = streamable_http_app - mcp.sse_app = sse_app + + # Defined inside the guard rather than above it with an ``assert``: the + # neighbouring module states the convention ("guard explicitly rather than + # asserting, since ``assert`` is stripped under ``python -O``"), and closing + # over a name the type checker already knows is non-None needs neither. + if original_sse_app is not None: + + def sse_app(*args: Any, **kwargs: Any) -> Starlette: + # Forward whatever positional/keyword args the SDK passes so a + # future signature change in ``sse_app`` cannot TypeError at + # app-build time; only the verbatim PRM rewrite below is ours. + app = original_sse_app(*args, **kwargs) + rewrite_prm(app) + return app + + mcp.sse_app = sse_app + setattr(mcp, _INSTALLED_FLAG, True) diff --git a/authplane-mcp/authplane_mcp/url_elicitation.py b/authplane-mcp/authplane_mcp/url_elicitation.py index ab6e8d9..bfebbfe 100644 --- a/authplane-mcp/authplane_mcp/url_elicitation.py +++ b/authplane-mcp/authplane_mcp/url_elicitation.py @@ -70,7 +70,12 @@ def _resolve_elicitation_id_kwarg(model: type[BaseModel]) -> str: # with no id). The ``mcp<2`` ceiling means this branch can only be reached # inside mcp 1.x, so a third spelling is an unexpected schema change: fail # loudly rather than emit a malformed elicitation. - raise ImportError( + # RuntimeError, not ImportError: this resolver runs both at import time (where + # ImportError is the right shape) and lazily from + # ``_build_url_elicitation_params``, where the installed package imported + # fine and the failure is a runtime schema mismatch. ImportError from a + # non-import call site sends the reader looking for a missing dependency. + raise RuntimeError( f"authplane-mcp cannot resolve the elicitation-id field on {model.__name__!r}: " "none of the known spellings (elicitationId, elicitation_id) is a declared " "field. The installed mcp is not compatible; require mcp>=1.28.1,<2." @@ -79,9 +84,17 @@ def _resolve_elicitation_id_kwarg(model: type[BaseModel]) -> str: # Fail fast at import: the installed mcp must expose a known elicitation-id # spelling. Resolution is otherwise lazy (see _build_url_elicitation_params) so -# tests can patch the model without re-triggering this. The bare call exists -# only for its import-time validation side effect; no name is bound. -_resolve_elicitation_id_kwarg(ElicitRequestURLParams) +# tests can patch the model without re-triggering this. The name below is never +# read — it is bound only so this validation runs as an import-time side effect. +# +# The resolver raises RuntimeError because it is also called lazily, where the +# package imported fine and the failure is a runtime schema mismatch. At *this* +# call site the failure really is "the installed distribution is unusable", so +# translate it to the shape a reader expects from a failing import. +try: + _ELICITATION_ID_KWARG = _resolve_elicitation_id_kwarg(ElicitRequestURLParams) +except RuntimeError as exc: # pragma: no cover - exercised via importlib.reload + raise ImportError(str(exc)) from exc def _build_url_elicitation_params( diff --git a/authplane-mcp/authplane_mcp/verifier.py b/authplane-mcp/authplane_mcp/verifier.py index be2fd27..5d27946 100644 --- a/authplane-mcp/authplane_mcp/verifier.py +++ b/authplane-mcp/authplane_mcp/verifier.py @@ -77,8 +77,7 @@ class AuthplaneTokenVerifier(TokenVerifier): call's in-flight verify task is stashed on ``request.state`` keyed by the access token; any subsequent invocation within the same request awaits the same task instead of re-entering the inbound DPoP replay - store. The cache is defensive: it mirrors the TS adapter's - ``AsyncLocalStorage`` pattern and pre-empts a class of regressions + store. The cache is defensive: it pre-empts a class of regressions where a future framework change (transport rewrite, custom auth provider, ASGI wrapper) would silently double-call ``verify_token`` and the second call's proof would be rejected as @@ -136,6 +135,25 @@ def verifier(self) -> AuthplaneResource: """The underlying ``AuthplaneResource`` instance.""" return self._verifier + def verbatim_identifiers(self) -> tuple[str, str] | None: + """Return ``(issuer, resource)`` as configured, or ``None``. + + The PRM rewrite in :mod:`authplane_mcp.auth` needs the operator's + identifiers byte-for-byte, not the slash-normalized forms upstream's + ``RemoteAuthProvider`` derives. Both are set only by + ``authplane_mcp_auth``; a verifier built through the public + ``AuthplaneTokenVerifier(...)`` constructor carries neither, and the + rewrite must be skipped rather than half-applied. + + Returning ``None`` for that case — instead of exposing two private + attributes across module boundaries — keeps the "are these usable?" + question answerable in one call, which is what the caller actually + branches on. + """ + if self._verbatim_issuer is None or self._verbatim_resource is None: + return None + return self._verbatim_issuer, self._verbatim_resource + async def verify_token(self, token: str) -> AccessToken | None: """Validate a JWT and return an MCP ``AccessToken``. @@ -220,18 +238,16 @@ def _build_dpop_request_context(self, request: Request) -> DPoPRequestContext: not configured for inbound DPoP, the verifier's Mode-3 path rejects any DPoP signal regardless of what is passed here. - Cross-SDK note: the TS sibling ``buildDpopRequestContext`` - returns ``undefined`` when no ``DPoP`` header is present; - Python intentionally always builds the context with - ``proof=None``. Both shapes are behaviorally equivalent in - the core verifier (Mode 3 path treats absent and ``None`` - proofs the same), but a DPoP-bound token with no proof - yields a more specific ``DPoPProofMissingError`` here - instead of ``DPoPBindingMismatchError``. The error-type - contract is pinned per language by design. + Note: the context is always built, with ``proof=None`` when no + ``DPoP`` header is present, rather than omitted. Both shapes are + behaviorally equivalent in the core verifier (the Mode 3 path + treats absent and ``None`` proofs the same), but building it + unconditionally means a DPoP-bound token with no proof yields the + more specific ``DPoPProofMissingError`` instead of + ``DPoPBindingMismatchError``. """ - # ``raw_request_path`` reads ``scope["raw_path"]`` to preserve - # percent-encoding for DPoP ``htu`` parity with the TS sibling. + # ``raw_request_path`` reads ``scope["raw_path"]`` so percent-encoding + # is preserved in the DPoP ``htu`` (RFC 9449 §4.3, RFC 3986 §6.2.2.2). # ``request.url.query`` is sourced from ``scope["query_string"]`` # without percent-decoding, so it is already on-wire-safe. url = f"{self._resource_origin}{raw_request_path(request)}" diff --git a/authplane-mcp/docs/user-guide.md b/authplane-mcp/docs/user-guide.md index 226c402..5e9c19b 100644 --- a/authplane-mcp/docs/user-guide.md +++ b/authplane-mcp/docs/user-guide.md @@ -236,7 +236,7 @@ Trade-offs to understand before enabling `fail_closed=True`: - **Availability**: an authorization server or introspection outage makes every request fail with 401 until the outage resolves. Once the client's circuit breaker opens, checks fail fast and all tokens are rejected until the cooldown elapses. - **Credentials**: authorization servers commonly require authenticated introspection; without valid `as_credentials` the introspection call fails, which under `fail_closed=True` means every token is rejected. Verify credentials as part of deployment, not just at rollout. -- **Metadata**: an AS whose metadata document does not advertise `introspection_endpoint` fails every introspection attempt. Under the default that check is silently skipped; under `fail_closed=True` every token is rejected — and unlike an outage this never self-recovers, because the missing endpoint is a permanent property of the AS configuration. Confirm the endpoint is present in AS metadata before enabling. +- **Metadata**: an AS whose metadata document does not advertise `introspection_endpoint` fails every introspection attempt. Under the default that check is skipped and every request logs a `Revocation check failed (fail-open)` warning — for a missing endpoint that is every request, permanently, since the condition never clears; under `fail_closed=True` every token is rejected — and unlike an outage this never self-recovers, because the missing endpoint is a permanent property of the AS configuration. Confirm the endpoint is present in AS metadata before enabling. - `fail_closed` has no effect when `revocation_checker` is `None` — the flag is only consulted when a revocation check actually runs. The SDK logs a warning at resource construction when it detects this misconfiguration. ### Custom Revocation Checker diff --git a/authplane-mcp/pyproject.toml b/authplane-mcp/pyproject.toml index 27ea7b3..51b0e35 100644 --- a/authplane-mcp/pyproject.toml +++ b/authplane-mcp/pyproject.toml @@ -29,7 +29,11 @@ dependencies = [ # Floor is 1.28.1: mcp <=1.28.0 carries PYSEC-2026-3483, fixed in 1.28.1. # This adapter targets the mcp 1.x server API (`mcp.server.fastmcp.FastMCP`) # and the camelCase URL-elicitation field (`ElicitRequestURLParams( - # elicitationId=...)`), both of which hold through the 1.x line. The upper + # elicitationId=...)`), both of which hold through the 1.x line. (An earlier + # ceiling here was justified by "1.28 renamed it to snake_case + # `elicitation_id`" — that was wrong: the rename lands in 2.0, not 1.28. + # Noted so the next reader does not re-derive the same doubt. The dynamic + # resolver in url_elicitation.py makes the spelling moot either way.) The upper # bound excludes mcp 2.0, which removes `mcp.server.fastmcp` and renames the # field to snake_case `elicitation_id`; supporting it is a separate port. "mcp>=1.28.1,<2", diff --git a/authplane-mcp/tests/test_integration.py b/authplane-mcp/tests/test_integration.py index c05ce8f..7980cfb 100644 --- a/authplane-mcp/tests/test_integration.py +++ b/authplane-mcp/tests/test_integration.py @@ -66,6 +66,111 @@ async def test_prm_advertises_issuer_verbatim() -> None: assert prm["resource"] == "https://api.example.com/mcp" +@pytest.mark.asyncio +async def test_sse_app_serves_the_verbatim_prm_too() -> None: + """The SSE branch of ``install_request_context``, pinned by behaviour. + + ``test_request_context.py::test_install_wraps_sse_app_when_present`` pins + that the wrapper is *installed*, and is explicit that this is all it pins: + its ``FastMCP("test")`` has ``_token_verifier = None``, so the rewrite + inside the wrapper is a no-op there and deleting the ``rewrite_prm(app)`` + call from the body keeps it green. This covers what the wrapper *does*. + + Built through ``sse_app()`` rather than ``streamable_http_app()``, with the + empty-path authority — the shape where ``pydantic.AnyHttpUrl`` appends a + trailing slash — so the assertion fails unless the rewrite actually ran on + this path. + """ + mcp = _build_app( + issuer="https://auth.example.com", + resource="https://api.example.com", + ) + # Guarded the way `auth.py:218` guards it. That call site reaches `sse_app` + # through `getattr(..., None)` precisely because a future `mcp` 1.x may drop + # it — SSE is not on the streamable-HTTP path. Calling it bare here means + # that removal lands as an `AttributeError` on an unrelated PR instead of a + # sentence saying what needs re-pinning. + # + # `skip` here where `_upstream_resource_url` uses `fail` for a structurally + # similar upstream-symbol disappearance, because the two resolve differently: + # production *depends* on `_get_resource_url`, so its removal has to be red, + # while production *guards* `sse_app` and degrades — a run without it is the + # documented outcome, not a broken claim. + sse_app = getattr(mcp, "sse_app", None) + if sse_app is None: + pytest.skip( + "mcp no longer exposes sse_app; the SSE wrapping in auth.py and this " + "case both need re-pinning against the current transport surface" + ) + asgi_app = sse_app() + + async with AsyncClient( + transport=ASGITransport(app=asgi_app), + base_url="http://testserver", + ) as client: + response = await client.get("/.well-known/oauth-protected-resource") + + assert response.status_code == 200 + prm = response.json() + assert prm["authorization_servers"] == ["https://auth.example.com"] + assert prm["resource"] == "https://api.example.com" + + +@pytest.mark.asyncio +async def test_prm_advertises_trailing_slash_resource_verbatim() -> None: + """A resource whose path ends in a slash is advertised verbatim. + + This is the shape the route-selection fix was written for, and until now it + was covered only by a unit test that *hand-wrote* the registered route path + (``/.well-known/oauth-protected-resource/mcp/``) — encoding the very + assumption about upstream's registration that produced the bug. If upstream + changed that rule the unit test would stay green while the served document + went back to advertising the normalized identifier. + + Going through ``streamable_http_app()`` pins it against the real + registration instead: whatever path upstream registers, the rewrite has to + find it and the document has to come back with the configured string. + + ``follow_redirects=True`` is what makes the assertion match that claim. The + request path here is fixed, so if upstream ever registered ``/mcp`` without + the terminating slash, Starlette's ``redirect_slashes`` would answer 307 and + the default client — which does not follow redirects — would fail on + ``status_code == 200`` with the rewrite working correctly. That is a false + failure attributed to our code, which is exactly what this test exists to + rule out. + + **The load-bearing assertion is the one on ``authorization_servers``, not + the one this test is named for.** ``pydantic.AnyHttpUrl`` only appends a + slash to an *empty-path* authority, so ``https://api.example.com/mcp/`` + round-trips through it unchanged: both ``resource`` assertions below hold + with the rewrite entirely absent. The issuer *is* the empty-path case + (``https://auth.example.com`` becomes ``https://auth.example.com/``), so it + is the only served field that moves when the route is not wrapped, and + therefore the only one that can witness route selection. Do not prune it as + an unrelated issuer check in a resource-named test — that guts the test + while leaving it green. + """ + mcp = _build_app( + issuer="https://auth.example.com", + resource="https://api.example.com/mcp/", + ) + asgi_app = mcp.streamable_http_app() + + async with AsyncClient( + transport=ASGITransport(app=asgi_app), + base_url="http://testserver", + follow_redirects=True, + ) as client: + response = await client.get("/.well-known/oauth-protected-resource/mcp/") + + assert response.status_code == 200 + prm = response.json() + # This is the assertion that pins route selection — see the docstring. + assert prm["authorization_servers"] == ["https://auth.example.com"] + assert prm["resource"] == "https://api.example.com/mcp/" + assert prm["resource"].endswith("/") + + @pytest.mark.asyncio async def test_prm_advertises_root_resource_verbatim() -> None: """A resource configured with no trailing slash is advertised verbatim. diff --git a/authplane-mcp/tests/test_prm.py b/authplane-mcp/tests/test_prm.py index 1b8adcf..fccace8 100644 --- a/authplane-mcp/tests/test_prm.py +++ b/authplane-mcp/tests/test_prm.py @@ -7,8 +7,13 @@ """ import json +import warnings -from authplane_mcp._prm import _rewrite_body +import pytest +from starlette.responses import Response +from starlette.routing import Route + +from authplane_mcp._prm import _rewrite_body, rewrite_prm_routes_verbatim _ISSUER = "https://auth.example.com" _RESOURCE = "https://api.example.com/mcp" @@ -57,3 +62,75 @@ def test_body_untouched_when_nothing_to_rewrite() -> None: "utf-8" ) assert _rewrite_body(original, issuer=_ISSUER, resource=_RESOURCE) == original + + +def test_swaps_the_resource_whatever_the_normalization() -> None: + # The swap is unconditional by design: route matching already decided this + # document belongs to the configured resource. Gating on the served value + # would only cover the trailing slash and skip every other normalization the + # URL layer can apply — the mismatch this module exists to fix. + for served in ( + _RESOURCE + "/", + "https://API.example.com/mcp", + "https://api.example.com:443/mcp", + "https://api.example.com/mcp/./", + ): + out = _rewrite({"resource": served}) + assert out["resource"] == _RESOURCE, served + + +def _prm_route(path: str) -> Route: + async def endpoint(request: object) -> Response: # pragma: no cover - never called + return Response(b"{}") + + return Route(path, endpoint) + + +def test_only_the_route_for_this_resource_is_wrapped() -> None: + # RFC 9728 §3.1 derives the well-known path from the identifier, so the path + # is what says which resource a document describes. An app serving PRM for + # two resources registers one route each; wrapping both would make the + # sibling advertise this resource's identifier. + mine = _prm_route("/.well-known/oauth-protected-resource/mcp") + theirs = _prm_route("/.well-known/oauth-protected-resource/other") + original_theirs = theirs.app + + rewrite_prm_routes_verbatim([mine, theirs], issuer=_ISSUER, resource=_RESOURCE) + + assert theirs.app is original_theirs + assert mine.app is not None + + +def test_warns_when_no_route_matches_the_derivation() -> None: + # A silent no-op here ships a PRM advertising identifiers the core SDK's + # byte-for-byte comparison rejects, so a derivation/registration mismatch has + # to be audible rather than skipped. + stray = _prm_route("/.well-known/oauth-protected-resource/somewhere-else") + with pytest.warns(RuntimeWarning, match="no Protected Resource Metadata route matches"): + rewrite_prm_routes_verbatim([stray], issuer=_ISSUER, resource=_RESOURCE) + + +def test_no_warning_when_there_are_no_prm_routes_at_all() -> None: + # Nothing under the prefix means there is nothing to rewrite — not a + # mismatch. Warning here would fire on every app without a PRM route. + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + rewrite_prm_routes_verbatim([_prm_route("/health")], issuer=_ISSUER, resource=_RESOURCE) + + +def test_matches_the_route_upstream_registers_for_a_trailing_slash_resource() -> None: + # Upstream derives the well-known path keeping a trailing path slash — its + # rule is "the path unless it is exactly /" — while the derivation here + # strips it. Every other shape agrees; only this one diverges. An equality + # check matched nothing for a resource configured as `/mcp/`, and skipping + # the wrap skips the issuer rewrite too, so authorization_servers kept the + # slash-normalized form the core SDK rejects. + resource = "https://api.example.com/mcp/" + route = _prm_route("/.well-known/oauth-protected-resource/mcp/") + original = route.app + + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + rewrite_prm_routes_verbatim([route], issuer=_ISSUER, resource=resource) + + assert route.app is not original diff --git a/authplane-mcp/tests/test_request_context.py b/authplane-mcp/tests/test_request_context.py index bc8b0e1..d7bae45 100644 --- a/authplane-mcp/tests/test_request_context.py +++ b/authplane-mcp/tests/test_request_context.py @@ -10,7 +10,9 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Any +import warnings +from types import SimpleNamespace +from typing import TYPE_CHECKING, Any, cast import pytest from mcp.server.fastmcp import FastMCP @@ -26,6 +28,7 @@ from authplane_mcp._request_context import ( _current_request, # pyright: ignore[reportPrivateUsage] ) +from authplane_mcp.verifier import AuthplaneTokenVerifier def test_get_current_request_outside_scope_raises() -> None: @@ -199,3 +202,114 @@ def test_install_request_context_is_idempotent() -> None: app = mcp.streamable_http_app() middleware_classes = [m.cls for m in app.user_middleware] assert middleware_classes.count(AuthplaneRequestContextMiddleware) == 1 + + +# --------------------------------------------------------------------------- +# install_request_context — verbatim-PRM detection (three distinct states) +# --------------------------------------------------------------------------- + + +def _stub_verifier(**kwargs: Any) -> AuthplaneTokenVerifier: + resource = SimpleNamespace(resource="https://api.example.com/mcp") + return AuthplaneTokenVerifier(cast("Any", resource), **kwargs) + + +def test_no_warning_when_server_has_no_auth() -> None: + """A FastMCP with no auth configured has nothing to rewrite. + + ``_token_verifier`` exists on the instance and is ``None``. The old code + branched on ``is None`` alone and warned here, telling the operator the MCP + SDK had renamed a private attribute — which is not what happened. + """ + mcp: FastMCP[Any] = FastMCP("test") + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + install_request_context(mcp) + + +def test_warns_when_verifier_carries_no_verbatim_identifiers() -> None: + """The regression the warning exists to catch, on the path it used to miss. + + ``AuthplaneTokenVerifier(verifier)`` is a supported public constructor and + leaves both verbatim identifiers unset. The verifier *is* present, so the + old ``is None`` check stayed quiet while the served PRM kept advertising the + slash-normalized identifiers this SDK's own comparison rejects. + """ + mcp: FastMCP[Any] = FastMCP("test") + mcp._token_verifier = _stub_verifier() # type: ignore[attr-defined] + with pytest.warns(RuntimeWarning, match="no verbatim issuer/resource"): + install_request_context(mcp) + + +def test_no_warning_when_verbatim_identifiers_are_present() -> None: + mcp: FastMCP[Any] = FastMCP("test") + mcp._token_verifier = _stub_verifier( # type: ignore[attr-defined] + verbatim_issuer="https://auth.example.com", + verbatim_resource="https://api.example.com/mcp", + ) + with warnings.catch_warnings(): + warnings.simplefilter("error", RuntimeWarning) + install_request_context(mcp) + + +def test_warns_about_a_renamed_sdk_attribute_only_when_absent() -> None: + """The 'SDK renamed the attribute' wording is reserved for that case.""" + mcp: FastMCP[Any] = FastMCP("test") + # ``_token_verifier`` is set on the instance by FastMCP.__init__; deleting it + # is the closest stand-in for an upstream rename, which is the only thing + # that should produce this wording. + del mcp._token_verifier # type: ignore[attr-defined] + assert not hasattr(mcp, "_token_verifier") + with pytest.warns(RuntimeWarning, match="renamed the private attribute"): + install_request_context(mcp) + + +def test_verbatim_identifiers_accessor() -> None: + assert _stub_verifier().verbatim_identifiers() is None + assert _stub_verifier(verbatim_issuer="https://a").verbatim_identifiers() is None + assert _stub_verifier( + verbatim_issuer="https://a", verbatim_resource="https://b" + ).verbatim_identifiers() == ("https://a", "https://b") + + +def test_install_tolerates_a_server_without_sse_app() -> None: + """A future 1.x that drops ``sse_app`` must not break streamable-HTTP servers. + + Everything else in ``install_request_context`` is defensive (``getattr`` for + ``_token_verifier``, ``*args``/``**kwargs`` forwarding); the ``sse_app`` + lookup was the one bare attribute access, and SSE is not on the + streamable-HTTP path at all. + """ + mcp: FastMCP[Any] = FastMCP("test") + # Save and restore rather than reload: `from ... import FastMCP` bound this + # module's name to the original class object, so importlib.reload would build + # a *new* class and leave this one permanently mutated for later tests. + original = FastMCP.sse_app + del FastMCP.sse_app # type: ignore[attr-defined] + try: + install_request_context(mcp) + app = mcp.streamable_http_app() + assert app.user_middleware[0].cls is AuthplaneRequestContextMiddleware + finally: + FastMCP.sse_app = original # type: ignore[attr-defined] + + +def test_install_wraps_sse_app_when_present() -> None: + """The present branch had no coverage at all. + + Only the absent-`sse_app` case was pinned, so `mcp.sse_app = sse_app` could + have been deleted outright and every suite stayed green — on a line that had + just been moved inside a conditional. + + Asserted on the instance dict, not by comparing the attribute to a value + captured earlier: attribute access on a method builds a fresh bound object + each time, so an identity check passes whether or not the assignment + happened. The instance dict gains the key only when it does. + """ + mcp: FastMCP[Any] = FastMCP("test") + assert "sse_app" not in vars(mcp) + + install_request_context(mcp) + + assert "sse_app" in vars(mcp) + assert vars(mcp)["sse_app"].__name__ == "sse_app" diff --git a/authplane-mcp/tests/test_url_elicitation.py b/authplane-mcp/tests/test_url_elicitation.py index 7cad5eb..7a76dc6 100644 --- a/authplane-mcp/tests/test_url_elicitation.py +++ b/authplane-mcp/tests/test_url_elicitation.py @@ -22,6 +22,10 @@ import authplane_mcp.url_elicitation as url_elicitation from authplane_mcp.auth import _wrap_client_for_elicitation # pyright: ignore[reportPrivateUsage] +from authplane_mcp.url_elicitation import ( + _resolve_elicitation_id_kwarg, # pyright: ignore[reportPrivateUsage] + to_url_elicitation_required_error, +) _OPTIONS = TokenExchangeOptions(subject_token="test") @@ -56,7 +60,7 @@ def test_returns_url_elicitation_for_consent_with_url() -> None: status_code=400, ) - mapped = url_elicitation.to_url_elicitation_required_error(error) + mapped = to_url_elicitation_required_error(error) assert isinstance(mapped, UrlElicitationRequiredError) assert mapped.error.code == URL_ELICITATION_REQUIRED @@ -79,7 +83,7 @@ def test_returns_url_elicitation_for_consent_with_url() -> None: def test_returns_none_for_non_consent_error() -> None: assert ( - url_elicitation.to_url_elicitation_required_error( + to_url_elicitation_required_error( AuthError("bad request", code="invalid_request", status_code=400) ) is None @@ -93,7 +97,7 @@ def test_returns_none_for_consent_without_url() -> None: cause_detail="missing_user_consent", consent_url=None, ) - assert url_elicitation.to_url_elicitation_required_error(error) is None + assert to_url_elicitation_required_error(error) is None # --------------------------------------------------------------------------- @@ -115,12 +119,7 @@ def test_schema_lookup_picks_snake_case_after_rename() -> None: # The positive schema lookup resolves the constructor kwarg from the model # itself, so a rename to ``elicitation_id`` is picked up rather than the # camelCase kwarg silently landing in ``__pydantic_extra__``. - assert ( - url_elicitation._resolve_elicitation_id_kwarg( # pyright: ignore[reportPrivateUsage] - _StubRenamedElicit - ) - == "elicitation_id" - ) + assert _resolve_elicitation_id_kwarg(_StubRenamedElicit) == "elicitation_id" class _NoElicitId(BaseModel): @@ -135,10 +134,13 @@ def test_resolver_raises_when_no_known_spelling() -> None: # With ``extra="allow"``, returning a default kwarg for a model that declares # neither spelling would land it silently in ``__pydantic_extra__`` (a -32042 # with no id). The resolver must instead raise, naming the unrecognized model. - with pytest.raises(ImportError, match="cannot resolve the elicitation-id field"): - url_elicitation._resolve_elicitation_id_kwarg( # pyright: ignore[reportPrivateUsage] - _NoElicitId - ) + # + # RuntimeError, not ImportError: called directly like this — as + # _build_url_elicitation_params does — the package imported fine and the + # failure is a runtime schema mismatch. The import-time call site translates + # it to ImportError, which the next test pins. + with pytest.raises(RuntimeError, match="cannot resolve the elicitation-id field"): + _resolve_elicitation_id_kwarg(_NoElicitId) def test_import_raises_when_model_lacks_known_spelling(monkeypatch: pytest.MonkeyPatch) -> None: @@ -180,7 +182,7 @@ def test_rename_path_still_yields_minus_32042_with_id(monkeypatch: pytest.Monkey status_code=400, ) - mapped = url_elicitation.to_url_elicitation_required_error(error) + mapped = to_url_elicitation_required_error(error) assert isinstance(mapped, UrlElicitationRequiredError) assert mapped.error.code == URL_ELICITATION_REQUIRED diff --git a/authplane/docs/user-guide.md b/authplane/docs/user-guide.md index 067b2d7..0dcd653 100644 --- a/authplane/docs/user-guide.md +++ b/authplane/docs/user-guide.md @@ -245,10 +245,11 @@ This uses the RFC 7662 introspection endpoint after local JWT verification. Important behavior: -- by default it is **fail-open**: if introspection fails, the token is accepted +- by default it is **fail-open**: if introspection fails, the token is accepted and a warning is logged on every `verify()` - set `fail_closed=True` to reject tokens when the revocation check fails - the client must have AS credentials configured - the AS metadata must expose `introspection_endpoint` +- `fail_closed` has no effect when `revocation_checker` is `None` — there is no check to fail. The SDK logs a warning at resource construction if you set one without the other, so the no-op configuration is visible rather than silent. ```python # Fail-closed: reject tokens when introspection is unavailable @@ -557,6 +558,13 @@ Common meanings: - `MetadataFetchError`: AS metadata unavailable or invalid - `JWKSFetchError`: JWKS unavailable - `MissingMetadataEndpointError`: required discovered endpoint missing +- `InvalidIssuerError`: the configured issuer carries a query or fragment component (RFC 8414 §2). Raised from `AuthplaneClient.create()`, at construction, before any network fetch. Subclasses `ValueError` as well as `AuthplaneError`, so an existing `except ValueError` still catches it +- `InvalidResourceError`: the configured resource indicator carries a fragment component (RFC 8707 §2). Raised at construction, from three call sites, of which one is authoritative: + - `AuthplaneResource.__init__` — the authoritative gate. Every construction path reaches it, including direct construction of the package-root export, so `AuthplaneResource(...)` built by hand raises here too. + - `AuthplaneClient.resource()` — redundant for the guarantee, kept for the traceback: it raises at the line the operator wrote rather than one frame deeper in the constructor. + - `build_prm_url()` — a defensive backstop only. Its production caller is `AuthplaneResource.prm_url()`, which operators invoke inside a 401 response path, so validating *only* there turned a configuration error into a 500 on the failure path. + + Subclasses `ValueError` as well as `AuthplaneError`, on the same terms as `InvalidIssuerError` - `ProtocolError`: malformed successful OAuth response - `VerifierRuntimeError`: unexpected verifier or DPoP validation runtime failure - `InsufficientScopeError`: authorization failure, typically HTTP 403 diff --git a/authplane/dpop.py b/authplane/dpop.py index ee2b58f..2f342e3 100644 --- a/authplane/dpop.py +++ b/authplane/dpop.py @@ -11,7 +11,7 @@ from dataclasses import dataclass, field from types import MappingProxyType from typing import Any, Protocol, cast -from urllib.parse import urlparse, urlunparse +from urllib.parse import SplitResult, urlsplit, urlunsplit from authlib.jose import JsonWebKey, jwt @@ -19,6 +19,7 @@ InvalidDPoPProofError, ) from .internal.jwt import decode_jwt_header +from .internal.urls import host_literal SUPPORTED_DPOP_ALGORITHMS = ("ES256", "RS256") @@ -34,23 +35,57 @@ def _decode_jwt_header(token: str) -> dict[str, Any]: # pyright: ignore[reportU raise InvalidDPoPProofError(f"DPoP proof header must be a JSON object: {exc}") from exc +def _split_dpop_url(url: str) -> tuple[SplitResult, int | None]: + """Split *url* and resolve its port, mapping urllib's bare ``ValueError``s. + + Two of them escape on a malformed authority: ``urlsplit`` itself rejects a + netloc containing ``[`` without ``]`` (``Invalid IPv6 URL``), and + ``SplitResult.port`` is parsed lazily, so a non-numeric or out-of-range port + raises at attribute access rather than at split time. + + Both callers below run on attacker-controlled input — ``dpop_verification`` + passes the proof's own ``htu`` claim through ``normalize_dpop_htu`` — and the + MCP adapters catch only ``AuthplaneError``, so a ``ValueError`` reaching them + turns a 401 into an unhandled 500. ``internal/urls.py`` guards the same + urllib trap on the derivation side. + """ + try: + parsed = urlsplit(url) + return parsed, parsed.port + except ValueError as exc: + raise InvalidDPoPProofError(f"DPoP URL is not a valid URI, got {url!r}") from exc + + def normalize_dpop_htu(url: str) -> str: - """Normalize a URI for DPoP `htu` generation and comparison.""" - parsed = urlparse(url) + """Normalize a URI for DPoP `htu` generation and comparison. + + urlsplit, not urlparse: urlparse peels an RFC 3986 ``;params`` segment off + the last path segment into its own slot, and urlunparse then drops it + unless it is passed back. RFC 9449 §4.3 defines ``htu`` as the request URI + with query and fragment removed, and RFC 3986 §3.3 puts ``;params`` in the + path — so it has to survive. ``dpop_verification`` normalizes both the + request URL and the proof's ``htu`` through here before comparing them, + which makes this a binding check: collapsing ``/mcp;v=1`` onto ``/mcp`` + would let a proof minted for one endpoint be accepted at the other, + defeating the cross-endpoint replay protection the comparison exists for. + + ``internal/urls.py`` uses urlsplit for the same reason, on derivation + rather than binding. + """ + parsed, port = _split_dpop_url(url) if not parsed.scheme or not parsed.hostname: raise InvalidDPoPProofError(f"DPoP URL must be absolute, got {url!r}") scheme = parsed.scheme.lower() - hostname = parsed.hostname.lower() - port = parsed.port + host = host_literal(parsed.hostname.lower()) include_port = port is not None and not ( (scheme == "https" and port == 443) or (scheme == "http" and port == 80) ) - netloc = f"{hostname}:{port}" if include_port and port is not None else hostname + netloc = f"{host}:{port}" if include_port and port is not None else host # DPoP binds to the target URI without query/fragment so the same resource # remains stable across equivalent requests. path = parsed.path or "/" - return urlunparse((scheme, netloc, path, "", "", "")) + return urlunsplit((scheme, netloc, path, "", "")) def _public_jwk(jwk_dict: Mapping[str, Any]) -> dict[str, Any]: @@ -292,13 +327,18 @@ def __post_init__(self) -> None: ) def _nonce_key(self, url: str) -> str: - parsed = urlparse(url) + # Only scheme/host/port are read, so ``;params`` cannot reach the key — + # but urlsplit is used anyway, so the module has one parse idiom rather + # than a urlparse whose safety has to be argued case by case. + parsed, port = _split_dpop_url(url) if not parsed.scheme or not parsed.hostname: raise InvalidDPoPProofError(f"DPoP URL must be absolute, got {url!r}") - port = parsed.port if port is None: port = 443 if parsed.scheme.lower() == "https" else 80 - return f"{parsed.scheme.lower()}://{parsed.hostname.lower()}:{port}" + # Bracketed for the same reason as the htu above: this key is an origin + # string, and an unbracketed IPv6 literal makes two different origins + # collide as readily as it makes one unparseable. + return f"{parsed.scheme.lower()}://{host_literal(parsed.hostname.lower())}:{port}" def note_nonce(self, url: str, nonce: str) -> None: """Store a server-provided DPoP-Nonce for the given URL's origin.""" diff --git a/authplane/dpop_verification.py b/authplane/dpop_verification.py index a9e0b2e..65b1c95 100644 --- a/authplane/dpop_verification.py +++ b/authplane/dpop_verification.py @@ -150,12 +150,15 @@ async def verify_dpop_proof( f"DPoP proof URL mismatch: expected {normalized_url!r}, got {htu!r}" ) - if expected_nonce: - actual_nonce = str(claims.get("nonce", "")) - if actual_nonce != expected_nonce: - raise InvalidDPoPProofError( - f"DPoP proof nonce mismatch: expected {expected_nonce!r}, got {actual_nonce!r}" - ) + # RFC 9449 §9 (Resource Server-Provided Nonce). Opt-in: an empty + # expected_nonce means no policy, so a proof carrying an AS-issued nonce + # still verifies. The message carries no values on purpose — errors on this + # path reach an unauthenticated caller through the `error_description` of a + # `WWW-Authenticate` challenge (see `www_authenticate` in errors.py), and + # echoing the server's expected nonce there would hand out a currently + # valid nonce without the challenge round trip the freshness proof rests on. + if expected_nonce and str(claims.get("nonce", "")) != expected_nonce: + raise InvalidDPoPProofError("DPoP proof nonce mismatch") _validate_dpop_temporal( claims, iat, max_age_seconds=max_age_seconds, clock_skew_seconds=clock_skew_seconds diff --git a/authplane/net/ssrf.py b/authplane/net/ssrf.py index 60757ac..2520e7b 100644 --- a/authplane/net/ssrf.py +++ b/authplane/net/ssrf.py @@ -9,10 +9,11 @@ import json from dataclasses import dataclass from typing import Any -from urllib.parse import urlencode, urlparse +from urllib.parse import urlencode, urlsplit import httpx +from ..internal.urls import host_literal from .ip_validation import SSRFError, format_ip_for_url, is_ip_allowed, resolve_hostname @@ -25,6 +26,11 @@ class ValidatedURL: port: int path: str resolved_ips: list[str] + # Appended rather than inserted: this is a frozen dataclass, and adding a + # required field mid-list silently breaks positional construction for anyone + # importing it directly. Low blast radius — it is in no `__all__` and is not + # re-exported — but the position costs nothing. + scheme: str = "https" @dataclass(frozen=True) @@ -61,8 +67,21 @@ async def validate_url( Raises: SSRFError: If URL is invalid or resolves to blocked IPs """ + # urlsplit, not urlparse: urlparse peels an RFC 3986 ``;params`` segment off + # the last path segment into its own slot, and ``ValidatedURL.path`` is what + # the pinned request URL is rebuilt from below — so a urlparse here issues + # the request to ``/token`` when the caller asked for ``/token;v=1``. That + # silently retargets the request, undoes the ``;params``-preserving + # derivation in ``internal/urls.py`` before it reaches the wire, and puts the + # outbound DPoP proof's ``htu`` (built from the caller's URL in ``net/http``) + # out of sync with the request line the AS actually sees. + # + # ``.port`` is resolved inside the guard because it is parsed lazily and + # raises on a non-numeric or out-of-range port, which would escape this + # function as a bare ValueError rather than an SSRFError. try: - parsed = urlparse(url) + parsed = urlsplit(url) + parsed_port = parsed.port except (ValueError, AttributeError) as e: raise SSRFError(f"Invalid URL: {e}") from e @@ -78,7 +97,7 @@ async def validate_url( raise SSRFError("URL must have a host") hostname = parsed.hostname or parsed.netloc - port = parsed.port or (443 if parsed.scheme == "https" else 80) + port = parsed_port or (443 if parsed.scheme == "https" else 80) # Resolve and validate IPs resolved_ips = await resolve_hostname(hostname, port) @@ -100,6 +119,17 @@ async def validate_url( return ValidatedURL( original_url=url, + # The parsed scheme, already lowercased by urlsplit. Carried rather than + # recovered downstream: rebuilding it with `url.startswith("https://")` + # meant an uppercase `HTTPS://` passed the HTTPS-only gate above — which + # compares against the normalized scheme — and was then assembled as + # `http://`, putting the bytes on the wire in the clear. The port stays + # 443 so the request fails rather than silently downgrading, but the + # bytes leave unencrypted either way, and in `form_post` they carry the + # auth headers. Reachable from AS metadata: `jwks_uri`, + # `token_endpoint` and `introspection_endpoint` are validated against + # the lowercased scheme and reach the fetch verbatim. + scheme=parsed.scheme, hostname=hostname, port=port, path=parsed.path + ("?" + parsed.query if parsed.query else ""), @@ -135,8 +165,8 @@ async def _execute_pinned_request( # reconstruction of the request URI from Host + path. IPv6 literals are # bracketed per RFC 3986 §3.2.2. default_port = 443 if scheme == "https" else 80 - host_literal = f"[{hostname}]" if ":" in hostname else hostname - headers["Host"] = host_literal if port == default_port else f"{host_literal}:{port}" + literal = host_literal(hostname) + headers["Host"] = literal if port == default_port else f"{literal}:{port}" headers["Accept"] = "application/json" stream_kwargs: dict[str, Any] = { @@ -245,7 +275,7 @@ async def _ssrf_safe_request( last_error: Exception | None = None for pinned_ip in validated.resolved_ips: - scheme = "https" if url.startswith("https://") else "http" + scheme = validated.scheme pinned_url = f"{scheme}://{format_ip_for_url(pinned_ip)}:{validated.port}{validated.path}" try: diff --git a/tests/net/test_ssrf.py b/tests/net/test_ssrf.py index 0a649e4..7239ff6 100644 --- a/tests/net/test_ssrf.py +++ b/tests/net/test_ssrf.py @@ -206,6 +206,24 @@ async def test_invalid_url_rejected(self) -> None: with pytest.raises(SSRFError, match="must use HTTPS"): await validate_url("not a url") + @patch("authplane.net.ssrf.resolve_hostname") + async def test_params_segment_survives_into_path(self, mock_resolve: AsyncMock) -> None: + """An RFC 3986 ``;params`` segment must stay in the request path. + + ``urlparse`` moves it out of ``.path`` into its own slot, so the pinned + URL built from ``ValidatedURL.path`` would target ``/token`` when the + caller asked for ``/token;v=1``. RFC 3986 §3.3 puts it in the path. + """ + mock_resolve.return_value = ["8.8.8.8"] + + validated = await validate_url("https://as.example.com/token;v=1?x=1") + assert validated.path == "/token;v=1?x=1" + + async def test_malformed_port_raises_ssrf_error(self) -> None: + """``SplitResult.port`` parses lazily; the bare ValueError must not escape.""" + with pytest.raises(SSRFError, match="Invalid URL"): + await validate_url("https://as.example.com:abc/token") + @pytest.mark.asyncio class TestSSRFSafeFetch: @@ -220,6 +238,7 @@ async def test_successful_fetch( # Setup validation mock_validate.return_value = ValidatedURL( original_url="https://example.com/.well-known/jwks.json", + scheme="https", hostname="example.com", port=443, path="/.well-known/jwks.json", @@ -264,6 +283,91 @@ async def mock_aiter_bytes() -> AsyncGenerator[bytes, None]: assert call_args[0][1] == "https://1.2.3.4:443/.well-known/jwks.json" assert call_args[1]["headers"]["Host"] == "example.com" + @patch("authplane.net.ssrf.resolve_hostname") + @patch("httpx.AsyncClient") + async def test_pinned_url_retains_params_segment( + self, mock_client_class: MagicMock, mock_resolve: AsyncMock + ) -> None: + """The pinned URL must address the endpoint the caller named. + + ``validate_url`` is deliberately not mocked here: the pinned URL is + assembled from ``ValidatedURL.path``, so this is the assertion that ties + the parse fix to the bytes on the wire. With ``urlparse`` the request + goes to ``/token`` — a different endpoint than the caller asked for, and + a different one than the outbound DPoP proof's ``htu`` names. + """ + mock_resolve.return_value = ["1.2.3.4"] + + mock_response = MagicMock() + mock_response.headers = {"content-length": "2"} + + async def mock_aiter_bytes() -> AsyncGenerator[bytes, None]: + yield b"{}" + + mock_response.aiter_bytes = mock_aiter_bytes + mock_response.status_code = 200 + + mock_stream_cm = AsyncMock() + mock_stream_cm.__aenter__.return_value = mock_response + mock_stream_cm.__aexit__.return_value = None + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream_cm) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__.return_value = None + mock_client_class.return_value = mock_client + + await ssrf_safe_get("https://as.example.com/token;v=1") + + call_args = mock_client.stream.call_args + assert call_args[0][1] == "https://1.2.3.4:443/token;v=1" + + @patch("authplane.net.ssrf.resolve_hostname") + @patch("httpx.AsyncClient") + async def test_uppercase_scheme_stays_on_the_wire_as_https( + self, mock_client_class: MagicMock, mock_resolve: AsyncMock + ) -> None: + """An uppercase scheme must not be downgraded to cleartext. + + ``validate_url`` is deliberately not mocked, for the same reason as the + params-segment case above: the defect was that the gate and the wire + read the scheme from two different places. ``urlsplit`` lowercases it, + so ``HTTPS://`` passed the HTTPS-only check, and the pinned URL was then + rebuilt with ``url.startswith("https://")`` — false — and issued as + ``http://``. The port stays 443, so the request fails rather than + silently downgrading, but the bytes leave unencrypted, and in + ``form_post`` they carry the auth headers. + + Reachable from AS metadata: ``jwks_uri``, ``token_endpoint`` and + ``introspection_endpoint`` are validated against the normalized scheme + and reach the fetch verbatim. + """ + mock_resolve.return_value = ["1.2.3.4"] + + mock_response = MagicMock() + mock_response.headers = {"content-length": "2"} + + async def mock_aiter_bytes() -> AsyncGenerator[bytes, None]: + yield b"{}" + + mock_response.aiter_bytes = mock_aiter_bytes + mock_response.status_code = 200 + + mock_stream_cm = AsyncMock() + mock_stream_cm.__aenter__.return_value = mock_response + mock_stream_cm.__aexit__.return_value = None + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream_cm) + mock_client.__aenter__ = AsyncMock(return_value=mock_client) + mock_client.__aexit__.return_value = None + mock_client_class.return_value = mock_client + + await ssrf_safe_get("HTTPS://as.example.com/jwks") + + call_args = mock_client.stream.call_args + assert call_args[0][1] == "https://1.2.3.4:443/jwks" + @patch("authplane.net.ssrf.validate_url") @patch("httpx.AsyncClient") async def test_host_header_includes_non_default_port( @@ -272,6 +376,7 @@ async def test_host_header_includes_non_default_port( """Non-default port must appear in the Host header (RFC 7230 §5.4).""" mock_validate.return_value = ValidatedURL( original_url="http://localhost:9000/foo", + scheme="http", hostname="localhost", port=9000, path="/foo", @@ -309,6 +414,7 @@ async def test_host_header_strips_default_http_port( """Default HTTP port 80 must be omitted from the Host header.""" mock_validate.return_value = ValidatedURL( original_url="http://example.com/path", + scheme="http", hostname="example.com", port=80, path="/path", @@ -346,6 +452,7 @@ async def test_host_header_brackets_ipv6_literal( """IPv6 hostnames must be bracketed in the Host header (RFC 3986 §3.2.2).""" mock_validate.return_value = ValidatedURL( original_url="http://[::1]:9000/foo", + scheme="http", hostname="::1", port=9000, path="/foo", @@ -383,6 +490,7 @@ async def test_response_too_large_content_length( """Should reject response if Content-Length exceeds max_size.""" mock_validate.return_value = ValidatedURL( original_url="https://example.com/large", + scheme="https", hostname="example.com", port=443, path="/large", @@ -414,6 +522,7 @@ async def test_response_too_large_actual_content( """Should reject response if actual content exceeds max_size.""" mock_validate.return_value = ValidatedURL( original_url="https://example.com/large", + scheme="https", hostname="example.com", port=443, path="/large", @@ -450,6 +559,7 @@ async def test_redirects_disabled( """Should disable redirects to prevent bypass.""" mock_validate.return_value = ValidatedURL( original_url="https://example.com/", + scheme="https", hostname="example.com", port=443, path="/", @@ -490,6 +600,7 @@ async def test_timeout_configured( """Should configure timeout for all operations.""" mock_validate.return_value = ValidatedURL( original_url="https://example.com/", + scheme="https", hostname="example.com", port=443, path="/", @@ -538,6 +649,7 @@ async def test_fallback_to_next_ip_on_timeout( """Should try next IP if first times out.""" mock_validate.return_value = ValidatedURL( original_url="https://example.com/", + scheme="https", hostname="example.com", port=443, path="/", diff --git a/tests/net/test_ssrf_edge_cases.py b/tests/net/test_ssrf_edge_cases.py index f00482e..e54665e 100644 --- a/tests/net/test_ssrf_edge_cases.py +++ b/tests/net/test_ssrf_edge_cases.py @@ -136,6 +136,7 @@ async def test_http_url_accepted_when_allow_http_true(self, mock_resolve: AsyncM def _make_validated_url(ips: list[str] | None = None) -> ValidatedURL: return ValidatedURL( original_url="https://example.com/jwks.json", + scheme="https", hostname="example.com", port=443, path="/jwks.json", @@ -216,6 +217,7 @@ async def test_json_decode_error_tries_next_ip( """A non-JSON response on the first IP causes a retry on the second IP.""" mock_validate.return_value = ValidatedURL( original_url="https://example.com/jwks.json", + scheme="https", hostname="example.com", port=443, path="/jwks.json", @@ -269,6 +271,7 @@ async def test_all_ips_fail_raises_last_error( """When all resolved IPs fail, the last error is raised.""" mock_validate.return_value = ValidatedURL( original_url="https://example.com/jwks.json", + scheme="https", hostname="example.com", port=443, path="/jwks.json", @@ -297,6 +300,7 @@ async def test_all_ips_fail_json_decode_raises_last_error( """When all IPs return invalid JSON, the JSONDecodeError is raised.""" mock_validate.return_value = ValidatedURL( original_url="https://example.com/jwks.json", + scheme="https", hostname="example.com", port=443, path="/jwks.json", @@ -366,6 +370,7 @@ async def test_all_ips_timeout_raises_last_timeout( """When all IPs time out, the last TimeoutException is propagated.""" mock_validate.return_value = ValidatedURL( original_url="https://example.com/jwks.json", + scheme="https", hostname="example.com", port=443, path="/jwks.json", @@ -405,6 +410,7 @@ async def test_post_returns_json_body_on_success( """ssrf_safe_post returns the parsed JSON body on a 200 response.""" mock_validate.return_value = ValidatedURL( original_url="https://auth.example.com/introspect", + scheme="https", hostname="auth.example.com", port=443, path="/introspect", @@ -444,6 +450,7 @@ async def test_post_passes_form_data_to_stream( """ssrf_safe_post passes form_data as the `data` kwarg to client.stream.""" mock_validate.return_value = ValidatedURL( original_url="https://auth.example.com/introspect", + scheme="https", hostname="auth.example.com", port=443, path="/introspect", @@ -488,6 +495,7 @@ async def test_post_json_decode_error_raises( is not valid JSON (all IPs exhausted).""" mock_validate.return_value = ValidatedURL( original_url="https://auth.example.com/introspect", + scheme="https", hostname="auth.example.com", port=443, path="/introspect", @@ -527,6 +535,7 @@ async def test_post_network_error_raises( """ssrf_safe_post propagates the last RequestError when all IPs fail.""" mock_validate.return_value = ValidatedURL( original_url="https://auth.example.com/introspect", + scheme="https", hostname="auth.example.com", port=443, path="/introspect", diff --git a/tests/test_dpop_and_security.py b/tests/test_dpop_and_security.py index d4dd33b..7725f4e 100644 --- a/tests/test_dpop_and_security.py +++ b/tests/test_dpop_and_security.py @@ -26,6 +26,7 @@ MetadataFetchError, MissingMetadataEndpointError, ProtocolError, + www_authenticate, ) from authplane.internal.document_cache import JWKSCache from authplane.internal.fetch_result import FetchResult @@ -282,6 +283,142 @@ async def test_verify_dpop_proof_method_mismatch(dpop_provider: DPoPProvider) -> ) +async def test_verify_dpop_proof_rejects_wrong_nonce_under_policy( + dpop_provider: DPoPProvider, +) -> None: + """``expected_nonce`` had no test at all outside the conformance suite. + + The parameter has been on ``verify_dpop_proof`` all along, and nothing in + the unit suite exercised it — so the resource-server nonce policy (RFC 9449 + §9, not the §8 AS-provided nonce) could have been deleted without anything + here going red. + """ + proof = dpop_provider.build_proof( + "GET", "https://api.example.com/resource", access_token="access-token", nonce="stale" + ) + + with pytest.raises(InvalidDPoPProofError, match="nonce mismatch") as excinfo: + await verify_dpop_proof( + proof, + method="GET", + url="https://api.example.com/resource", + replay_store=MemoryReplayStore(), + access_token="access-token", + expected_jkt=dpop_provider.key_material.thumbprint, + expected_nonce="server-nonce-abc", + ) + # The rejection reaches an unauthenticated caller via error_description in + # the WWW-Authenticate challenge, so it must not echo the nonce the server + # is expecting — that would supply a valid nonce without the round trip. + assert "server-nonce-abc" not in www_authenticate(excinfo.value) + assert "stale" not in str(excinfo.value) + + +async def test_verify_dpop_proof_rejects_missing_nonce_under_policy( + dpop_provider: DPoPProvider, +) -> None: + """An omitted nonce claim is as much a policy violation as a wrong one.""" + proof = dpop_provider.build_proof( + "GET", "https://api.example.com/resource", access_token="access-token" + ) + + with pytest.raises(InvalidDPoPProofError, match="nonce mismatch"): + await verify_dpop_proof( + proof, + method="GET", + url="https://api.example.com/resource", + replay_store=MemoryReplayStore(), + access_token="access-token", + expected_jkt=dpop_provider.key_material.thumbprint, + expected_nonce="server-nonce-abc", + ) + + +async def test_verify_dpop_proof_without_policy_ignores_the_nonce_claim( + dpop_provider: DPoPProvider, +) -> None: + """No configured policy means no nonce requirement. + + The guard is ``if expected_nonce:``, so a caller that never opts in must + not start rejecting proofs that happen to carry a nonce — the AS-issued + one, for instance, on a token the resource server is merely verifying. + """ + proof = dpop_provider.build_proof( + "GET", + "https://api.example.com/resource", + access_token="access-token", + nonce="as-issued-nonce", + ) + + verified = await verify_dpop_proof( + proof, + method="GET", + url="https://api.example.com/resource", + replay_store=MemoryReplayStore(), + access_token="access-token", + expected_jkt=dpop_provider.key_material.thumbprint, + ) + assert verified.raw["nonce"] == "as-issued-nonce" + + +async def test_verify_dpop_proof_rejects_params_segment_endpoint_swap( + dpop_provider: DPoPProvider, +) -> None: + """A proof minted for ``/mcp;v=1`` must not be accepted at ``/mcp``. + + RFC 3986 §3.3 puts a ``;params`` segment in the path, and RFC 9449 §4.3 + strips only query and fragment from ``htu`` — so these are two distinct + endpoints. ``normalize_dpop_htu`` ran urlparse/urlunparse with an empty + params slot, which collapsed them onto one ``htu``. Since verification + normalizes both sides through it, the URI-binding comparison silently + passed across that pair and a proof for one endpoint was replayable at the + other. This is the binding half of the collapse; the derivation half is + covered by ``TestParamsSegmentIsNotCollapsed`` in tests/internal/test_urls.py. + """ + replay_store = MemoryReplayStore() + proof = dpop_provider.build_proof( + "GET", "https://api.example.com/mcp;v=1", access_token="access-token" + ) + + with pytest.raises(InvalidDPoPProofError, match="URL mismatch"): + await verify_dpop_proof( + proof, + method="GET", + url="https://api.example.com/mcp", + replay_store=replay_store, + access_token="access-token", + expected_jkt=dpop_provider.key_material.thumbprint, + ) + + +async def test_verify_dpop_proof_accepts_the_same_params_segment( + dpop_provider: DPoPProvider, +) -> None: + """The other direction: keeping the segment must not break the honest case. + + A stricter normalizer that rejected ``/mcp;v=1`` against itself would pass + the test above for the wrong reason. + """ + replay_store = MemoryReplayStore() + proof = dpop_provider.build_proof( + "GET", "https://api.example.com/mcp;v=1", access_token="access-token" + ) + + verified = await verify_dpop_proof( + proof, + method="GET", + url="https://api.example.com/mcp;v=1", + replay_store=replay_store, + access_token="access-token", + expected_jkt=dpop_provider.key_material.thumbprint, + ) + # ``VerifiedDPoPProof.htu`` is the raw claim, so this asserts the *outbound* + # side: build_proof minted an htu carrying the segment. The inbound + # comparison is what the successful return above proves. + assert verified.htu == "https://api.example.com/mcp;v=1" + assert verified.key_thumbprint == dpop_provider.key_material.thumbprint + + async def test_verify_dpop_proof_rejects_expired_exp_claim(dpop_provider: DPoPProvider) -> None: replay_store = MemoryReplayStore() now = 1_700_000_000 @@ -573,3 +710,13 @@ async def test_mode3_not_configured_does_not_allocate_replay_store( # Internal attribute: confirms the load-bearing optimisation that nothing is # allocated when DPoP is not in use. assert verifier._dpop_replay_store is None # type: ignore[reportPrivateUsage] + + +def test_nonce_key_maps_a_malformed_authority_to_the_sdk_error( + dpop_provider: DPoPProvider, +) -> None: + # The outbound half of the same urllib trap: ``.port`` is parsed lazily, so + # a bare ValueError would escape note_nonce/current_nonce instead of the + # AuthplaneError the caller catches. + with pytest.raises(InvalidDPoPProofError): + dpop_provider.note_nonce("https://auth.example.com:abc/oauth/token", "nonce-123") diff --git a/tests/test_protocol_and_http_edges.py b/tests/test_protocol_and_http_edges.py index 4a1cefd..219e703 100644 --- a/tests/test_protocol_and_http_edges.py +++ b/tests/test_protocol_and_http_edges.py @@ -200,6 +200,108 @@ def test_normalize_dpop_htu_rejects_relative_url() -> None: normalize_dpop_htu("/relative") +def test_normalize_dpop_htu_keeps_the_params_segment() -> None: + # urlparse peels ";params" off the last path segment; urlunparse then drops + # it unless passed back. RFC 3986 §3.3 puts it in the path, and htu is the + # request URI minus query and fragment only, so it has to survive. + assert ( + normalize_dpop_htu("https://api.example.com/mcp;v=1") == "https://api.example.com/mcp;v=1" + ) + + +@pytest.mark.parametrize( + "malformed", + [ + "https://api.example.com:abc/mcp", # port cast, raised at attribute access + "https://api.example.com:99999/mcp", # port out of range + "https://[::1#frag", # urlsplit itself: "Invalid IPv6 URL" + ], +) +def test_normalize_dpop_htu_maps_malformed_authorities_to_the_sdk_error(malformed: str) -> None: + # dpop_verification passes the proof's own htu claim through here, so this + # runs on attacker-controlled input. The MCP adapters catch only + # AuthplaneError, so a bare urllib ValueError turns a 401 into an + # unhandled 500. + with pytest.raises(InvalidDPoPProofError): + normalize_dpop_htu(malformed) + + +@pytest.mark.parametrize( + ("url", "expected"), + [ + ("https://[::1]:8080/mcp", "https://[::1]:8080/mcp"), + ("https://[2001:db8::1]:9443/token", "https://[2001:db8::1]:9443/token"), + # Default port elided, brackets still required. + ("https://[::1]/mcp", "https://[::1]/mcp"), + ("http://[::1]:80/mcp", "http://[::1]/mcp"), + # Case folding still applies inside the brackets. + ("https://[2001:DB8::1]/mcp", "https://[2001:db8::1]/mcp"), + ], +) +def test_normalize_dpop_htu_brackets_ipv6_literals(url: str, expected: str) -> None: + # SplitResult.hostname strips the brackets RFC 3986 §3.2.2 requires, so + # reassembling the authority from it produced an invalid URI: + # "https://[::1]:8080/mcp" came back as "https://::1:8080/mcp". + assert normalize_dpop_htu(url) == expected + + +def test_normalize_dpop_htu_is_idempotent_over_ipv6() -> None: + # The end-to-end consequence, and the reason this is not cosmetic: htu is + # emitted by the client and re-parsed by the server, both this SDK. The + # unbracketed form is rejected by _split_dpop_url, so an honest proof against + # an IPv6 endpoint was refused — reachable in dev mode against http://[::1]. + once = normalize_dpop_htu("https://[::1]:8080/mcp") + assert normalize_dpop_htu(once) == once + + +# `::1:8080` is itself a valid IPv6 literal, so these are two distinct endpoints +# whose unbracketed forms are byte-identical: +# +# 'https://[::1]:8080/x' -> 'https://::1:8080/x' +# 'https://[::1:8080]/x' -> 'https://::1:8080/x' +# +# which is the same acceptance widening this PR started from, in its IPv6 +# variant. The first version of these two cases compared `:8080` against `:9443` +# — distinct with or without brackets — so both passed with the bracketing +# removed and neither tested what its docstring claimed. That is the criticism +# this PR made of itself in round one: "a normalizer that over-rejected would +# satisfy the first test alone". +_COLLIDING_IPV6 = ("https://[::1]:8080/x", "https://[::1:8080]/x") + + +def test_normalize_dpop_htu_keeps_colliding_ipv6_authorities_apart() -> None: + bracketed, literal = _COLLIDING_IPV6 + assert normalize_dpop_htu(bracketed) != normalize_dpop_htu(literal) + + +def test_nonce_key_is_a_well_formed_origin_for_an_ipv6_literal( + jwks_keypair: dict[str, Any], +) -> None: + # `_nonce_key` cannot collide the way `htu` does — it always appends an + # explicit port, so the two authorities above stay distinct even unbracketed + # (``::1:8080`` against ``::1:8080:443``). What the bracketing buys here is + # that the key is a well-formed origin rather than an unparseable string, so + # that is what this asserts: the same reassembly bug, without claiming an + # acceptance widening that this function's shape rules out. + provider = DPoPProvider( + DPoPKeyMaterial.from_pem(jwks_keypair["private_key"], algorithm="ES256") + ) + + key = provider._nonce_key("https://[::1]:8080/token") # pyright: ignore[reportPrivateUsage] + + assert key == "https://[::1]:8080" + # And it survives the module's own parser, which the unbracketed form does not. + assert normalize_dpop_htu(f"{key}/token") == "https://[::1]:8080/token" + + +def test_normalize_dpop_htu_still_strips_query_and_fragment() -> None: + # The swap must not widen what htu drops: RFC 9449 §4.3 removes exactly + # query and fragment. + assert ( + normalize_dpop_htu("https://api.example.com/mcp?a=1#frag") == "https://api.example.com/mcp" + ) + + def test_jwk_thumbprint_rejects_unknown_kty() -> None: with pytest.raises(InvalidDPoPProofError): jwk_thumbprint({"kty": "oct"}) From c8d06334066242a8eeca1f26705db962482239e2 Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Tue, 18 Aug 2026 19:12:14 -0300 Subject: [PATCH 5/5] ci,scripts: pin the conformance catalog tooling, stop ignoring uv lockfiles, record the changelog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The conformance suite stops xfailing the inbound DPoP-Nonce case now that the catalog carries it, and the catalog-fetch tooling is aligned with the pinned ref so a local run matches CI rather than the moving default branch. `backport-fixes.sh` accepts a tag as `--from`: `release.yml` deletes `release/vX.Y.Z` once the tag is pushed, so afterwards the tag is the only ref naming those commits — which is exactly what the release summary tells the operator to pass. `--from` and `--to` are validated as ref names before reaching a fetch refspec, since `git ls-remote` matches its arguments as globs. `uv.lock` is ignored: nothing tracks or consumes these files — no workflow installs with uv — so they are local resolution artifacts. Three of them were swept into a merge commit as untracked files once and accounted for 94% of that diff. Tracking them for reproducible installs is a real decision and belongs in its own PR, alongside the CI change that would make them load-bearing. --- .github/workflows/security.yml | 10 - .github/workflows/workflows-lint.yml | 50 +- .gitignore | 8 + CHANGELOG.md | 21 +- conformance-tests/README.md | 57 +- conformance-tests/conftest.py | 42 +- .../test_jwt_and_dpop_conformance.py | 78 ++- scripts/backport-fixes.sh | 191 ++++++- scripts/backport-fixes.test.sh | 504 ++++++++++++++++++ 9 files changed, 888 insertions(+), 73 deletions(-) create mode 100755 scripts/backport-fixes.test.sh diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 287f376..4afa658 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -74,16 +74,6 @@ jobs: # latest released version, so `pip install --upgrade pip` can't pull # a patched build. Drop this ignore once pip >= 26.1 is on PyPI. # See https://github.com/pypa/pip/pull/13870. - # - # PYSEC-2026-3483: affects mcp <= 1.27.2 (fixed in 1.28.1). The - # authplane-mcp adapter pins mcp <1.28.0 because 1.28 renamed the - # elicitation field elicitationId -> elicitation_id (snake_case), - # which breaks url_elicitation.py's ElicitRequestURLParams wire - # handling (every consent-driven exchange would raise a pydantic - # ValidationError). Accepted risk until the adapter is migrated to - # the 1.28 field name and the floor is raised to 1.28.1; drop this - # ignore then. run: >- pip-audit --skip-editable --progress-spinner off --ignore-vuln CVE-2026-3219 - --ignore-vuln PYSEC-2026-3483 diff --git a/.github/workflows/workflows-lint.yml b/.github/workflows/workflows-lint.yml index dfadf36..8d72b79 100644 --- a/.github/workflows/workflows-lint.yml +++ b/.github/workflows/workflows-lint.yml @@ -1,19 +1,28 @@ -name: Lint workflows +name: Release tooling # Catches workflow YAML / shell-in-`run:` regressions at PR time so a # typo can't reach a release tag and surface only when a publish run -# fails. Scoped to changes under `.github/workflows/**` to keep CI -# overhead off unrelated PRs. +# fails. The shell scripts under scripts/ are in the same category — a +# break in them surfaces only when someone reaches for them after a +# release, which is the worst moment to discover it — so they are linted +# and tested here too. Scoped to those two paths to keep CI overhead off +# unrelated PRs. +# +# The scripts trigger is `scripts/**`, not `scripts/*.sh`: a single-level +# glob would leave a future scripts/lib/*.sh both untriggered here and +# unlinted below, in each case silently. on: pull_request: paths: - ".github/workflows/**" + - "scripts/**" push: branches: - main paths: - ".github/workflows/**" + - "scripts/**" permissions: contents: read @@ -56,9 +65,36 @@ jobs: echo "${ACTIONLINT_INSTALL_DIR}" >> "${GITHUB_PATH}" "${ACTIONLINT_INSTALL_DIR}/actionlint" -version - # `-shellcheck=shellcheck` makes the shellcheck dependency explicit - # rather than relying on actionlint's implicit lookup against the - # runner image's $PATH; if the Ubuntu image ever drops shellcheck the - # job fails loudly instead of silently degrading. + # Both steps below resolve `shellcheck` off the runner image's $PATH — + # actionlint via `-shellcheck=shellcheck`, the script lint directly. + # Asserting it once, up front, is what makes that dependency explicit: + # naming the binary in actionlint's flag only changes which lookup + # fails, and neither step announces the version it linted with. If the + # Ubuntu image ever drops shellcheck, this fails first and says so, + # rather than actionlint quietly degrading to no shell analysis. + - name: Check shellcheck is available + run: shellcheck --version + - name: Run actionlint run: actionlint -color -shellcheck=shellcheck + + - name: Shellcheck the release scripts + # find, not `scripts/*.sh`: the single-level glob would silently skip + # a future scripts/lib/*.sh, the same blind spot the path trigger had. + # An empty result is an error rather than a green no-op, so a moved or + # renamed directory cannot pass as a clean lint. + run: | + mapfile -d '' -t sh_files < <(find scripts -type f -name '*.sh' -print0) + if [[ ${#sh_files[@]} -eq 0 ]]; then + echo "error: no shell scripts found under scripts/" >&2 + exit 1 + fi + printf 'shellcheck: %s\n' "${sh_files[@]}" + shellcheck "${sh_files[@]}" + + # backport-fixes.sh accepts a branch or a tag as --from, and only the + # branch form has a remote-tracking ref. The tag form is what the release + # flow tells you to use once release.yml has deleted the branch, so it is + # the form least likely to be exercised before it is needed. + - name: Test backport-fixes.sh + run: scripts/backport-fixes.test.sh diff --git a/.gitignore b/.gitignore index 81effdf..855e3e6 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,14 @@ wheels/ .installed.cfg *.egg +# uv lockfiles. Not tracked on main and nothing consumes them — no workflow +# installs with uv — so they are local resolution artifacts. Three of them were +# swept into a merge commit as untracked files and accounted for 94% of a PR's +# diff; ignoring them is what stops that recurring. Tracking them for +# reproducible installs is a real decision, and it belongs in its own PR +# alongside the CI change that would make them load-bearing. +uv.lock + # Testing .pytest_cache/ .coverage diff --git a/CHANGELOG.md b/CHANGELOG.md index 0d29b0d..5951f74 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,17 +12,36 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - `authplane-fastmcp`, `authplane-mcp`: `authplane_auth()` and `authplane_mcp_auth()` accept `fail_closed: bool = False` and forward it to `AuthplaneClient.resource(...)`. - `AuthplaneClient.resource(...)` logs a warning when `fail_closed=True` is set without a `revocation_checker`. +- `InvalidIssuerError` and `InvalidResourceError` — raised when an issuer carries a query or fragment component (RFC 8414 §2), or a resource indicator carries a fragment (RFC 8707 §2). Both subclass `AuthplaneError` **and** `ValueError`, so existing `except ValueError` handlers are unaffected; what they add is the ability to tell an identifier misconfiguration apart from any other `ValueError` the SDK raises. Both identifiers are typed, not just the issuer — the resource rejection below is a behaviour change deployments have to react to, and catching it should not require the undiscriminating handler. +- `authplane-fastmcp`: `VerbatimPRMRemoteAuthProvider` is now public (was `_VerbatimPRMRemoteAuthProvider`), and `rewrite_prm_routes_verbatim` is exported as a supported hook. Building a `RemoteAuthProvider` by hand is a documented FastMCP pattern, and doing so previously lost the verbatim PRM silently. +- `authplane-mcp`: `AuthplaneTokenVerifier.verbatim_identifiers()` returns the configured `(issuer, resource)` pair, or `None` when the verifier was not built through `authplane_mcp_auth`. ### Security +- `verify_dpop_proof` no longer includes the expected or the received nonce in the `InvalidDPoPProofError` it raises when a configured `expected_nonce` policy is violated. The message was `DPoP proof nonce mismatch: expected 'server-nonce-abc', got 'stale'`; it is now `DPoP proof nonce mismatch`. `www_authenticate` copies `str(error)` into the challenge's `error_description` verbatim, so once `expected_nonce` becomes reachable from `AuthplaneResource.verify()` the 401 would hand an unauthenticated caller a currently-valid resource-server nonce — the freshness guarantee the nonce exists to provide (RFC 9449 §9), obtained without the challenge round trip that is supposed to be the only way to get one. Not reachable from the resource path today, which is why it is corrected before the wiring lands rather than after. **Migration:** code matching on the operand text of this message must match on `nonce mismatch` instead. `_sanitize_header_value` was never a defence here — it prevents header injection, not disclosure. +- `normalize_dpop_htu` no longer collapses an RFC 3986 `;params` segment, which weakened DPoP endpoint binding. `urlparse` peels the segment off the last path segment into its own slot and `urlunparse` dropped it, so `https://api.example.com/mcp;v=1` and `https://api.example.com/mcp` normalized to one `htu`. Inbound verification normalizes **both** the request URL and the proof's `htu` through this function before comparing them, so those two distinct endpoints compared equal and **a proof minted for one was accepted at the other** — an acceptance widening on the cross-endpoint replay protection that RFC 9449 §4.3's URI binding exists to provide. RFC 3986 §3.3 places `;params` in the path, and §4.3 removes only query and fragment from `htu`, so the segment has to survive. Now uses `urlsplit`/`urlunsplit`, matching `internal/urls.py`; query and fragment are still stripped. **Impact:** deployments serving endpoints that differ only by a `;params` segment. Outbound proofs now carry the segment in `htu`, so a server comparing `htu` against a request URI containing one will match where it previously did not. +- The SSRF-safe fetch path no longer drops an RFC 3986 `;params` segment from the request target. `validate_url` parsed with `urlparse` and the pinned request URL is rebuilt from `ValidatedURL.path`, so with `ssrf_protection=True` — the default — a call to `https://as.example.com/token;v=1` was issued to `/token`. Three consequences, all fixed together with the `normalize_dpop_htu` change above, which is what makes the two sides agree: the request silently addressed a **different endpoint** than the caller named; the outbound DPoP proof's `htu` (built from the caller's URL) disagreed with the request line, which an AS rejects as an `htu` mismatch; and the `;params`-preserving `.well-known` derivation in `build_prm_url`/`build_metadata_url` was undone before it reached the wire. Now uses `urlsplit`. A malformed port (`https://h:abc/`) also raises `SSRFError` rather than a bare `ValueError` — `SplitResult.port` is parsed lazily, so it escaped the function's own guard. +- `normalize_dpop_htu` and the DPoP nonce origin key raise `InvalidDPoPProofError` instead of a bare `ValueError` on a malformed authority (non-numeric or out-of-range port, unterminated IPv6 literal). Inbound verification passes the proof's own `htu` claim through the normalizer, and the MCP adapters catch only `AuthplaneError` — so a client could turn a 401 into an unhandled 500 with a crafted proof. +- The SSRF-safe fetch path no longer downgrades an uppercase scheme to cleartext. `urlsplit` normalizes the scheme, so `HTTPS://as.example.com/jwks` passed the HTTPS-only gate in `validate_url` — which compares the normalized value — and the pinned request URL was then rebuilt with `url.startswith("https://")`, which is false, and issued as `http://`. The port stayed at the validated 443, so the request fails rather than silently downgrading, but the bytes left unencrypted, and on the `form_post` path they carry the client's auth headers. Reachable from AS metadata content: `jwks_uri`, `token_endpoint` and `introspection_endpoint` are validated against the already-lowercased scheme in `internal/metadata.py` and reach `ssrf_safe_post` verbatim. `ValidatedURL` now carries the parsed scheme and the request is built from it, so the value the gate checked is the value that goes on the wire. +- `normalize_dpop_htu` and the DPoP nonce origin key no longer emit an unbracketed IPv6 authority. `SplitResult.hostname` strips the brackets RFC 3986 §3.2.2 requires, so reassembling the authority produced an invalid URI — `https://[::1]:8080/mcp` came back as `https://::1:8080/mcp`, which this SDK's own `htu` parser then refuses. A proof minted by this SDK against an IPv6 endpoint was therefore **rejected by this SDK with both sides honest**, reachable in dev mode (`allow_localhost`) against `http://[::1]:port`. The nonce key had the same defect, where an unbracketed literal also lets distinct origins collide. `net/ssrf.py` already re-bracketed when reconstructing the `Host` header for the same comparison; both halves of that binding check now agree. This was also cross-SDK drift rather than a quirk — go re-brackets explicitly, java's `URI.getHost()` and ts's WHATWG `URL.hostname` keep the brackets. +- `internal/metadata.py`'s endpoint validation raises `MetadataFetchError` instead of a bare `ValueError` on a malformed authority. This is the same untyped-`ValueError` escape closed in `dpop.py`, at the one `urlparse` call site left out of that audit — and this value is AS metadata, i.e. remote content, so an unwrapped `ValueError` turned a metadata rejection into an unhandled 500 in the MCP adapters, which catch only `AuthplaneError`. - `authplane-mcp`: the `mcp` dependency floor is now `>=1.28.1` (was `>=1.23.0`), pulling in the fix for [PYSEC-2026-3483](https://osv.dev/vulnerability/PYSEC-2026-3483), which affects `mcp <=1.28.0`. - `authplane-fastmcp`: now declares a direct `mcp>=1.28.1,<2` dependency. The adapter imports the top-level `mcp` package directly (e.g. `mcp.shared.exceptions`, `mcp.types`), so the PYSEC-2026-3483 floor must be pinned here explicitly — the transitive `fastmcp>=3.2,<4` dependency does not guarantee it. ### Fixed +- `build_prm_url` and `build_metadata_url` no longer strip slashes from the *front* of the identifier's path. `str.strip("/")` removed them from both ends, so a doubled leading slash (`//mcp`) lost a segment and derived the same well-known URL as `/mcp` — two distinct identifiers collapsing onto one document. RFC 9728 §3.1 and RFC 8414 §3.1 speak only of the *terminating* slash. +- `authplane-mcp`: `install_request_context` no longer accesses `mcp.sse_app` unguarded. SSE is not on the streamable-HTTP path, so a future `mcp` 1.x that drops the attribute would have taken down servers that never touch SSE. +- `authplane-fastmcp`, `authplane-mcp`: the elicitation-id resolver raises `RuntimeError` rather than `ImportError` when called lazily — at that point the package imported fine and the failure is a runtime schema mismatch. The import-time call site translates it back to `ImportError`, which is the right shape there. +- Docs: the core user guide's revocation section had lost the note that `fail_closed` is a no-op without a `revocation_checker`, and that the SDK warns at resource construction when both are set that way. The behaviour never went away. +- `authplane-mcp`: `install_request_context(mcp)` now detects an `AuthplaneTokenVerifier` that carries no verbatim identifiers and warns, instead of silently skipping the PRM rewrite. A verifier built through the public `AuthplaneTokenVerifier(verifier)` constructor has no verbatim issuer/resource, so the previous `_token_verifier is None` check never fired for it and the served document kept advertising the slash-normalized identifiers this SDK's own byte-for-byte comparison rejects — the exact regression the warning was written to prevent, on the path where it did not fire. Conversely, a `FastMCP` with no auth configured no longer produces a spurious "the MCP SDK renamed the private attribute" warning; that wording is now reserved for `_token_verifier` being absent as an attribute. New `AuthplaneTokenVerifier.verbatim_identifiers()` exposes the pair without cross-module private attribute reads. +- `rewrite_prm_routes_verbatim` accepts an `Iterable[BaseRoute]` (was `MutableSequence[BaseRoute]`). It only iterates and mutates each route's `app`; the invariant type meant the fastmcp call site type-checked only because upstream's `get_routes` erases to `Any`. +- `authplane-fastmcp`, `authplane-mcp`: the verbatim PRM rewrite now applies to a single route — the one whose path is the RFC 9728 §3.1 derivation of the configured resource, compared up to a trailing slash — instead of every route under `/.well-known/oauth-protected-resource`. An application advertising more than one resource previously had every document's `resource` clobbered to the single configured value. Selecting by path rather than by the served value keeps the rewrite covering every normalization the URL layer can apply, not just a trailing slash. If routes exist under the well-known prefix but none is that derivation, a `RuntimeWarning` is emitted: the rewrite did nothing, and the served document keeps the slash-normalized identifiers the byte-for-byte comparison rejects. +- `_redact_authority` no longer raises while building an error message. `urllib`'s `ParseResult.port` raises `ValueError` on a malformed authority (e.g. `https://h:abc/`) — precisely the kind of input that reaches these guards — so the caller saw "Port could not be cast to integer value" instead of the RFC citation the guard was written to produce. - `authplane-fastmcp`, `authplane-mcp`: the Protected Resource Metadata now advertises the configured issuer (`authorization_servers`) and `resource` byte-for-byte. The adapters serve the PRM through upstream MCP's `pydantic.AnyHttpUrl` fields, which normalize an empty-path authority with a trailing slash (`https://auth.example.com` → `https://auth.example.com/`); after the core SDK began comparing these identifiers verbatim (RFC 8414 §3.3, RFC 9728 §3.3), a client that followed the advertised value literally was rejected by the strict comparison and tokens minted for the advertised `resource` failed the `aud` check. The served document is now rewritten so both identifiers match the configured strings, leaving every other PRM field untouched. `authplane-fastmcp` applies this automatically; `authplane-mcp` applies it inside `install_request_context(mcp)`, so call that after constructing `FastMCP`. ### Changed +- **BREAKING (pre-1.0)** **Behaviour change at construction.** A fragment-bearing resource identifier is now rejected with `InvalidResourceError` at **every** construction path — `AuthplaneClient.resource(...)` and `AuthplaneResource(...)` alike — symmetrically with the issuer check in `create()`. A deployment that configured such an identifier and never called `prm_url()` used to start normally and now raises at `client.resource(...)` or at `AuthplaneResource(...)`. **Migration:** remove the fragment — RFC 8707 §2 forbids one in a resource indicator. Previously the only gate lived in `build_prm_url`, whose production caller is `AuthplaneResource.prm_url()` — the method operators use to compose the `resource_metadata` parameter of an RFC 9728 challenge, i.e. inside a 401 response path. A misconfigured resource therefore surfaced as a 500 emitted from the failure path rather than as a startup error. The gate is authoritative in `AuthplaneResource.__init__`, which every construction path reaches, including direct construction of the package-root export; the factory keeps a call of its own so the traceback points at the line the operator wrote, and the check in `build_prm_url` remains as a defensive backstop. - **BREAKING (pre-1.0)** `authplane-mcp`: the supported `mcp` range is now `>=1.28.1, <2.0.0` (was `>=1.23.0, <1.28.0`). The adapter still targets the mcp 1.x server API (`mcp.server.fastmcp.FastMCP`) and the camelCase URL-elicitation field (`ElicitRequestURLParams(elicitationId=...)`), which are the current 1.x shape. As a belt-and-braces measure the adapter no longer hard-codes the field spelling: it resolves the elicitation-id field name from the model's own schema, so a rename within 1.x is picked up automatically. The upper bound excludes mcp 2.0, which removes `mcp.server.fastmcp` and renames the elicitation field to snake_case `elicitation_id`. **Migration:** projects on `mcp <1.28.1` must upgrade to at least `1.28.1`; projects on `mcp 2.0` are not yet supported by this adapter — track the mcp 2.0 port separately. -- Issuer identifiers are now stored and compared byte-for-byte (RFC 9068 `iss`, RFC 8414 §3.3). The configured issuer is no longer trailing-slash-stripped before storage, and the AS-metadata issuer comparison no longer strips either side — a metadata document whose `issuer` differs from the configured issuer only by a trailing slash is now correctly rejected. This fixes an outage for authorization servers whose issuer ends in `/`: such an AS mints tokens whose `iss` keeps the slash, and the SDK was comparing them against the stripped form, rejecting every token. Building `.well-known` discovery URLs still strips the terminating slash (RFC 8414/9728 §3.1) — that is derivation, not identity, and is unchanged. `build_prm_url` now also preserves the resource's query component in the derived Protected Resource Metadata URL (RFC 9728 §3.1), while a fragment-bearing resource — for which `build_prm_url` and the resource `prm_url()` previously returned a (fragment-stripped) URL — is now rejected with a `ValueError` (RFC 8707 §2 forbids a fragment in a resource indicator). A query-bearing **or fragment-bearing** issuer (RFC 8414 §2 forbids both a query and a fragment component in the issuer identifier) is now rejected at `AuthplaneClient.create()` with a clear `ValueError` instead of being silently stripped and later surfacing as a confusing "issuer mismatch". **Migration:** If your configured issuer differs from your authorization server's actual identifier by a trailing slash, correct the config — the SDK no longer silently reconciles them. If your configured issuer carries a query or fragment component, remove it. If your resource identifier carries a fragment component, remove it — `build_prm_url` / `prm_url()` now raise instead of returning a fragment-stripped URL. +- Issuer identifiers are now stored and compared byte-for-byte (RFC 9068 `iss`, RFC 8414 §3.3). The configured issuer is no longer trailing-slash-stripped before storage, and the AS-metadata issuer comparison no longer strips either side — a metadata document whose `issuer` differs from the configured issuer only by a trailing slash is now correctly rejected. This fixes an outage for authorization servers whose issuer ends in `/`: such an AS mints tokens whose `iss` keeps the slash, and the SDK was comparing them against the stripped form, rejecting every token. Building `.well-known` discovery URLs still strips the terminating slash (RFC 8414/9728 §3.1) — that is derivation, not identity, and is unchanged. `build_prm_url` now also preserves the resource's query component in the derived Protected Resource Metadata URL (RFC 9728 §3.1), while a fragment-bearing resource — for which `build_prm_url` and the resource `prm_url()` previously returned a (fragment-stripped) URL — is now rejected with an `InvalidResourceError` (RFC 8707 §2 forbids a fragment in a resource indicator), and is rejected at resource construction before either is reached (see the "Behaviour change at construction" entry under **Changed**). A query-bearing **or fragment-bearing** issuer (RFC 8414 §2 forbids both a query and a fragment component in the issuer identifier) is now rejected at `AuthplaneClient.create()` with a clear `InvalidIssuerError` instead of being silently stripped and later surfacing as a confusing "issuer mismatch". **Migration:** If your configured issuer differs from your authorization server's actual identifier by a trailing slash, correct the config — the SDK no longer silently reconciles them. If your configured issuer carries a query or fragment component, remove it. If your resource identifier carries a fragment component, remove it — `AuthplaneClient.resource(...)` and `AuthplaneResource(...)` now raise at construction, i.e. at startup rather than from the first `prm_url()` call; `build_prm_url` / `prm_url()` also raise instead of returning a fragment-stripped URL, but they are no longer where you will first see it. Details in the "Behaviour change at construction" entry under **Changed**. ## [0.3.0] - 2026-07-21 diff --git a/conformance-tests/README.md b/conformance-tests/README.md index a0b6b97..20accc8 100644 --- a/conformance-tests/README.md +++ b/conformance-tests/README.md @@ -23,35 +23,70 @@ Tests can carry optional coverage metadata to flag partial coverage or known gap ```python @pytest.mark.conformance( - "rfc9449-dpop-proof-jwk-must-not-include-private-key-material", + "", level="partial", gaps=["expected.error_hint"], - note="Python rejects the proof but does not expose a stable diagnostic.", + note="", ) async def test_...(...): ... ``` +The case id is a placeholder on purpose: naming a real one here would claim +coverage metadata that the marker on the actual test may not carry. + | Parameter | Default | Description | |-----------|---------|-------------| -| `level` | `"full"` | `"full"` or `"partial"` — how closely the test matches the catalog spec | -| `gaps` | `[]` | List of expected catalog fields not covered by this test | -| `note` | `""` | Free-text explanation (appears in both JSON and Markdown reports) | +| `level` | `"full"` | `"full"` or `"partial"` — how closely the test matches the catalog spec. Always reaches `conformance-report.md`. | +| `gaps` | `[]` | Catalog *field paths* the test does not reach (`use_case`, `expected.error_hint`, …). Reaches `conformance-report.md` only alongside a `note`; always reaches `conformance-report.json`. | +| `note` | `""` | Free-text prose. Gates the Coverage Notes section — see below. | + +Keep them in that order — `gaps` names the fields, `note` carries the prose — +and **always write a `note` alongside `gaps`, because `note` is the gate**. In +`conftest.py`'s `_build_markdown_report`, a single filter (`:216`) selects the +cases with a truthy `note`, and it decides both whether the Coverage Notes +section is emitted at all (`:217`) and which cases it lists (`:219`) — and the +`Gaps:` line (`:224-225`) is emitted *inside* that section. So a `gaps`-only +marker states no reason anywhere in `conformance-report.md` and its `gaps` +survive in `conformance-report.json` alone; set a `note` and both render. + +`level` is not gated on `note`: it reaches the markdown either way, via the +Cases table's Coverage column (`:193-197`). + +Also keep `|` out of the `note` — it is interpolated into a markdown table cell +unescaped and will break the row. + +### Partial coverage vs. not implemented -### Not-yet-implemented tests +A test that exercises part of a case but not all of it is a `partial`: it still +runs and still asserts. Prefer that over an `xfail` wherever one is honest — an +`xfail` asserts nothing, so it cannot notice the day the gap closes, and it +reports as a skip while the report carries the case as not-run. -Tests for features that don't exist yet should still be present with the marker and a `pytest.xfail(...)` body that documents what is missing: +A case with nothing behind it at all should carry the marker with a +`pytest.xfail(...)` body documenting what is missing: ```python @pytest.mark.conformance( - "rfc9449-dpop-inbound-nonce-must-be-validated-when-required", - note="Not implemented: the SDK has no nonce generation, DPoP-Nonce challenge emission, or challenge-retry lifecycle for resource servers.", + "", + note="Not implemented: .", ) -async def test_rfc9449_dpop_inbound_nonce_must_be_validated_when_required(...): +async def test_(...): pytest.xfail("Not implemented: ...") ``` -These tests show up as `skipped` (with their `note` carried through) in both `conformance-report.json` and `conformance-report.md` — pytest classifies `xfail` outcomes as skips. Keeping the suite green for known gaps means CI never has to be ignored to merge; the gap is still visible in the report's per-case status and coverage notes. +The id is a placeholder deliberately: **the suite currently has no `xfail`s**, +so there is no live case to point at, and `test_catalog_alignment.py` requires +every catalog id to carry a marker — so any real id named here would be one +that does have a test behind it. (This section previously used +`rfc9449-dpop-inbound-nonce-must-be-validated-when-required` as its worked +example; that case now runs as a `partial`.) + +`xfail` tests show up as `skipped` — with their `note` carried through — in both +`conformance-report.json` and `conformance-report.md`, because pytest +classifies `xfail` outcomes as skips. Keeping the suite green for known gaps +means CI never has to be ignored to merge; the gap stays visible in the +report's per-case status and coverage notes. ## Running diff --git a/conformance-tests/conftest.py b/conformance-tests/conftest.py index ec9baf0..32c32e3 100644 --- a/conformance-tests/conftest.py +++ b/conformance-tests/conftest.py @@ -10,22 +10,48 @@ async def test_rfc9068_valid_at_jwt_must_verify(...): Optional coverage metadata can be added:: @pytest.mark.conformance( - "rfc9449-dpop-proof-jwk-must-not-include-private-key-material", + "", level="partial", gaps=["expected.error_hint"], - note="Python rejects the proof but does not expose a stable diagnostic.", + note="", ) async def test_...(...): ... -Tests that are not yet implemented should use ``pytest.xfail`` — these -appear as ``skipped`` in the generated report (pytest classifies ``xfail`` -outcomes as skips) so the suite stays green for known gaps while the gap -itself remains visible in the per-case status and the ``note`` field:: +Case ids in this docstring are placeholders on purpose: a real id here would +be claiming coverage metadata that the marker on the actual test may not +carry. - @pytest.mark.conformance("rfc9449-dpop-inbound-nonce-must-be-validated-when-required") +``gaps`` holds *catalog field paths* — the parts of the case the test does +not reach. ``note`` holds the prose. Always write a ``note`` alongside +``gaps``, because ``note`` is the gate: one filter selects the cases with a +truthy ``note``, and it decides both whether the Coverage Notes section is +emitted at all and which cases it lists — and the ``Gaps:`` line is emitted +*inside* that section. So a ``gaps``-only marker states no reason anywhere +in ``conformance-report.md`` and its ``gaps`` survive in +``conformance-report.json`` alone; set a ``note`` and both render. ``level`` +is not gated on ``note`` — it reaches the markdown either way, via the Cases +table's Coverage column. + +A test that exercises part of a case but not all of it is a ``partial`` — it +still runs and still asserts:: + + @pytest.mark.conformance( + "", + level="partial", + gaps=["use_case"], + note="setup/stimulus/expected are covered; the lifecycle the use_case " + "narrates is not implemented and is not exercised here.", + ) async def test_...(...): - pytest.xfail("Not implemented: inbound nonce enforcement") + ... + +A case with nothing behind it at all should use ``pytest.xfail``, which the +report records as ``skipped`` (pytest classifies ``xfail`` outcomes as skips) +so the suite stays green while the per-case status keeps the gap visible. +Prefer a running ``partial`` where one is honest: an xfail asserts nothing, +so it cannot notice the day the gap closes. The suite currently has no +xfails. """ import json diff --git a/conformance-tests/test_jwt_and_dpop_conformance.py b/conformance-tests/test_jwt_and_dpop_conformance.py index 59e63ee..3bab28d 100644 --- a/conformance-tests/test_jwt_and_dpop_conformance.py +++ b/conformance-tests/test_jwt_and_dpop_conformance.py @@ -1077,24 +1077,76 @@ async def test_rfc9449_dpop_replay_store_must_evict_expired_entries() -> None: @pytest.mark.conformance( "rfc9449-dpop-inbound-nonce-must-be-validated-when-required", - note="Not implemented: the SDK has no nonce generation, DPoP-Nonce challenge emission, or challenge-retry lifecycle for resource servers.", + level="partial", + gaps=["use_case"], + note=( + "setup/stimulus/expected are covered: the verifier enforces a nonce policy " + "when one is supplied. The use_case narrative is not: the SDK issues no " + "nonce, emits no 401 + DPoP-Nonce challenge, and expected_nonce is not " + "reachable from AuthplaneResource.verify() or the MCP adapters, only from " + "verify_dpop_proof. That lifecycle is tracked separately." + ), ) async def test_rfc9449_dpop_inbound_nonce_must_be_validated_when_required( jwks_keypair: dict[str, Any], ) -> None: - """Full resource-server nonce challenge-retry flow: - 1. Client sends proof without nonce. - 2. Resource server responds 401 + DPoP-Nonce: . - 3. Client retries with the issued nonce in the proof. - 4. Resource server verifies the nonce matches and accepts. - - The SDK must own the nonce lifecycle: generation, DPoP-Nonce header - emission on rejection, and validation on retry. None of this is - currently implemented.""" - pytest.xfail( - "Not implemented: SDK lacks nonce generation, DPoP-Nonce challenge " - "emission, and the challenge-retry lifecycle for resource servers." + """A configured nonce policy must reject a proof that carries the wrong + nonce, and one that omits the claim entirely. + + The catalog stimulus is "verify_dpop_proof with nonce policy" — setup + supplies ``expected_nonce: server-nonce-abc`` against a proof claiming + ``nonce: wrong-nonce``, expecting rejection with hint "nonce". Both arms + below match ts-sdk's case for the same id. This is the RFC 9449 §9 + resource-server nonce, not the §8 AS-provided one. + + This was previously ``pytest.xfail``ed on the grounds that the SDK owns no + nonce lifecycle. It does not — but the catalog case does not ask for one, + and the primitive it does ask for has been present in ``verify_dpop_proof`` + all along. The xfail reported as a skip, so the suite stayed green while + the report carried the case as not-run.""" + provider = DPoPProvider( + DPoPKeyMaterial.from_pem(jwks_keypair["private_key"], algorithm="ES256") + ) + url = "https://api.example.com/resource" + + wrong_nonce_proof = provider.build_proof("GET", url, access_token="tok", nonce="wrong-nonce") + with pytest.raises(InvalidDPoPProofError, match="nonce mismatch"): + await verify_dpop_proof( + wrong_nonce_proof, + method="GET", + url=url, + replay_store=MemoryReplayStore(), + access_token="tok", + expected_jkt=provider.key_material.thumbprint, + expected_nonce="server-nonce-abc", + ) + + # RFC 9449 §9: an omitted nonce claim is as much a policy violation as a + # wrong one. The catalog's requirement_summary names both. + missing_nonce_proof = provider.build_proof("GET", url, access_token="tok") + with pytest.raises(InvalidDPoPProofError, match="nonce mismatch"): + await verify_dpop_proof( + missing_nonce_proof, + method="GET", + url=url, + replay_store=MemoryReplayStore(), + access_token="tok", + expected_jkt=provider.key_material.thumbprint, + expected_nonce="server-nonce-abc", + ) + + # The policy must not reject the honest case: the nonce the server issued. + matching_proof = provider.build_proof("GET", url, access_token="tok", nonce="server-nonce-abc") + verified = await verify_dpop_proof( + matching_proof, + method="GET", + url=url, + replay_store=MemoryReplayStore(), + access_token="tok", + expected_jkt=provider.key_material.thumbprint, + expected_nonce="server-nonce-abc", ) + assert verified.raw["nonce"] == "server-nonce-abc" @pytest.mark.conformance("rfc9728-well-known-path-must-derive-from-resource-uri") diff --git a/scripts/backport-fixes.sh b/scripts/backport-fixes.sh index ac18df7..2268bbf 100755 --- a/scripts/backport-fixes.sh +++ b/scripts/backport-fixes.sh @@ -1,9 +1,10 @@ #!/usr/bin/env bash set -euo pipefail -# Cherry-pick commits from a release/hotfix branch to a local backport -# branch off main (or another target). Does NOT push, create PRs, or -# touch remotes beyond `git fetch`. +# Cherry-pick commits from a release/hotfix branch — or from the tag that +# names them once the branch is gone — to a local backport branch off main +# (or another target). Does NOT push, create PRs, or touch remotes beyond +# `git ls-remote` and `git fetch`. # # Conflicts use git's native cherry-pick state machine — resolve, then # `git cherry-pick --continue` (or --skip / --abort). Re-running this @@ -16,22 +17,38 @@ set -euo pipefail usage() { cat <<'EOF' Usage: - backport-fixes.sh --from [--to ] [--branch ] + backport-fixes.sh --from [--to ] [--branch ] Options: - --from Source branch on origin (e.g. release/v0.6.0, - hotfix/v0.5.1). Do not include 'origin/'. Required. - --to Target branch on origin (default: main). + --from + Source branch OR tag on origin (e.g. release/v0.6.0, + hotfix/v0.5.1, v0.6.0). Do not include 'origin/'. + Required. After a release, release.yml has deleted + release/vX.Y.Z, so the tag is the only ref naming + those commits — which is what backport-fixes.yml + tells you to pass. A branch wins if a branch and a + tag share the name. + --to Target branch on origin (default: main). Branch only: + backport-fixes.yml opens a PR with --base, which + needs a branch that exists on the remote. --branch Name for the local backport branch (default: `backport/vX.Y.Z` derived from --from when it - matches release/vX.Y.Z or hotfix/vX.Y.Z; otherwise - `backport/`). + matches release/vX.Y.Z or hotfix/vX.Y.Z. Anything + else — a tag included — is flattened into + `backport/`, so --from v0.6.0 + gives `backport/v0.6.0`). -h, --help Show this help. Behavior: - 1. Fetches origin. - 2. Lists commits on origin/ that aren't already on origin/, - and commits that are already there (skipped). + 1. Validates and as ref names (git check-ref-format), then + asks origin what they name (git ls-remote) and fetches those two + refs — not the whole remote. The validation is what keeps a glob + out of the refspecs: `git ls-remote` would match `release/*` as a + pattern and the fetch would expand it, leaving a name that is not a + commit. + 2. Lists commits on the resolved ref — origin/ for a + branch, refs/tags/ for a tag — that aren't already on + origin/, and commits that are already there (skipped). 3. Creates the backport branch off origin/. 4. Runs `git cherry-pick -x` with the candidates, oldest-first. 5. On conflict: stops. Resolve, then `git cherry-pick --continue`. @@ -41,8 +58,9 @@ it (`git branch -D `) or pass `--branch ` to override. No push. No PR. The branch stays local; you decide what to do next. -Example: +Examples: backport-fixes.sh --from release/v0.6.0 + backport-fixes.sh --from v0.6.0 # after release.yml deleted it EOF } @@ -78,6 +96,52 @@ if [[ "$FROM" == "$TO" ]]; then exit 2 fi +# Both names reach `git ls-remote` and then a fetch refspec, and neither treats +# them as literals: ls-remote matches its argument as a glob, and `*` is legal +# in a refspec. So `--from 'release/*'` resolves against the remote, fetches +# wildcard-expanded, and leaves FROM_REF naming a pattern rather than a commit — +# which `git cherry` rejects and the run then reports as "Nothing to backport." +# with exit 0. That is a tooling failure presented to the operator as a fact +# about the refs, which is the class of error the resolver below exists to +# remove; through backport-fixes.yml it also fires the "all commits on +# origin/ are already present on origin/" notice about a ref that never +# resolved. Before the resolver landed the same input died at the fetch with +# `fatal: invalid refspec` and exit 128, so leaving it would be a regression. +# +# `git check-ref-format` is git's own refname(7) rule set, so this cannot drift +# from what git accepts: it rejects `*`, whitespace, `..`, control characters and +# the rest. Checking here rather than after resolution keeps the wildcard out of +# ls-remote, the fetch and `git checkout -b` alike. +if ! git check-ref-format "refs/heads/$FROM" 2>/dev/null; then + echo "error: --from is not a valid ref name: $FROM" >&2 + exit 2 +fi +if ! git check-ref-format "refs/heads/$TO" 2>/dev/null; then + echo "error: --to is not a valid ref name: $TO" >&2 + exit 2 +fi +# --branch is the third name that becomes a ref, and it is checked here for +# consistency rather than for safety. A bad value already fails: `git checkout -b` +# exits 128 with `fatal: '' is not a valid branch name`, before any +# cherry-pick, and there is nothing to inject because `-b` consumes the next word +# whatever it looks like (`--branch --track` fails as the branch name `--track`, +# not as an option). What the check buys is that all three ref-name inputs fail +# the same way — up front, exit 2, with the script's own message — instead of two +# of three doing that while the last dies on a raw git fatal after the fetches +# and the `git cherry`, one line below "Creating branch". The derived names need +# no check: they come from the already-validated $FROM through a substitution +# that only removes characters. +# +# One divergence, left alone on purpose: `refs/heads/--track` is a legal refname, +# so check-ref-format accepts it while `git checkout -b` refuses it. Matching that +# would mean hand-rolling a rule on top of git's, which is exactly the drift using +# git's own rule set avoids — and it costs nothing, since such a name still fails +# at the checkout, before any cherry-pick. +if [[ -n "$BRANCH_OVERRIDE" ]] && ! git check-ref-format "refs/heads/$BRANCH_OVERRIDE" 2>/dev/null; then + echo "error: --branch is not a valid ref name: $BRANCH_OVERRIDE" >&2 + exit 2 +fi + # Must be in a git repo if ! git rev-parse --git-dir >/dev/null 2>&1; then echo "error: not inside a git repository" >&2 @@ -99,21 +163,102 @@ if ! git diff --quiet || ! git diff --cached --quiet; then fi echo "Fetching origin..." -git fetch origin "$FROM" "$TO" --no-tags -if ! git rev-parse --verify "origin/$FROM" >/dev/null 2>&1; then - echo "error: origin/$FROM not found on remote" >&2 +# Fetch and resolve in one step, because the two have to agree about where a ref +# lands. A bare-name refspec — `git fetch origin v1.0.0` — writes FETCH_HEAD and +# nothing else: no refs/tags entry, no remote-tracking ref. Fetching that way and +# then looking under refs/tags only works when the tag happens to be there +# already, which is true of a clone made after the release and false for the +# maintainer this path exists for: someone whose last fetch predates the tag. +# +# An explicit destination refspec materialises it. Both refspecs below carry a +# leading `+`: the bare-name form they replaced still got its remote-tracking +# update through `remote.origin.fetch`, whose refspec is forced. Writing the +# destination out without the `+` silently drops that force, and a source branch +# that was force-pushed — routine during release prep, e.g. an amended release +# commit — stops fast-forwarding and fails a backport that used to work. +# +# Ask the remote what a name is before fetching it, instead of attempting a +# fetch and reading its failure as absence. `git ls-remote --exit-code` answers +# 0 (the ref is there), 2 (the remote answered and has no such ref) or 128 (the +# remote was unreachable, or refused us). Only 2 means "not found"; reporting +# 128 as a missing ref sends the operator after the ref when the ref is fine. +# +# The fetches below run without -q on purpose: -q suppresses the per-ref status +# table, which is where `! [rejected]` is written, so a fetch that fails after +# ls-remote said the ref was there would exit with no explanation at all. +remote_ref_exists() { + local rc=0 + git ls-remote --exit-code origin "$1" >/dev/null || rc=$? + case "$rc" in + 0) return 0 ;; + 2) return 1 ;; + # git has already described the failure on stderr; adding a guess about the + # ref on top of it would only mislead. + *) exit 1 ;; + esac +} + +# These assign to FROM_REF / TO_REF rather than echoing their result. Called as +# `$(...)`, the body would run in a subshell, where the `exit 1` above exits +# only that subshell and the caller carries on with an empty ref. +FROM_REF="" +TO_REF="" + +# --from accepts a branch or a tag. After a release, release.yml has deleted +# release/vX.Y.Z, so the tag is the only ref naming those commits. +fetch_source_ref() { + local name="$1" + if remote_ref_exists "refs/heads/$name"; then + git fetch origin "+refs/heads/$name:refs/remotes/origin/$name" || exit 1 + FROM_REF="origin/$name" + elif remote_ref_exists "refs/tags/$name"; then + # A re-cut tag (deleted on origin and re-pushed at a new commit) lands here. + # `+` overwrites the stale local tag, which otherwise keeps pointing at the + # superseded release and would backport the wrong commits. + git fetch origin "+refs/tags/$name:refs/tags/$name" || exit 1 + FROM_REF="refs/tags/$name" + else + return 1 + fi +} + +# --to is branch-only, deliberately. A tag would resolve and `git checkout -b` +# would even work, but backport-fixes.yml opens a PR with `--base "$TO"`, which +# needs a branch that exists on the remote. +fetch_target_ref() { + local name="$1" + if remote_ref_exists "refs/heads/$name"; then + git fetch origin "+refs/heads/$name:refs/remotes/origin/$name" || exit 1 + TO_REF="origin/$name" + else + return 1 + fi +} + +if ! fetch_source_ref "$FROM"; then + echo "error: $FROM not found on origin as a branch or a tag" >&2 exit 1 fi -if ! git rev-parse --verify "origin/$TO" >/dev/null 2>&1; then - echo "error: origin/$TO not found on remote" >&2 +if ! fetch_target_ref "$TO"; then + echo "error: $TO not found on origin as a branch (--to must be a branch)" >&2 exit 1 fi # `git cherry -v ` prints one line per commit: # + -> not on upstream (candidate for backport) # - -> already on upstream via patch-ID match -cherry_out="$(git cherry -v "origin/$TO" "origin/$FROM" || true)" +# +# Checked, not `|| true`. `git cherry` exits 0 whether or not there are +# candidates, so a non-zero here means it could not do the comparison at all — +# and swallowing that re-merges "genuinely nothing to backport" with "something +# went wrong", which is the exact distinction the resolver above exists to keep. +# `if !` rather than a bare call so the message names both refs; a bare call +# under `set -e` would exit with git's line alone. +if ! cherry_out="$(git cherry -v "$TO_REF" "$FROM_REF")"; then + echo "error: could not compare $FROM_REF against $TO_REF" >&2 + exit 1 +fi candidates_pretty="$(echo "$cherry_out" | awk '$1 == "+" { sub(/^\+ /, ""); print }')" already_pretty="$(echo "$cherry_out" | awk '$1 == "-" { sub(/^- /, ""); print }')" @@ -125,7 +270,7 @@ n_already=0 [[ -n "$already_pretty" ]] && n_already=$(echo "$already_pretty" | wc -l | tr -d ' ') echo -echo "=== Commits on origin/$FROM not yet on origin/$TO ($n_candidates) ===" +echo "=== Commits on $FROM_REF not yet on $TO_REF ($n_candidates) ===" if [[ "$n_candidates" -gt 0 ]]; then echo "$candidates_pretty" else @@ -134,7 +279,7 @@ fi if [[ "$n_already" -gt 0 ]]; then echo - echo "=== Already on origin/$TO, excluded ($n_already) ===" + echo "=== Already on $TO_REF, excluded ($n_already) ===" echo "$already_pretty" fi @@ -160,8 +305,8 @@ if git show-ref --verify --quiet "refs/heads/$branch"; then fi echo -echo "Creating branch $branch off origin/$TO..." -git checkout -b "$branch" "origin/$TO" +echo "Creating branch $branch off $TO_REF..." +git checkout -b "$branch" "$TO_REF" echo echo "Cherry-picking $n_candidates commit(s) with -x, oldest first..." diff --git a/scripts/backport-fixes.test.sh b/scripts/backport-fixes.test.sh new file mode 100755 index 0000000..8c143b6 --- /dev/null +++ b/scripts/backport-fixes.test.sh @@ -0,0 +1,504 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Tests for backport-fixes.sh --from ref resolution. +# +# The script has no other test, and the case it regressed on is not one a reader +# would guess: --from accepts a branch OR a tag, and only the branch form has a +# remote-tracking ref. After a release, release.yml deletes the release branch, +# so the tag is the only ref naming those commits — the tag form is the one +# backport-fixes.yml's input description and release.yml's own run summary both +# tell you to use. +# +# Each case builds a throwaway origin + clone in a temp dir, so nothing here +# touches the real repository or the network. +# +# Run: scripts/backport-fixes.test.sh + +SCRIPT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/backport-fixes.sh" +failures=0 + +# Cut the developer's own git config out of the fixtures. Every `git` call below +# — in the suite and inside the script under test — otherwise reads +# ~/.gitconfig, and one maintainer setting is enough to take the whole run down: +# with `commit.gpgsign=true` set globally, make_fixture dies at its first commit +# ("error: cannot run gpg: No such file or directory"), `set -e` aborts before +# any case reports, and the run ends with no summary and exit 128. CI runners +# carry no such config, so it is a local-only failure that reads as a broken +# suite. +# +# GIT_CONFIG_NOSYSTEM, not GIT_CONFIG_SYSTEM=/dev/null. The latter only redirects +# the /etc/gitconfig slot, and Apple Git reads a second system config out of its +# own install tree that the redirect does not cover — so with +# GIT_CONFIG_SYSTEM=/dev/null exported, `git config --get init.defaultBranch` +# still answers `main` from +# /Applications/Xcode.app/.../usr/share/git-core/gitconfig, and the export is a +# silent no-op. GIT_CONFIG_NOSYSTEM=1 is honored by every git, and leaves +# init.defaultBranch unset. The global half is the half that matters here +# (commit.gpgsign lives in ~/.gitconfig) and worked either way, but a guard that +# does nothing on the machine most likely to need it is not a guard. +# +# This does NOT subsume the explicit `-b main` below. With system and global +# config suppressed init.defaultBranch is unset, and `git init` then falls back +# to its built-in default — which is still `master`. Verified; the two guards are +# independent. Under the old spelling that was not reproducible on macOS: Apple's +# un-suppressed system config sets init.defaultBranch=main, so `git init` looked +# like it defaulted to `main` on its own. +export GIT_CONFIG_GLOBAL=/dev/null +export GIT_CONFIG_NOSYSTEM=1 + +# `git log` goes into a variable, never into `grep -q`. Under `set -o pipefail` +# the pipe form hands grep the pipeline's exit status: grep -q stops at its first +# match and closes the read end, git takes SIGPIPE on its next write, and 141 — +# not grep's 0 — is what the caller tests. It bites only once the log outgrows +# the pipe buffer, which is why it does not show up here: verified on a +# 4000-commit history, `git log --oneline | grep -q ` exits 141 +# under bash with git 2.46.2 and with Apple Git 2.50.1, while the one-line +# `main..HEAD` ranges these cases assert on exit 0, because git's whole output +# fits the buffer and it finishes writing before grep exits. That makes the pipe +# form safe by the size of the log it happens to be fed — not a property a +# fixture should have to keep true. Command substitution has no reader to close. + +pass() { printf ' ok %s\n' "$1"; } +fail() { printf ' FAIL %s\n %s\n' "$1" "$2"; failures=$((failures + 1)); } + +# Builds: origin with `main`, a v1.0.0 tag, and one commit after the tag that is +# only reachable from the tag's branch — the shape of a fix landed on a release +# branch during release prep. +make_fixture() { + local root="$1" + # -b main explicitly: the default branch name comes from init.defaultBranch, + # which differs between a developer machine and a CI runner. Without it the + # fixture builds `master` somewhere and every checkout of `main` fails. + git init -q -b main "$root/origin" + git -C "$root/origin" config user.email t@example.com + git -C "$root/origin" config user.name "Test" + echo base > "$root/origin/f.txt" + git -C "$root/origin" add -A + git -C "$root/origin" commit -qm "base" + + # Clone before the tag exists. A clone made afterwards fetches every tag, which + # leaves refs/tags/v1.0.0 populated locally and hides whether the script's own + # fetch materialises it — the exact blind spot that let a broken resolver pass. + # The real scenario is a maintainer who last fetched before the release. + git clone -q "$root/origin" "$root/clone" + + git -C "$root/origin" checkout -q -b release/v1.0.0 + echo fix > "$root/origin/f.txt" + git -C "$root/origin" commit -qam "fix: something landed on the release branch" + # Annotated, matching release.yml's `git tag -a`. A lightweight tag resolves + # the same way here, but the fixture should produce what the flow it models + # produces. + git -C "$root/origin" tag -a v1.0.0 -m "v1.0.0" + git -C "$root/origin" checkout -q main + git -C "$root/clone" config user.email t@example.com + git -C "$root/clone" config user.name "Test" +} + +# --- a branch as --from keeps working ----------------------------------------- +t_branch() { + local root; root="$(mktemp -d)"; trap 'rm -rf "$root"' RETURN + make_fixture "$root" + local out picked + if out="$(cd "$root/clone" && "$SCRIPT" --from release/v1.0.0 --to main 2>&1)"; then + picked="$(git -C "$root/clone" log --oneline main..HEAD)" + if [[ "$picked" == *"landed on the release branch"* ]]; then + pass "a branch as --from cherry-picks its commits" + else + fail "a branch as --from cherry-picks its commits" "branch created but the commit is missing" + fi + else + fail "a branch as --from cherry-picks its commits" "script exited non-zero: ${out##*$'\n'}" + fi +} + +# --- a tag as --from: the regression ------------------------------------------ +# Before the fix this exited 1 with "origin/v1.0.0 not found on remote", because +# origin/ resolves only against refs/remotes and a tag has none. +t_tag() { + local root; root="$(mktemp -d)"; trap 'rm -rf "$root"' RETURN + make_fixture "$root" + local out picked + if out="$(cd "$root/clone" && "$SCRIPT" --from v1.0.0 --to main 2>&1)"; then + picked="$(git -C "$root/clone" log --oneline main..HEAD)" + if [[ "$picked" == *"landed on the release branch"* ]]; then + pass "a tag as --from cherry-picks its commits" + else + fail "a tag as --from cherry-picks its commits" "branch created but the commit is missing" + fi + else + fail "a tag as --from cherry-picks its commits" "script exited non-zero: ${out##*$'\n'}" + fi +} + +# --- an unknown ref fails, and leaves nothing behind --------------------------- +# It fails at the resolver, which is what the assertion below pins: `git +# ls-remote --exit-code` exits 2 for a name the remote does not have, both arms +# of fetch_source_ref return non-zero, and the script prints its own message. +# What matters is the contract: non-zero, and no branch created. +t_unknown() { + local root; root="$(mktemp -d)"; trap 'rm -rf "$root"' RETURN + make_fixture "$root" + local out + if out="$(cd "$root/clone" && "$SCRIPT" --from does-not-exist --to main 2>&1)"; then + fail "an unknown --from fails" "script exited zero" + elif [[ -n "$(git -C "$root/clone" branch --list 'backport/*')" ]]; then + fail "an unknown --from fails" "it created a backport branch anyway" + elif ! grep -q "as a branch or a tag" <<<"$out"; then + fail "an unknown --from fails" "reached the fetch, not the resolver: ${out##*$'\n'}" + else + pass "an unknown --from fails at the resolver, creating no branch" + fi +} + +# --- --to is branch-only ------------------------------------------------------ +# A tag resolves and `git checkout -b` would even work, but backport-fixes.yml +# opens a PR with `--base "$TO"`, which needs a branch on the remote. Rejecting +# it here beats failing after the cherry-picks have run. +t_to_rejects_a_tag() { + local root; root="$(mktemp -d)"; trap 'rm -rf "$root"' RETURN + make_fixture "$root" + local out + if out="$(cd "$root/clone" && "$SCRIPT" --from main --to v1.0.0 2>&1)"; then + fail "--to rejects a tag" "script exited zero" + elif grep -q "must be a branch" <<<"$out"; then + pass "--to rejects a tag, naming the reason" + else + fail "--to rejects a tag" "unexpected message: ${out##*$'\n'}" + fi +} + +# --- a force-pushed source branch still backports ------------------------------ +# What the `+` on the refspecs is for. Without it the fetch is a non-fast-forward +# rejection, and the resolver reported that as "not found on origin as a branch +# or a tag" — sending the maintainer after a ref that is present and current. +# Amending a release commit during release prep is routine, and the bare-name +# form this replaced handled it, so losing it would be a regression against main. +t_force_pushed_source() { + local root; root="$(mktemp -d)"; trap 'rm -rf "$root"' RETURN + make_fixture "$root" + + # Seed the remote-tracking ref at the pre-amend commit: the state of a + # maintainer who last fetched before the force-push. Without this the clone + # has no origin/release/v1.0.0 at all and any fetch is trivially a + # fast-forward, which is how a missing `+` would go unnoticed. + git -C "$root/clone" fetch -q origin \ + '+refs/heads/release/v1.0.0:refs/remotes/origin/release/v1.0.0' + + git -C "$root/origin" checkout -q release/v1.0.0 + echo amended > "$root/origin/f.txt" + git -C "$root/origin" commit -q --amend -am "fix: something landed on the release branch (amended)" + git -C "$root/origin" checkout -q main + + local out picked + if out="$(cd "$root/clone" && "$SCRIPT" --from release/v1.0.0 --to main 2>&1)"; then + picked="$(git -C "$root/clone" log --oneline main..HEAD)" + if [[ "$picked" == *"(amended)"* ]]; then + pass "a force-pushed source branch backports the rewritten commit" + else + fail "a force-pushed source branch backports the rewritten commit" \ + "it backported the pre-amend commit" + fi + else + fail "a force-pushed source branch backports the rewritten commit" \ + "script exited non-zero: ${out##*$'\n'}" + fi +} + +# --- a branch wins when a branch and a tag share the name ---------------------- +# The arm order in fetch_source_ref decides this and --help now states it, so it +# needs a case: a repo that tags v1.0.0 and later cuts a branch of the same name +# would otherwise silently change which commits get backported. +t_branch_beats_tag() { + local root; root="$(mktemp -d)"; trap 'rm -rf "$root"' RETURN + make_fixture "$root" + + # A branch literally named v1.0.0, carrying a commit the tag does not. + git -C "$root/origin" checkout -q -b v1.0.0 main + echo from-branch > "$root/origin/f.txt" + git -C "$root/origin" commit -qam "fix: reached through the branch" + git -C "$root/origin" checkout -q main + + local out picked + if out="$(cd "$root/clone" && "$SCRIPT" --from v1.0.0 --to main 2>&1)"; then + picked="$(git -C "$root/clone" log --oneline main..HEAD)" + if [[ "$picked" == *"reached through the branch"* ]]; then + pass "a branch wins over a tag of the same name" + else + fail "a branch wins over a tag of the same name" "it resolved the tag instead" + fi + else + fail "a branch wins over a tag of the same name" "script exited non-zero: ${out##*$'\n'}" + fi +} + +# --- an unreachable remote is not a missing ref -------------------------------- +# `git ls-remote --exit-code` answers 2 for "asked, and the remote has no such +# ref" and 128 for "could not ask" — unreachable, or refused. The 128 arm is the +# one that separates them, and without a case a regression in it is invisible: +# the suite passes while a network failure is reported as a missing ref, sending +# the operator after a ref that is fine. +# +# It asserts the absence of the resolver's message rather than the presence of +# git's, because the wording of `fatal: Could not read from remote repository.` +# is git's to change. What must hold is that the script does not add a claim +# about the ref on top of it. +t_unreachable_remote() { + local root; root="$(mktemp -d)"; trap 'rm -rf "$root"' RETURN + make_fixture "$root" + git -C "$root/clone" remote set-url origin /nonexistent + + local out + if out="$(cd "$root/clone" && "$SCRIPT" --from release/v1.0.0 --to main 2>&1)"; then + fail "an unreachable remote is not reported as a missing ref" "script exited zero" + elif grep -q "not found on origin" <<<"$out"; then + fail "an unreachable remote is not reported as a missing ref" \ + "the network failure was reported as a missing ref" + elif [[ -n "$(git -C "$root/clone" branch --list 'backport/*')" ]]; then + fail "an unreachable remote is not reported as a missing ref" "it created a backport branch anyway" + else + pass "an unreachable remote is not reported as a missing ref" + fi +} + +# --- a glob as --from is rejected, not resolved -------------------------------- +# Neither `git ls-remote` nor a fetch refspec treats these names as literals: +# ls-remote matches its argument as a glob and `*` is legal in a refspec. So +# `--from 'release/*'` used to resolve, fetch wildcard-expanded, and hand +# `git cherry` a name that is not a commit — reported as "Nothing to backport." +# with exit 0. +# +# That is worse than a bare no-op: through backport-fixes.yml it fires +# "::notice::Nothing to backport — all commits on origin/ are already +# present on origin/", an actively false statement about a ref that never +# resolved. And it is a regression, not just a gap — before the resolver landed, +# the bare-name fetch died with `fatal: invalid refspec` and exit 128. +# +# Asserted on both arms, because they fail differently: the branch arm expands +# the wildcard against refs/heads, the tag arm against refs/tags. +t_glob_from_rejected() { + local root; root="$(mktemp -d)"; trap 'rm -rf "$root"' RETURN + make_fixture "$root" + + local pattern out + for pattern in 'release/*' 'v1.0.*'; do + if out="$(cd "$root/clone" && "$SCRIPT" --from "$pattern" --to main 2>&1)"; then + fail "a glob as --from is rejected ($pattern)" "script exited zero" + elif grep -q "Nothing to backport" <<<"$out"; then + fail "a glob as --from is rejected ($pattern)" \ + "the wildcard resolved and the run reported a fact about the refs" + elif ! grep -q "not a valid ref name" <<<"$out"; then + fail "a glob as --from is rejected ($pattern)" \ + "rejected, but not by the name check: ${out##*$'\n'}" + elif [[ -n "$(git -C "$root/clone" branch --list 'backport/*')" ]]; then + fail "a glob as --from is rejected ($pattern)" "it created a backport branch anyway" + else + pass "a glob as --from is rejected before it reaches a refspec ($pattern)" + fi + done +} + +# --- --branch is validated like --from and --to -------------------------------- +# Consistency, not safety: a bad --branch already failed, at `git checkout -b` +# with rc=128 and git's own `fatal: '' is not a valid branch name`, after the +# fetches and the `git cherry` and one line below "Creating branch". Nothing was +# injectable — `-b` consumes the next word, so `--branch --track` is just a +# branch named `--track`. What is pinned here is that all three ref-name inputs +# now fail the same way: exit 2, the script's own message, and before the script +# touches the remote. +# +# That last claim is proved positively, not by asserting the absence of +# "Fetching origin". An absence assertion decays silently — reword that progress +# line and it keeps passing while proving nothing. Instead origin is pointed at a +# path that does not exist for the rejection sub-cases: exiting 2 with the +# script's own message is then only possible if the remote was never contacted, +# because reaching it cannot succeed. The control immediately after is what makes +# that discriminating rather than vacuous — same unreachable origin, a valid +# --branch, which must get past the guard and die at the remote instead. +# +# Not asserted, deliberately: a leading dash. `refs/heads/--track` is a legal +# refname, so `git check-ref-format` accepts it (rc=0) while `git checkout -b` +# refuses it (rc=128, "not a valid branch name") — the one place the two rules +# diverge, over 9 names differentially tested. Closing it would mean hand-rolling +# a rule on top of git's, which is the drift the check-ref-format approach exists +# to avoid, and there is nothing to close: `-b` consumes the next word, so +# `--branch --track` is a branch named `--track`, not an option, and it still +# fails before any cherry-pick. +# +# The second half is the half that keeps the guard honest: a legitimate override +# must still work, or "validated" would just mean "rejected". +t_branch_override_rejects_a_bad_name() { + local root; root="$(mktemp -d)"; trap 'rm -rf "$root"' RETURN + make_fixture "$root" + + # Nothing reachable at origin: any success below is proof of ordering, not luck. + git -C "$root/clone" remote set-url origin /nonexistent-remote + + local name out picked + for name in 'foo*' 'foo bar' 'foo..bar'; do + if out="$(cd "$root/clone" && "$SCRIPT" --from release/v1.0.0 --to main --branch "$name" 2>&1)"; then + fail "a bad --branch is rejected ($name)" "script exited zero" + elif ! grep -q "not a valid ref name" <<<"$out"; then + fail "a bad --branch is rejected ($name)" \ + "rejected, but not by the name check: ${out##*$'\n'}" + else + pass "a bad --branch is rejected up front, before the remote ($name)" + fi + done + + # The control for the three above: a name the guard accepts must get further + # and die at the unreachable remote. Without this, a script that rejected every + # --branch — or one that never reached the network at all — would satisfy them. + if out="$(cd "$root/clone" && "$SCRIPT" --from release/v1.0.0 --to main --branch backport/my-fix 2>&1)"; then + fail "a valid --branch passes the guard and reaches the remote" \ + "script exited zero against an unreachable origin" + elif grep -q "not a valid ref name" <<<"$out"; then + fail "a valid --branch passes the guard and reaches the remote" \ + "the name check rejected a legitimate override" + else + pass "a valid --branch passes the guard and reaches the remote" + fi + + git -C "$root/clone" remote set-url origin "$root/origin" + + if out="$(cd "$root/clone" && "$SCRIPT" --from release/v1.0.0 --to main --branch backport/my-fix 2>&1)"; then + picked="$(git -C "$root/clone" log --oneline main..HEAD)" + if [[ "$(git -C "$root/clone" rev-parse --abbrev-ref HEAD)" != "backport/my-fix" ]]; then + fail "a legitimate --branch is still honored" "the override did not name the branch" + elif [[ "$picked" != *"landed on the release branch"* ]]; then + fail "a legitimate --branch is still honored" "branch created but the commit is missing" + else + pass "a legitimate --branch override is still honored" + fi + else + fail "a legitimate --branch is still honored" "script exited non-zero: ${out##*$'\n'}" + fi +} + +# --- a re-cut tag backports the new commits ------------------------------------ +# What the `+` on the tag refspec is for, and the one behavior the resolver's own +# comment claims that nothing pinned. release.yml deletes and re-pushes a tag +# when a release is re-cut; without the force the local tag keeps pointing at the +# superseded commit, and the fetch is rejected with "would clobber existing tag". +t_recut_tag() { + local root; root="$(mktemp -d)"; trap 'rm -rf "$root"' RETURN + make_fixture "$root" + + # The state this exists for: a clone that already holds refs/tags/v1.0.0 at the + # first cut. Without this the local tag is absent and any fetch trivially + # succeeds, which is how a missing `+` would go unnoticed. + git -C "$root/clone" fetch -q origin '+refs/tags/v1.0.0:refs/tags/v1.0.0' + + git -C "$root/origin" checkout -q release/v1.0.0 + echo recut > "$root/origin/f.txt" + git -C "$root/origin" commit -q --amend -am "fix: RE-CUT release" + git -C "$root/origin" tag -d v1.0.0 >/dev/null + git -C "$root/origin" tag -a v1.0.0 -m "v1.0.0" + git -C "$root/origin" checkout -q main + + local out picked + if out="$(cd "$root/clone" && "$SCRIPT" --from v1.0.0 --to main 2>&1)"; then + picked="$(git -C "$root/clone" log --oneline main..HEAD)" + if [[ "$picked" == *"RE-CUT"* ]]; then + pass "a re-cut tag backports the re-cut commit" + else + fail "a re-cut tag backports the re-cut commit" "it backported the superseded commit" + fi + else + fail "a re-cut tag backports the re-cut commit" "script exited non-zero: ${out##*$'\n'}" + fi +} + +# --- a force-pushed target branch still backports ------------------------------ +# The third refspec. Same failure as t_force_pushed_source but on --to: without +# the `+`, a rewritten origin/main is a non-fast-forward rejection, and the +# resolver reports a present, current branch as "not found on origin". +t_force_pushed_target() { + local root; root="$(mktemp -d)"; trap 'rm -rf "$root"' RETURN + make_fixture "$root" + + # Land a commit on main, seed the clone's origin/main at it, then amend it on + # origin — so the next fetch is a non-fast-forward, which is what the `+` is + # for. Amending a commit above the base rather than the base itself keeps the + # two histories sharing a merge-base, so `git cherry` still names exactly the + # release commit; rewriting the root would leave them unrelated and make every + # commit a candidate. It touches g.txt, not f.txt, so the cherry-pick applies + # cleanly and a failure here means the refspec rather than a conflict. + git -C "$root/origin" checkout -q main + echo target-side > "$root/origin/g.txt" + git -C "$root/origin" add -A + git -C "$root/origin" commit -qm "chore: target-side change" + git -C "$root/clone" fetch -q origin '+refs/heads/main:refs/remotes/origin/main' + echo target-side-amended > "$root/origin/g.txt" + git -C "$root/origin" commit -q --amend -am "chore: target-side change (rewritten)" + + local out base_subject + if out="$(cd "$root/clone" && "$SCRIPT" --from release/v1.0.0 --to main 2>&1)"; then + # The commit the backport branch was cut from, asserted exactly rather than + # searched for in a log: this case is about which commit is the base, and + # HEAD~1 names it. (No pipe, per the note at the top of the file.) + base_subject="$(git -C "$root/clone" log -1 --format=%s HEAD~1)" + if [[ "$base_subject" == *"target-side change (rewritten)"* ]]; then + pass "a force-pushed --to branch is fetched and used as the base" + else + fail "a force-pushed --to branch is fetched and used as the base" \ + "the backport branch was cut off the stale origin/main" + fi + else + fail "a force-pushed --to branch is fetched and used as the base" \ + "script exited non-zero: ${out##*$'\n'}" + fi +} + +# --- a source with nothing new is a clean no-op -------------------------------- +# The one success path the fix touched: `git cherry ... || true` became `if !`, +# so that a cherry which could not run at all stops being reported as "Nothing to +# backport." That rewrite has to leave the legitimate empty result alone, and it +# does — `git cherry` exits 0 with no output — but nothing pinned it, and the +# mistake it invites is cheap to make and expensive to have: treating an empty +# result as a failed comparison turns every no-op backport into an error the +# operator has to go and disprove. +t_nothing_to_backport() { + local root; root="$(mktemp -d)"; trap 'rm -rf "$root"' RETURN + make_fixture "$root" + + # A branch that exists and resolves, carrying nothing main does not have — so + # `git cherry` runs successfully and prints nothing, which is the case the + # `if !` must not claim as an error. + git -C "$root/origin" branch nothing-new main + + local out + if out="$(cd "$root/clone" && "$SCRIPT" --from nothing-new --to main 2>&1)"; then + if ! grep -q "Nothing to backport" <<<"$out"; then + fail "a source with nothing new is a clean no-op" \ + "exited zero without reporting an empty backport: ${out##*$'\n'}" + elif [[ -n "$(git -C "$root/clone" branch --list 'backport/*')" ]]; then + fail "a source with nothing new is a clean no-op" "it created a backport branch anyway" + else + pass "a source with nothing new exits 0 with 'Nothing to backport', creating no branch" + fi + else + fail "a source with nothing new is a clean no-op" \ + "an empty result was reported as a failure: ${out##*$'\n'}" + fi +} + +echo "backport-fixes.sh — --from ref resolution" +t_branch +t_tag +t_unknown +t_nothing_to_backport +t_glob_from_rejected +t_branch_override_rejects_a_bad_name +t_to_rejects_a_tag +t_force_pushed_source +t_force_pushed_target +t_recut_tag +t_branch_beats_tag +t_unreachable_remote + +if [[ "$failures" -gt 0 ]]; then + echo "$failures failing" + exit 1 +fi +echo "all passing"