Skip to content

Commit 1e86bfa

Browse files
fix(dpop,mcp,fastmcp): keep the ;params segment in the htu binding, make the PRM hook public
`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.
1 parent c89de96 commit 1e86bfa

30 files changed

Lines changed: 1827 additions & 155 deletions

‎authplane-fastmcp/README.md‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,40 @@ asyncio.run(main())
4848

4949
`authplane_auth()` holds background JWKS and metadata refresh tasks; call `aclose()` on the returned `client` during server shutdown.
5050

51+
## Hand-rolling the auth provider
52+
53+
`authplane_auth()` returns a `VerbatimPRMRemoteAuthProvider`, a `RemoteAuthProvider`
54+
subclass that serves the Protected Resource Metadata identifiers byte-for-byte.
55+
It matters: upstream builds the PRM from `pydantic.AnyHttpUrl` fields, which append
56+
a trailing slash to an empty-path authority, and the core SDK compares identifiers
57+
verbatim — so a client that follows the advertised value literally is rejected.
58+
59+
If you build a `RemoteAuthProvider` yourself instead of calling `authplane_auth()`
60+
— a documented FastMCP pattern — use the subclass rather than the base class:
61+
62+
```python
63+
from authplane_fastmcp import VerbatimPRMRemoteAuthProvider
64+
from pydantic import AnyHttpUrl
65+
66+
provider = VerbatimPRMRemoteAuthProvider(
67+
token_verifier=token_verifier,
68+
authorization_servers=[AnyHttpUrl(issuer)],
69+
base_url=AnyHttpUrl(base_url),
70+
scopes_supported=scopes,
71+
# The two that make it verbatim. Pass the identifiers exactly as configured,
72+
# not the AnyHttpUrl forms above — that is the whole point: those normalize.
73+
verbatim_issuer=issuer,
74+
verbatim_resource=resource,
75+
)
76+
```
77+
78+
`base_url` is the server's base URL and `verbatim_resource` is the full resource
79+
identifier; they are not the same value when the MCP server is mounted under a
80+
path.
81+
82+
If you cannot subclass, `rewrite_prm_routes_verbatim(routes, issuer=..., resource=...)`
83+
is exported as a supported hook — apply it to the route list your provider returns.
84+
5185
## Documentation
5286

5387
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)**.

‎authplane-fastmcp/authplane_fastmcp/__init__.py‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,14 +16,21 @@
1616
except _PackageNotFoundError: # pragma: no cover - source tree without an install
1717
__version__ = "0.0.0+unknown"
1818

19-
from .auth import AuthplaneAuthResult, authplane_auth
19+
from ._prm import rewrite_prm_routes_verbatim
20+
from .auth import (
21+
AuthplaneAuthResult,
22+
VerbatimPRMRemoteAuthProvider,
23+
authplane_auth,
24+
)
2025
from .url_elicitation import to_url_elicitation_required_error
2126
from .verifier import AuthplaneTokenVerifier
2227

2328
__all__ = [
2429
"AuthplaneAuthResult",
2530
"AuthplaneTokenVerifier",
31+
"VerbatimPRMRemoteAuthProvider",
2632
"__version__",
2733
"authplane_auth",
34+
"rewrite_prm_routes_verbatim",
2835
"to_url_elicitation_required_error",
2936
]

‎authplane-fastmcp/authplane_fastmcp/_prm.py‎

Lines changed: 75 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,18 @@
1313
This module post-processes the served PRM response so the two identifier fields
1414
carry exactly the operator-configured strings, without touching any other field
1515
(scopes, bearer methods, cache headers, CORS) the upstream route emits.
16+
17+
NOTE: this module is mirrored byte-for-byte in
18+
``authplane-mcp/authplane_mcp/_prm.py``. It is the larger and subtler of
19+
the two duplicated modules — the rewrite gating below is easy to get wrong in one
20+
copy only. Any fix here must be applied to both.
1621
"""
1722

1823
import json
19-
from collections.abc import Awaitable, Callable, MutableSequence
24+
import warnings
25+
from collections.abc import Awaitable, Callable, Iterable
2026
from typing import Any
27+
from urllib.parse import urlsplit
2128

2229
from starlette.routing import BaseRoute, Route
2330

@@ -37,7 +44,8 @@ def _rewrite_body(body: bytes, *, issuer: str, resource: str) -> bytes:
3744
trailing-slash normalization: in ``authorization_servers`` the element equal
3845
to ``issuer`` or ``issuer + "/"`` is swapped for the verbatim ``issuer`` and
3946
every other entry is left in place, so a multi-AS advertisement keeps its
40-
extra entries. ``resource`` is set verbatim.
47+
extra entries. ``resource`` is replaced outright: the caller only routes this
48+
function at the document belonging to the configured resource.
4149
4250
Any body that is not a JSON object (e.g. a CORS preflight with an empty
4351
body) is returned unchanged.
@@ -55,7 +63,13 @@ def _rewrite_body(body: bytes, *, issuer: str, resource: str) -> bytes:
5563
if rewritten != servers:
5664
doc["authorization_servers"] = rewritten
5765
changed = True
58-
if "resource" in doc and doc["resource"] != resource:
66+
# Unconditional: which document this is was already decided by route
67+
# matching in rewrite_prm_routes_verbatim, so anything served here belongs to
68+
# the configured resource. Gating on the value instead would only cover the
69+
# trailing-slash normalization and silently skip every other one the URL
70+
# layer can apply (host case, an explicit default port, a doubled slash, a
71+
# dot segment) — which is precisely the mismatch this module exists to fix.
72+
if doc.get("resource") != resource:
5973
doc["resource"] = resource
6074
changed = True
6175
if not changed:
@@ -113,17 +127,64 @@ async def capture(message: _Message) -> None:
113127
return app
114128

115129

116-
def rewrite_prm_routes_verbatim(
117-
routes: MutableSequence[BaseRoute], *, issuer: str, resource: str
118-
) -> None:
119-
"""Wrap, in place, every Protected Resource Metadata route in ``routes``.
130+
def rewrite_prm_routes_verbatim(routes: Iterable[BaseRoute], *, issuer: str, resource: str) -> None:
131+
"""Wrap, in place, the Protected Resource Metadata route for ``resource``.
132+
133+
Selects by route path, not by document contents: RFC 9728 §3.1 derives the
134+
well-known path *from* the resource identifier, so the path is what says
135+
which resource a document describes. An app serving PRM for several
136+
resources registers one route each, and only the matching one is wrapped.
137+
138+
Matching on the path rather than on the served ``resource`` value is what
139+
keeps full normalization coverage. The served value has been through the
140+
URL layer and can differ from the configured string by more than a trailing
141+
slash; the path has not.
120142
121-
Matches routes registered under ``/.well-known/oauth-protected-resource``
122-
(RFC 9728 §3) and swaps their ASGI app for one that advertises ``issuer``
123-
and ``resource`` verbatim.
143+
Emits a ``RuntimeWarning`` when routes exist under the well-known prefix but
144+
none is the derivation of ``resource`` — that means the rewrite did nothing,
145+
and a silent no-op here ships a PRM advertising identifiers the core SDK's
146+
byte-for-byte comparison rejects.
124147
"""
148+
target = _PRM_PATH_PREFIX + urlsplit(resource).path.rstrip("/")
149+
seen_prefix = False
150+
wrapped = False
125151
for route in routes:
126-
if isinstance(route, Route) and (
127-
route.path == _PRM_PATH_PREFIX or route.path.startswith(_PRM_PATH_PREFIX + "/")
128-
):
129-
route.app = _wrap_app(route.app, issuer=issuer, resource=resource)
152+
if not isinstance(route, Route):
153+
continue
154+
if route.path == _PRM_PATH_PREFIX or route.path.startswith(_PRM_PATH_PREFIX + "/"):
155+
seen_prefix = True
156+
# Compare right-stripped: upstream keeps a trailing path slash when
157+
# deriving the well-known path (its rule is "the path unless it is
158+
# exactly /"), while `target` strips it. Everything else agrees. Left
159+
# as an equality check, a resource configured as `/mcp/` matched no
160+
# route — and skipping the wrap skips the *issuer* rewrite too, so
161+
# `authorization_servers` kept the slash-normalized form the core
162+
# SDK rejects. The prefix match this replaced covered that shape.
163+
#
164+
# What it trades away: an application serving `/mcp` and `/mcp/` as
165+
# two distinct resources has both routes wrapped, which is the
166+
# sibling clobber that route selection exists to prevent. Two
167+
# identifiers differing only by a trailing slash collapse to one
168+
# document under *this* SDK's derivation, and under the TS
169+
# sibling's, which documents the same choice (`core/prm.ts`:
170+
# "Trailing slashes on the resource path are dropped"). RFC 9728
171+
# §3.1 does not settle the case — it says to insert the well-known
172+
# segment between the host and the path, and says nothing about
173+
# normalizing a terminating slash — and upstream keeps it, deriving
174+
# two documents. That divergence is where the trailing-slash bug
175+
# came from, and it is why this comparison is right-stripped at
176+
# all. Given our derivation, the pair is pathological rather than a
177+
# case to support.
178+
if route.path.rstrip("/") == target:
179+
route.app = _wrap_app(route.app, issuer=issuer, resource=resource)
180+
wrapped = True
181+
if seen_prefix and not wrapped:
182+
warnings.warn(
183+
f"no Protected Resource Metadata route matches {target!r} (the RFC 9728 "
184+
f"§3.1 derivation of {resource!r}), so the served document keeps the "
185+
"slash-normalized identifiers the core SDK's byte-for-byte comparison "
186+
"rejects. Routes under the well-known prefix were found, so the "
187+
"derivation and the registered path disagree.",
188+
RuntimeWarning,
189+
stacklevel=2,
190+
)

‎authplane-fastmcp/authplane_fastmcp/auth.py‎

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@
2020
from authplane.oauth import TokenExchangeOptions, TokenResponse
2121
from fastmcp.server.auth import RemoteAuthProvider
2222
from pydantic import AnyHttpUrl
23-
from starlette.routing import Route
23+
from starlette.routing import BaseRoute
2424

2525
from ._prm import rewrite_prm_routes_verbatim
2626
from .url_elicitation import to_url_elicitation_required_error
2727
from .verifier import AuthplaneTokenVerifier
2828

2929

30-
class _VerbatimPRMRemoteAuthProvider(RemoteAuthProvider):
30+
class VerbatimPRMRemoteAuthProvider(RemoteAuthProvider):
3131
"""``RemoteAuthProvider`` that advertises identifiers verbatim in the PRM.
3232
3333
Upstream builds the Protected Resource Metadata document from
@@ -51,7 +51,7 @@ def __init__(
5151
self._verbatim_issuer = verbatim_issuer
5252
self._verbatim_resource = verbatim_resource
5353

54-
def get_routes(self, *args: Any, **kwargs: Any) -> list[Route]:
54+
def get_routes(self, *args: Any, **kwargs: Any) -> list[BaseRoute]:
5555
# Forward whatever positional/keyword args the framework passes so a
5656
# future signature change in the base ``get_routes`` cannot TypeError
5757
# 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]:
6464
return routes
6565

6666

67+
def _derive_resource_url(base_url: str, mcp_path: str) -> str:
68+
"""Compose the canonical resource identifier (= JWT audience) from the mount.
69+
70+
This must match exactly what ``RemoteAuthProvider`` advertises in the PRM,
71+
which FastMCP computes as ``base_url`` joined with the transport mount path
72+
via ``_get_resource_url()``. That agreement is pinned directly against
73+
upstream's function by ``test_derive_resource_url_matches_fastmcp``, so it
74+
is checked rather than only asserted here.
75+
"""
76+
return base_url.rstrip("/") + "/" + mcp_path.lstrip("/")
77+
78+
6779
def _wrap_client_for_elicitation(client: AuthplaneClient) -> AuthplaneClient:
6880
"""Translate ``client.exchange`` consent errors into MCP ``-32042``.
6981
@@ -227,7 +239,15 @@ async def authplane_auth(
227239
mcp_path: Mount path of the MCP endpoint (default ``"/mcp"``).
228240
The JWT audience (resource) is derived as
229241
``base_url + mcp_path``. Only set this if you changed
230-
FastMCP's default HTTP mount path.
242+
FastMCP's default HTTP mount path. Pass a real mount path — an
243+
empty string is not one. Against a ``base_url`` that carries a
244+
path, ``""`` derives an identifier FastMCP does not serve:
245+
upstream short-circuits a falsy path and returns the base URL
246+
untouched, while this join appends a slash. Accepted rather than
247+
rejected, since raising would be a behaviour change to a public
248+
factory for an input nothing passes; the divergence itself is
249+
pinned by
250+
``test_derive_resource_url_diverges_from_fastmcp_on_an_empty_mount_path``.
231251
as_credentials: Client credentials for authenticating to the AS.
232252
Shared by introspection (RFC 7662) and token exchange (RFC 8693).
233253
Required when using ``IntrospectionRevocation`` for authenticated
@@ -270,10 +290,7 @@ async def authplane_auth(
270290
"""
271291
resolved_scopes = scopes or []
272292

273-
# Derive the canonical resource URL (= JWT audience) from base_url + mcp_path.
274-
# This must match exactly what RemoteAuthProvider advertises in the PRM, which
275-
# FastMCP computes as base_url + mcp_path via _get_resource_url().
276-
resource = base_url.rstrip("/") + "/" + mcp_path.lstrip("/")
293+
resource = _derive_resource_url(base_url, mcp_path)
277294

278295
# Prepare client-level kwargs, filtering out None to use SDK defaults
279296
client_kwargs_raw: dict[str, Any] = {
@@ -332,11 +349,11 @@ async def authplane_auth(
332349
# upstream framework requires the URL type internally. That construction
333350
# normalizes an empty-path authority with a trailing slash, so the served
334351
# PRM would otherwise advertise ``https://auth.example.com/`` for an issuer
335-
# configured as ``https://auth.example.com``. ``_VerbatimPRMRemoteAuthProvider``
352+
# configured as ``https://auth.example.com``. ``VerbatimPRMRemoteAuthProvider``
336353
# rewrites the served ``authorization_servers`` / ``resource`` back to the
337354
# verbatim configured strings so they match the core SDK's byte-for-byte
338355
# comparison (RFC 8414 §3.3, RFC 9728 §3.3).
339-
auth_provider = _VerbatimPRMRemoteAuthProvider(
356+
auth_provider = VerbatimPRMRemoteAuthProvider(
340357
token_verifier=token_verifier,
341358
authorization_servers=[AnyHttpUrl(issuer)],
342359
base_url=AnyHttpUrl(base_url),

‎authplane-fastmcp/authplane_fastmcp/url_elicitation.py‎

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,12 @@ def _resolve_elicitation_id_kwarg(model: type[BaseModel]) -> str:
7070
# with no id). The ``mcp<2`` ceiling means this branch can only be reached
7171
# inside mcp 1.x, so a third spelling is an unexpected schema change: fail
7272
# loudly rather than emit a malformed elicitation.
73-
raise ImportError(
73+
# RuntimeError, not ImportError: this resolver runs both at import time (where
74+
# ImportError is the right shape) and lazily from
75+
# ``_build_url_elicitation_params``, where the installed package imported
76+
# fine and the failure is a runtime schema mismatch. ImportError from a
77+
# non-import call site sends the reader looking for a missing dependency.
78+
raise RuntimeError(
7479
f"authplane-fastmcp cannot resolve the elicitation-id field on {model.__name__!r}: "
7580
"none of the known spellings (elicitationId, elicitation_id) is a declared "
7681
"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:
7984

8085
# Fail fast at import: the installed mcp must expose a known elicitation-id
8186
# spelling. Resolution is otherwise lazy (see _build_url_elicitation_params) so
82-
# tests can patch the model without re-triggering this. The bare call exists
83-
# only for its import-time validation side effect; no name is bound.
84-
_resolve_elicitation_id_kwarg(ElicitRequestURLParams)
87+
# tests can patch the model without re-triggering this. The name below is never
88+
# read — it is bound only so this validation runs as an import-time side effect.
89+
#
90+
# The resolver raises RuntimeError because it is also called lazily, where the
91+
# package imported fine and the failure is a runtime schema mismatch. At *this*
92+
# call site the failure really is "the installed distribution is unusable", so
93+
# translate it to the shape a reader expects from a failing import.
94+
try:
95+
_ELICITATION_ID_KWARG = _resolve_elicitation_id_kwarg(ElicitRequestURLParams)
96+
except RuntimeError as exc: # pragma: no cover - exercised via importlib.reload
97+
raise ImportError(str(exc)) from exc
8598

8699

87100
def _build_url_elicitation_params(

‎authplane-fastmcp/docs/user-guide.md‎

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,7 @@ Trade-offs to understand before enabling `fail_closed=True`:
244244

245245
- **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.
246246
- **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.
247-
- **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.
247+
- **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.
248248
- `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.
249249

250250
### Custom Revocation Checker
@@ -529,10 +529,36 @@ Returned by `authplane_auth()`. Supports `**` unpacking into `FastMCP()` — the
529529

530530
| Attribute | Type | Description |
531531
|-----------|------|-------------|
532-
| `auth` | `RemoteAuthProvider` | Auth provider for FastMCP |
532+
| `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 |
533533
| `token_verifier` | `AuthplaneTokenVerifier` | Token verifier (for advanced / manual setup) |
534534
| `client` | `AuthplaneClient` | Underlying SDK client (use `client.exchange()` for RFC 8693) |
535535

536+
### `VerbatimPRMRemoteAuthProvider`
537+
538+
`RemoteAuthProvider` subclass that serves the Protected Resource Metadata identifiers
539+
byte-for-byte. Upstream builds the PRM from `pydantic.AnyHttpUrl` fields, which append a
540+
trailing slash to an empty-path authority; the core SDK compares identifiers verbatim, so a
541+
client following the advertised value literally is rejected.
542+
543+
`authplane_auth()` returns one already configured. Construct it directly only when you build
544+
the provider yourself — a documented FastMCP pattern — since using the base class instead
545+
loses the verbatim PRM silently.
546+
547+
| Constructor argument | Description |
548+
|---|---|
549+
| `verbatim_issuer` | The issuer exactly as configured, not the `AnyHttpUrl` form |
550+
| `verbatim_resource` | The resource identifier exactly as configured |
551+
552+
Everything else is forwarded to `RemoteAuthProvider` — note `base_url` is the server base,
553+
which is not the same value as `verbatim_resource` when the server is mounted under a path.
554+
555+
### `rewrite_prm_routes_verbatim(routes, *, issuer, resource)`
556+
557+
The rewrite itself, exported for the case where you cannot subclass. Apply it to the route
558+
list your provider returns. It wraps the single route whose path is the RFC 9728 §3.1
559+
derivation of `resource`, and emits a `RuntimeWarning` when routes exist under the well-known
560+
prefix but none is that derivation — meaning the rewrite did nothing.
561+
536562
### `AuthplaneTokenVerifier`
537563

538564
FastMCP `TokenVerifier` implementation.

0 commit comments

Comments
 (0)