Skip to content

Commit 5f0c8ee

Browse files
committed
feat: enforce inbound DPoP in MCP adapters, bound TokenCache, add require_scopes
Adapters (authplane-mcp, authplane-fastmcp): - Forward a DPoPRequestContext from verify_token to AuthplaneResource.verify so inbound_dpop=InboundDPoPOptions(required=True) enforces the proof check end-to-end. htu origin is the operator-configured resource URI, never the inbound Host / X-Forwarded-Proto headers. Operators using required=True with authplane-mcp should call install_request_context(mcp) after constructing FastMCP so the verifier can read the per-request context; without it the request fails closed (401) rather than skipping the check. - Reconstruct htu from scope["raw_path"] to preserve percent-encoding (e.g. %2F) on the wire under ASGI, falling back to request.url.path. - authplane-mcp adds AuthplaneRequestContextMiddleware, get_current_request(), and install_request_context(mcp) (idempotent) to publish the active request on a ContextVar. - Cache the in-flight verify task per request so a repeat verify_token within one request reuses it instead of re-entering the inbound DPoP replay store. Core (authplane): - Bound TokenCache with a configurable max_entries cap (default 10_000) and LRU eviction; plumbed through AuthplaneClient.create(cache_max_entries=). - Add VerifiedClaims.require_scopes(scopes) plural AND-style scope helper. Docs and demos run adapter setup, the async server entry point, and aclose() in a single asyncio.run(main()), keeping the client's locks, HTTP pool, and background refresh tasks on one event loop.
1 parent 348cfe8 commit 5f0c8ee

24 files changed

Lines changed: 2386 additions & 149 deletions

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- `TokenCache` is now bounded by a configurable `max_entries` cap (default `10_000`, exposed as `TokenCache.DEFAULT_MAX_ENTRIES` and a read-only `cache.max_entries` property) and evicts the least-recently-used entry on overflow; both `get` and `set` bump the touched key to MRU. Plumbed through `AuthplaneClient.create(cache_max_entries=...)`. Token-exchange cache keys are high-cardinality (the subject token is part of the key), so the cap keeps long-lived clients bounded.
12+
- `VerifiedClaims.require_scopes(scopes: Iterable[str])` — plural AND-style scope-union helper. Empty input is a no-op; on failure the raised `InsufficientScopeError` carries the full requested tuple on `required_scopes` and names every missing scope plus the token's available scopes in the message.
13+
- `authplane-mcp`: new public surface — `AuthplaneRequestContextMiddleware`, `get_current_request()`, `install_request_context(mcp)` — an ASGI middleware that publishes the active request on a `ContextVar` so the verifier can build a `DPoPRequestContext`.
14+
- `authplane-fastmcp`, `authplane-mcp`: `AuthplaneTokenVerifier` caches the in-flight verify task per request (keyed by access token on `request.state`), so a repeat `verify_token` within the same HTTP request awaits the same task rather than re-entering the inbound DPoP replay store. Cross-request replay protection is unaffected (distinct requests get distinct caches).
15+
16+
### Fixed
17+
- `authplane-fastmcp`, `authplane-mcp`: inbound DPoP proof-of-possession is now enforced end-to-end. `AuthplaneTokenVerifier.verify_token` forwards a `DPoPRequestContext` (method + reconstructed `htu` + proof header) to `AuthplaneResource.verify`, so `inbound_dpop=InboundDPoPOptions(required=True)` checks the proof on every request. The `htu` origin is always the operator-configured resource URI, never the inbound `Host` / `X-Forwarded-Proto` headers. Operators using `required=True` with `authplane-mcp` should call `install_request_context(mcp)` after constructing `FastMCP` so the verifier can read the per-request context; if it is not installed the request fails closed (401) rather than skipping the check.
18+
- `authplane-fastmcp`, `authplane-mcp`: DPoP `htu` reconstruction reads `scope["raw_path"]` to preserve percent-encoding (e.g. `%2F`) on the wire under ASGI, falling back to `request.url.path` when the server omits `raw_path`.
19+
- `authplane-mcp`: `install_request_context(mcp)` is idempotent — repeated calls on the same `FastMCP` instance are no-ops.
20+
- Docs and demos now run adapter setup, the async server entry point (`run_streamable_http_async` / `run_async`), and `aclose()` in a single `asyncio.run(main())`, keeping the client's locks, HTTP pool, and background JWKS/metadata refresh tasks on one event loop.
21+
1022
## [0.2.0] - 2026-05-20
1123

1224
### Security

authplane-fastmcp/authplane_fastmcp/verifier.py

Lines changed: 145 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -5,15 +5,29 @@
55
to FastMCP's ``AccessToken`` with the full JWT payload in ``claims``.
66
"""
77

8+
import asyncio
89
import logging
10+
from collections.abc import Callable
911
from typing import Any, cast
10-
11-
from authplane import AuthplaneError, AuthplaneResource
12+
from urllib.parse import urlsplit
13+
14+
from authplane import AuthplaneError, AuthplaneResource, DPoPRequestContext
15+
from authplane._dpop_adapter import (
16+
BuiltDPoPRequestContext,
17+
get_or_create_verify_cache,
18+
raw_request_path,
19+
read_dpop_header,
20+
)
1221
from fastmcp.server.auth import AccessToken, TokenVerifier
22+
from fastmcp.server.dependencies import get_http_request as _default_get_http_request
23+
from starlette.requests import Request
1324

1425
logger = logging.getLogger(__name__)
1526

1627

28+
__all__ = ["AuthplaneTokenVerifier"]
29+
30+
1731
class AuthplaneTokenVerifier(TokenVerifier):
1832
"""FastMCP TokenVerifier backed by AuthplaneResource.
1933
@@ -23,9 +37,39 @@ class AuthplaneTokenVerifier(TokenVerifier):
2337
tool handlers via FastMCP's native ``CurrentAccessToken()`` dependency
2438
or ``get_access_token()`` function.
2539
26-
All security-critical logic (signature verification, claim validation,
27-
JWKS caching, SSRF protection) is handled by the core SDK. This class
28-
is a thin adapter that maps between the two interfaces.
40+
DPoP (RFC 9449)
41+
---------------
42+
43+
When the underlying :class:`AuthplaneResource` was created with
44+
``inbound_dpop=InboundDPoPOptions(...)``, this verifier pulls the
45+
active HTTP request via
46+
:func:`fastmcp.server.dependencies.get_http_request`, builds a
47+
:class:`~authplane.DPoPRequestContext` (method + reconstructed
48+
``htu`` + proof header), and forwards it to
49+
:meth:`AuthplaneResource.verify`. The ``htu`` origin
50+
(scheme + host + port) is taken from the operator-configured
51+
resource URI, never from the inbound ``Host`` /
52+
``X-Forwarded-Proto`` headers — letting an upstream decide which
53+
``htu`` the proof is checked against would neuter DPoP's
54+
cross-endpoint anti-replay. Only the path varies per call.
55+
56+
Per-request verify cache
57+
------------------------
58+
59+
FastMCP's standard HTTP stack invokes ``verify_token`` exactly once
60+
per request (Starlette ``AuthenticationMiddleware`` →
61+
``BearerAuthBackend`` → ``TokenVerifier.verify_token``). The first
62+
call's in-flight verify task is stashed on ``request.state`` keyed by
63+
the access token; any subsequent invocation within the same request
64+
awaits the same task instead of re-entering the inbound DPoP replay
65+
store. The cache is defensive: it mirrors the TS adapter's
66+
``AsyncLocalStorage`` pattern and pre-empts a class of regressions
67+
where a future framework change (transport rewrite, custom auth
68+
provider, ASGI wrapper) would silently double-call ``verify_token``
69+
and the second call's proof would be rejected as
70+
``DPoPReplayDetected``. Different requests get distinct
71+
``request.state`` objects so cross-request replay protection is
72+
preserved.
2973
3074
Scope enforcement is FastMCP's responsibility via
3175
``@mcp.tool(auth=require_scopes(...))``.
@@ -36,6 +80,8 @@ def __init__(
3680
verifier: AuthplaneResource,
3781
base_url: str | None = None,
3882
required_scopes: list[str] | None = None,
83+
*,
84+
get_http_request: Callable[[], Request] | None = None,
3985
) -> None:
4086
"""Initialize the token verifier.
4187
@@ -47,9 +93,26 @@ def __init__(
4793
``TokenVerifier`` for PRM generation.
4894
required_scopes: Scopes required for all requests. Passed to
4995
the parent ``TokenVerifier``.
96+
get_http_request: Override for the active-request lookup
97+
(defaults to
98+
``fastmcp.server.dependencies.get_http_request``). Tests
99+
inject a fake to drive the DPoP / per-request-cache
100+
paths without spinning up an ASGI app.
50101
"""
51102
super().__init__(base_url=base_url, required_scopes=required_scopes)
52103
self._verifier = verifier
104+
self._get_http_request = get_http_request or _default_get_http_request
105+
106+
# ``AuthplaneResource.resource`` is operator-configured and must be a
107+
# string URI — guard against mis-wired mocks (a bare ``MagicMock`` with
108+
# no ``resource`` set silently produces ``MagicMock://MagicMock`` here
109+
# and would corrupt ``htu`` reconstruction in production).
110+
if not isinstance(verifier.resource, str):
111+
raise TypeError(
112+
f"verifier.resource must be a str URI, got {type(verifier.resource).__name__}"
113+
)
114+
split = urlsplit(verifier.resource)
115+
self._resource_origin = f"{split.scheme}://{split.netloc}"
53116

54117
@property
55118
def verifier(self) -> AuthplaneResource:
@@ -67,25 +130,57 @@ def scopes_supported(self) -> list[str]:
67130
return list(self._verifier.scopes)
68131

69132
async def verify_token(self, token: str) -> AccessToken | None:
70-
"""Validate a JWT and return a FastMCP AccessToken.
133+
"""Validate a JWT and return a FastMCP ``AccessToken``.
71134
72-
Called by FastMCP once per authenticated request. Delegates to
73-
``AuthplaneResource.verify()`` for all validation, then maps the
74-
resulting ``VerifiedClaims`` to a FastMCP ``AccessToken`` with the
75-
full JWT payload in ``claims``.
135+
Pulls the active HTTP request to build a per-request
136+
:class:`DPoPRequestContext` (RFC 9449 §7.1) and to scope the
137+
verify-task cache. The cache hit path re-awaits the original
138+
:class:`asyncio.Task`, so a cached :class:`AuthplaneError`
139+
re-raises with the same type on every call within the request —
140+
the ``AuthplaneError → None`` translation happens once here, not
141+
inside the cached coroutine, which keeps the cached failure
142+
diagnosable.
76143
77144
Args:
78-
token: The raw JWT string (FastMCP strips ``'Bearer '``
79-
before calling this method).
145+
token: The raw JWT string (FastMCP strips the ``Bearer ``
146+
prefix before calling this method).
80147
81148
Returns:
82-
``AccessToken`` on successful validation with ``token``,
83-
``client_id``, ``scopes``, ``expires_at``, and ``claims``
84-
(full JWT payload dict) fields populated. Returns ``None``
85-
on any validation failure (FastMCP responds with 401).
149+
``AccessToken`` on successful validation. ``None`` on any
150+
``AuthplaneError`` (FastMCP responds with 401).
86151
"""
87152
try:
88-
claims = await self._verifier.verify(token)
153+
request: Request | None = self._get_http_request()
154+
except RuntimeError as exc:
155+
# ``fastmcp.server.dependencies.get_http_request`` raises a bare
156+
# ``RuntimeError("No active HTTP request found.")`` when called
157+
# outside an HTTP request context (unit tests, background tasks
158+
# with no snapshotted request). Match the message to avoid
159+
# silently degrading to ``dpop_request=None`` if a future
160+
# FastMCP release surfaces an unrelated ``RuntimeError`` from
161+
# this dependency — that would re-introduce the silent-pass
162+
# this PR fixed (PRM advertising DPoP-required while the
163+
# verifier never sees a proof). Drop this narrow when the
164+
# upstream public surface adopts a typed exception.
165+
if "No active HTTP request" not in str(exc):
166+
raise
167+
request = None
168+
169+
try:
170+
if request is None:
171+
claims = await self._verifier.verify(token, dpop_request=None)
172+
else:
173+
cache = get_or_create_verify_cache(request)
174+
task = cache.get(token)
175+
if task is None:
176+
# No await between cache miss and cache write — concurrent
177+
# verify_token(token) calls on the same loop cannot race.
178+
dpop_request = self._build_dpop_request_context(request)
179+
task = asyncio.ensure_future(
180+
self._verifier.verify(token, dpop_request=dpop_request)
181+
)
182+
cache[token] = task
183+
claims = await task
89184
except AuthplaneError as error:
90185
logger.debug(
91186
"authplane.token_verification_failed",
@@ -100,3 +195,36 @@ async def verify_token(self, token: str) -> AccessToken | None:
100195
expires_at=claims.expires_at,
101196
claims=cast("dict[str, Any]", claims.raw), # full JWT payload
102197
)
198+
199+
def _build_dpop_request_context(self, request: Request) -> DPoPRequestContext:
200+
"""Build the per-request DPoP context.
201+
202+
Always returns a context — the resource verifier inspects the
203+
access token's ``cnf`` claim plus the context's ``proof`` to
204+
decide whether DPoP enforcement applies. When the resource is
205+
not configured for inbound DPoP, the verifier's Mode-3 path
206+
rejects any DPoP signal regardless of what is passed here.
207+
208+
Cross-SDK note: the TS sibling ``buildDpopRequestContext``
209+
returns ``undefined`` when no ``DPoP`` header is present;
210+
Python intentionally always builds the context with
211+
``proof=None``. Both shapes are behaviorally equivalent in
212+
the core verifier (Mode 3 path treats absent and ``None``
213+
proofs the same), but a DPoP-bound token with no proof
214+
yields a more specific ``DPoPProofMissingError`` here
215+
instead of ``DPoPBindingMismatchError``. The error-type
216+
contract is pinned per language by design.
217+
"""
218+
# ``raw_request_path`` reads ``scope["raw_path"]`` to preserve
219+
# percent-encoding for DPoP ``htu`` parity with the TS sibling.
220+
# ``request.url.query`` is sourced from ``scope["query_string"]``
221+
# without percent-decoding, so it is already on-wire-safe.
222+
url = f"{self._resource_origin}{raw_request_path(request)}"
223+
query = request.url.query
224+
if query:
225+
url = f"{url}?{query}"
226+
return BuiltDPoPRequestContext(
227+
method=request.method.upper(),
228+
url=url,
229+
proof=read_dpop_header(request),
230+
)

authplane-fastmcp/demo/mcpserver.py

Lines changed: 27 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -16,11 +16,8 @@
1616

1717
from authplane_fastmcp import authplane_auth
1818

19-
if __name__ == "__main__":
20-
logging.basicConfig(
21-
level=logging.DEBUG, format="%(asctime)s %(name)s %(levelname)s %(message)s"
22-
)
2319

20+
async def main() -> None:
2421
load_dotenv(os.path.join(os.path.dirname(__file__), ".env"))
2522

2623
resource = os.environ.get("RESOURCE_URL", "http://localhost:8080/mcp")
@@ -32,18 +29,16 @@
3229
client_id = os.environ.get("CLIENT_ID", resource)
3330
client_secret = os.environ["CLIENT_SECRET"]
3431

35-
auth_result = asyncio.run(
36-
authplane_auth(
37-
issuer=issuer,
38-
base_url=base_url,
39-
scopes=["tools/add", "tools/multiply"],
40-
dev_mode=True, # Enables local testing
41-
as_credentials=ASCredentials(
42-
client_id=client_id,
43-
client_secret=client_secret,
44-
),
45-
revocation_checker=IntrospectionRevocation(),
46-
)
32+
auth_result = await authplane_auth(
33+
issuer=issuer,
34+
base_url=base_url,
35+
scopes=["tools/add", "tools/multiply"],
36+
dev_mode=True, # Enables local testing
37+
as_credentials=ASCredentials(
38+
client_id=client_id,
39+
client_secret=client_secret,
40+
),
41+
revocation_checker=IntrospectionRevocation(),
4742
)
4843

4944
mcp = FastMCP("Calculator Service", **auth_result)
@@ -67,4 +62,19 @@ def multiply(a: float, b: float) -> float:
6762
# equivalent demo in ``authplane-mcp/demo/mcpserver.py``, which uses the
6863
# low-level MCP server and surfaces the elicitation correctly.
6964

70-
mcp.run(transport="http", port=port, log_level="DEBUG")
65+
# The adapter setup, server, and aclose() must share one event loop —
66+
# auth_result holds async resources (locks, httpx pool, background JWKS
67+
# refresh tasks) bound to the running loop. ``run_async`` is FastMCP's
68+
# async entry point and keeps everything on the same loop.
69+
try:
70+
await mcp.run_async(transport="http", port=port, log_level="DEBUG")
71+
finally:
72+
await auth_result.aclose()
73+
74+
75+
if __name__ == "__main__":
76+
logging.basicConfig(
77+
level=logging.DEBUG, format="%(asctime)s %(name)s %(levelname)s %(message)s"
78+
)
79+
80+
asyncio.run(main())

authplane-fastmcp/tests/conftest.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -63,8 +63,12 @@ def mock_verifier(valid_claims: VerifiedClaims) -> AsyncMock:
6363

6464
mock = AsyncMock(spec=AuthplaneResource)
6565
type(mock).scopes = PropertyMock(return_value=["tools/query", "tools/write"])
66+
type(mock).resource = PropertyMock(return_value="https://api.example.com/mcp")
6667

67-
async def verify_side_effect(token: str) -> VerifiedClaims:
68+
async def verify_side_effect(
69+
token: str, *, dpop_request: object | None = None
70+
) -> VerifiedClaims:
71+
_ = dpop_request # accept but ignore — covered by dedicated DPoP tests
6872
if token == "valid_token":
6973
return valid_claims
7074
raise AuthplaneError("Invalid token")

0 commit comments

Comments
 (0)