Skip to content

Commit 55185b0

Browse files
committed
fix: WWW-Authenticate hardening, DPoP htu port, docs and conformance
WWW-Authenticate / http_status - Security: www_authenticate() sanitizes CR, LF, ", and \ from every interpolated value, closing a header-injection path through attacker-influenced error messages. - DPoPNotSupportedError now emits WWW-Authenticate: Bearer (was DPoP). - http_status(CircuitOpenError) returns 503 (was 500). - www_authenticate() gains keyword-only resource_metadata_url= (RFC 9728) and scope= (RFC 6750), with scope= auto-populated from InsufficientScopeError.required_scopes. - New helpers: response_headers_for() bundles status + WWW-Authenticate into one call; AuthplaneResource.prm_url() returns the RFC 9728 well-known URL. - Both adapter verifiers emit a logging.DEBUG event "authplane.token_verification_failed" with structured error_class / error fields before returning None. DPoP htu host header - Outbound HTTP layer preserves non-default ports in the Host header and brackets IPv6 hostnames, so DPoP-protected requests to authservers on non-standard ports verify under RFC 9449. Docs - Fix 6 snippets that referenced an undefined run_query(); switch URL elicitation example to UrlElicitationRequiredError; rewrite MCP adapter Quick Starts to a single asyncio.run(main()) loop so refresh tasks share the request loop; small inaccuracies removed. Conformance tests - Align RFC 8693 issued_token_type test with the catalog; enforce one-test-per-case_id at collection time (collapses 3 sibling pairs); unify env var to AUTHPLANE_CONFORMANCE_CATALOG; raise a clear error when the catalog file is missing; cleanup (extract repeated SSRF stub, hoist imports). Tests: new coverage in tests/test_errors.py and tests/net/test_ssrf.py; adapter tests cover the new debug log event.
1 parent ec39fff commit 55185b0

28 files changed

Lines changed: 959 additions & 269 deletions

.github/workflows/ci.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@ jobs:
7474

7575
- name: Test and coverage
7676
env:
77-
CONFORMANCE_CATALOG_PATH: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml
77+
AUTHPLANE_CONFORMANCE_CATALOG: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml
7878
run: |
7979
if [ "${{ matrix.package }}" = "root" ]; then
8080
coverage run -m pytest tests conformance-tests && coverage report

.github/workflows/release.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ jobs:
173173
# below, as part of the single release commit.
174174
- name: Install packages and run tests
175175
env:
176-
CONFORMANCE_CATALOG_PATH: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml
176+
AUTHPLANE_CONFORMANCE_CATALOG: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml
177177
run: |
178178
pip install -e ".[dev]"
179179
pip install -e "./authplane-mcp[dev]"

CHANGELOG.md

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

88
## [Unreleased]
99

10+
### Security
11+
- `www_authenticate()` now sanitizes CR, LF, double-quote, and backslash from every value it interpolates (`realm`, `error_description`, `scope`, `resource_metadata`), closing a header-injection path through attacker-influenced error messages.
12+
1013
### Fixed
14+
- `DPoPNotSupportedError` now emits `WWW-Authenticate: Bearer` instead of `DPoP`. The resource is bearer-only by configuration, so advertising the DPoP scheme misled clients into retries that would fail the same way.
15+
- `http_status(CircuitOpenError)` now returns `503` (was `500`). The circuit breaker is structurally identical to other temporary-AS-unavailability errors and should be retryable, not surfaced as an internal error.
16+
- Outbound `Host` header now preserves non-default ports and brackets IPv6 hostnames, fixing DPoP `htu` validation against authservers on non-standard ports.
1117
- Packaging issues discovered after the first release.
1218
- Documentation links and demo references.
1319
- `authplane-fastmcp` dependency range now correctly requires `fastmcp>=3.2,<4` (was `>=2.0`, which could resolve to a version the adapter can't import).
1420

21+
### Added
22+
- `www_authenticate()` accepts `resource_metadata_url=` (RFC 9728 §5.1) and `scope=` (RFC 6750 §3) keyword arguments. When the caller does not pass `scope=`, the helper auto-populates it from `InsufficientScopeError.required_scopes`.
23+
- `InsufficientScopeError` now carries a structured `required_scopes` attribute, populated automatically by `VerifiedClaims.require_scope()` so the wire challenge can advertise the missing scope.
24+
- `response_headers_for(error, *, realm, resource_metadata_url, scope)` — bundled helper returning `(status, {"WWW-Authenticate": challenge})` in one call.
25+
- Both adapter verifiers (`authplane-mcp`, `authplane-fastmcp`) now emit a `logging.DEBUG` event `authplane.token_verification_failed` with structured `error_class` and `error` fields before returning `None`. Wire behaviour is unchanged; operators can now distinguish expired tokens from JWKS outages and DPoP replays in logs.
26+
1527
### Changed
1628
- CI and release workflow improvements from first-release learnings.
1729

CONTRIBUTING.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -99,7 +99,7 @@ Then run:
9999
pytest conformance-tests/
100100
```
101101

102-
`conformance-tests/conftest.py` resolves the catalog at `../conformance/oauth-sdk-conformance-catalog.yaml` by default. Set `CONFORMANCE_CATALOG_PATH=/absolute/path/to/oauth-sdk-conformance-catalog.yaml` to override (useful if your checkout layout differs). Without the catalog, `conformance-tests/` fails with a clear error — the rest of the test suite still runs.
102+
`conformance-tests/conftest.py` resolves the catalog at `../conformance/oauth-sdk-conformance-catalog.yaml` by default. Set `AUTHPLANE_CONFORMANCE_CATALOG=/absolute/path/to/oauth-sdk-conformance-catalog.yaml` to override (useful if your checkout layout differs). Without the catalog, `conformance-tests/` fails with a clear error — the rest of the test suite still runs.
103103

104104
**Coverage (matches CI):**
105105

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@ async def main() -> None:
3030

3131
@mcp.tool(auth=require_scopes("tools/query"))
3232
def query(sql: str) -> str:
33-
return run_query(sql)
33+
return f"Ran: {sql}" # replace with your real handler
3434

3535
try:
3636
await mcp.run_async(transport="http", port=8080)

authplane-fastmcp/authplane_fastmcp/verifier.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,11 +5,14 @@
55
to FastMCP's ``AccessToken`` with the full JWT payload in ``claims``.
66
"""
77

8+
import logging
89
from typing import Any, cast
910

1011
from authplane import AuthplaneError, AuthplaneResource
1112
from fastmcp.server.auth import AccessToken, TokenVerifier
1213

14+
logger = logging.getLogger(__name__)
15+
1316

1417
class AuthplaneTokenVerifier(TokenVerifier):
1518
"""FastMCP TokenVerifier backed by AuthplaneResource.
@@ -83,7 +86,11 @@ async def verify_token(self, token: str) -> AccessToken | None:
8386
"""
8487
try:
8588
claims = await self._verifier.verify(token)
86-
except AuthplaneError:
89+
except AuthplaneError as error:
90+
logger.debug(
91+
"authplane.token_verification_failed",
92+
extra={"error_class": type(error).__name__, "error": str(error)},
93+
)
8794
return None
8895

8996
return AccessToken(

authplane-fastmcp/docs/user-guide.md

Lines changed: 58 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -32,24 +32,30 @@ Requires Python 3.11+.
3232
## Quick Start
3333

3434
```python
35+
import asyncio
36+
3537
from fastmcp import FastMCP
3638
from authplane_fastmcp import authplane_auth
3739

38-
mcp = FastMCP(
39-
"My Server",
40-
**await authplane_auth(
40+
async def main() -> None:
41+
result = await authplane_auth(
4142
issuer="https://auth.company.com",
4243
base_url="https://mcp.company.com",
4344
scopes=["tools/query", "tools/write"],
44-
),
45-
)
45+
)
46+
mcp = FastMCP("My Server", **result)
4647

47-
@mcp.tool()
48-
def query(sql: str) -> str:
49-
"""Execute a query."""
50-
return run_query(sql)
48+
@mcp.tool()
49+
def query(sql: str) -> str:
50+
"""Execute a query."""
51+
return f"Ran: {sql}" # replace with your real handler
52+
53+
try:
54+
await mcp.run_async(transport="http", port=8080)
55+
finally:
56+
await result.aclose()
5157

52-
mcp.run(transport="http", port=8080)
58+
asyncio.run(main())
5359
```
5460

5561
`authplane_auth()` performs RFC 8414 metadata discovery, fetches the JWKS, and wires up all authentication components. The result unpacks directly into `FastMCP()`.
@@ -95,15 +101,15 @@ from fastmcp.server.auth import require_scopes
95101
@mcp.tool(auth=require_scopes("tools/query"))
96102
def query(sql: str) -> str:
97103
"""Requires the tools/query scope."""
98-
return run_query(sql)
104+
return f"Ran: {sql}" # replace with your real handler
99105

100106
@mcp.tool(auth=require_scopes("tools/admin", "tools/delete"))
101107
def delete_all() -> str:
102108
"""Requires BOTH tools/admin AND tools/delete scopes."""
103109
return clear_database()
104110
```
105111

106-
FastMCP enforces scopes **before** the handler runs. If the token is missing a required scope, FastMCP returns a 403 response and the handler is never called.
112+
FastMCP enforces scopes **before** the handler runs by **filtering tools the caller cannot use out of the catalog**. If the token is missing a required scope, the tool is hidden from `tools/list`, and a `tools/call` for that tool returns HTTP 200 with `{"isError": true, "content": [{"text": "Unknown tool: '<name>'"}]}`**not** a 403. UX layers expecting a 403 to prompt for re-auth will not see one; key off `isError` + the tool-not-found content text instead.
107113

108114
## Accessing Token Claims
109115

@@ -274,20 +280,27 @@ downstream = await result.client.exchange(
274280

275281
When a token exchange needs interactive user consent at the AS (for example, first-time authorization against a third-party service), the AS returns `consent_required` with a `consent_url`. MCP surfaces this through the URL elicitation flow (JSON-RPC error `-32042`). The [`authplane-mcp`](../../authplane-mcp/docs/user-guide.md#url-elicitation-for-consent) adapter wires it up end-to-end.
276282

277-
**fastmcp 3.2 does not propagate `McpError` from tool handlers** (its tool dispatch wraps everything except `FastMCPError` as `{"isError": true}`), so `-32042` never reaches the wire. Handle `ConsentRequiredError` in the tool body for now:
283+
**fastmcp 3.2 does not propagate `McpError` from tool handlers** (its tool dispatch wraps everything except `FastMCPError` as `{"isError": true}`), so `-32042` never reaches the wire. The wrapped `client.exchange()` raises `UrlElicitationRequiredError` (the MCP-shaped form of the consent error) — catch it in the tool body and render the consent URL into the response yourself:
278284

279285
```python
280286
from authplane import ConsentRequiredError
281287
from authplane.oauth import TokenExchangeOptions
288+
from mcp.shared.exceptions import UrlElicitationRequiredError
282289

283290
@mcp.tool(auth=require_scopes("tools/call_downstream"))
284291
async def call_downstream(payload: str) -> str:
285292
try:
286293
downstream = await auth_result.client.exchange(
287294
TokenExchangeOptions(subject_token=..., scope="downstream/write")
288295
)
296+
except UrlElicitationRequiredError as error:
297+
urls = [e.url for e in error.elicitations] if error.elicitations else []
298+
return f"Consent required: {urls[0] if urls else '<no url>'}"
289299
except ConsentRequiredError as error:
290-
return f"Consent required: {error.consent_url}"
300+
# The wrapper only translates to UrlElicitationRequiredError when the
301+
# AS supplied a consent_url. Without one, the bare error reaches us —
302+
# surface its formatted description (no URL to render).
303+
return f"Consent required: {error.describe()}"
291304
return await downstream_api_call(downstream.access_token, payload)
292305
```
293306

@@ -364,12 +377,17 @@ When `fetch_settings` is provided, `dev_mode` is ignored for both metadata and J
364377
`authplane_auth()` returns an `AuthplaneAuthResult` that holds background JWKS / metadata refresh tasks and an HTTP connection pool. Call `aclose()` on shutdown:
365378

366379
```python
367-
result = await authplane_auth(...)
368-
try:
369-
mcp = FastMCP("My Server", **result)
370-
await mcp.run_async(transport="http", port=8080)
371-
finally:
372-
await result.aclose()
380+
import asyncio
381+
382+
async def main() -> None:
383+
result = await authplane_auth(...)
384+
try:
385+
mcp = FastMCP("My Server", **result)
386+
await mcp.run_async(transport="http", port=8080)
387+
finally:
388+
await result.aclose()
389+
390+
asyncio.run(main())
373391
```
374392

375393
`result.aclose()` closes the underlying `AuthplaneClient`, cancels its background tasks, and releases connections. Skipping it surfaces as leaked tasks, open sockets, and `ResourceWarning` in tests.
@@ -378,11 +396,29 @@ finally:
378396

379397
### Verification path
380398

381-
`AuthplaneTokenVerifier.verify_token` catches every `AuthplaneError` raised by `AuthplaneResource.verify()` (missing/expired/invalid/revoked token, DPoP failure, etc.) and returns `None`. FastMCP turns that into a uniform **401 Unauthorized** for the request — the adapter does not differentiate by error type.
399+
`AuthplaneTokenVerifier.verify_token` catches every `AuthplaneError` raised by `AuthplaneResource.verify()` (missing/expired/invalid/revoked token, DPoP failure, etc.) and returns `None`. FastMCP turns that into a uniform **401 Unauthorized** on the wire — the *wire* does not differentiate by error type, but the verifier emits a `logging.DEBUG` event `authplane.token_verification_failed` (logger `authplane_fastmcp.verifier`) with structured `error_class` and `error` fields so operators can distinguish expired tokens from JWKS outages from DPoP replays in logs.
382400

383401
### Scope enforcement
384402

385-
Scope checks happen *after* token validation succeeds and are a separate enforcement layer — see [Scope Enforcement](#scope-enforcement) above for `@mcp.tool(auth=require_scopes(...))`. Inside a handler, `claims.require_scope("…")` raises `InsufficientScopeError` (which `http_status()` maps to 403 if you call it).
403+
Scope checks happen *after* token validation succeeds and are a separate enforcement layer — see [Scope Enforcement](#scope-enforcement) above for `@mcp.tool(auth=require_scopes(...))`. Inside a handler, `claims.require_scope("…")` raises `InsufficientScopeError`. The error carries `required_scopes` so the SDK can emit RFC 6750's `scope=` challenge automatically.
404+
405+
### Building a `WWW-Authenticate` challenge in custom middleware
406+
407+
When you handle an `AuthplaneError` outside the verifier — typically because you are wrapping the adapter in your own middleware or calling `AuthplaneResource.verify()` directly — use `response_headers_for(error, …)` to map the error to `(status, {"WWW-Authenticate": challenge})` in one call. It forwards `realm`, `resource_metadata_url`, and `scope` into the underlying `www_authenticate()` helper, which sanitizes every interpolated value against header injection.
408+
409+
```python
410+
from authplane import AuthplaneError, response_headers_for
411+
412+
try:
413+
claims = await resource.verify(token, dpop_request=ctx)
414+
except AuthplaneError as error:
415+
status, headers = response_headers_for(
416+
error,
417+
realm="api.example.com",
418+
resource_metadata_url=resource.prm_url(),
419+
)
420+
return Response(status_code=status, headers=headers)
421+
```
386422

387423
### Catching SDK errors directly
388424

authplane-fastmcp/tests/test_verifier.py

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
"""Unit tests for AuthplaneTokenVerifier."""
22

3+
import logging
34
from unittest.mock import AsyncMock
45

56
import pytest
6-
from authplane import VerifiedClaims
7+
from authplane import AuthplaneError, TokenExpiredError, VerifiedClaims
78

89
from authplane_fastmcp import AuthplaneTokenVerifier
910

@@ -103,3 +104,43 @@ def test_verifier_property(
103104
) -> None:
104105
"""verifier property exposes the underlying AuthplaneResource."""
105106
assert token_verifier.verifier is mock_verifier
107+
108+
109+
@pytest.mark.asyncio
110+
async def test_verify_token_failure_logs_typed_error_at_debug(
111+
mock_verifier: AsyncMock, caplog: pytest.LogCaptureFixture
112+
) -> None:
113+
# Regression: contract-required None return must still
114+
# produce an operator-side signal carrying the typed error class and
115+
# message. Debug-level so steady-state invalid tokens stay quiet.
116+
mock_verifier.verify.side_effect = TokenExpiredError("expired at 2026")
117+
118+
verifier = AuthplaneTokenVerifier(mock_verifier)
119+
120+
with caplog.at_level(logging.DEBUG, logger="authplane_fastmcp.verifier"):
121+
result = await verifier.verify_token("expired_jwt")
122+
123+
assert result is None
124+
matching = [r for r in caplog.records if r.name == "authplane_fastmcp.verifier"]
125+
assert len(matching) == 1
126+
record = matching[0]
127+
assert record.levelno == logging.DEBUG
128+
assert record.message == "authplane.token_verification_failed"
129+
assert record.error_class == "TokenExpiredError" # type: ignore[attr-defined]
130+
assert record.error == "expired at 2026" # type: ignore[attr-defined]
131+
132+
133+
@pytest.mark.asyncio
134+
async def test_verify_token_failure_silent_above_debug(
135+
mock_verifier: AsyncMock, caplog: pytest.LogCaptureFixture
136+
) -> None:
137+
# Default INFO level must not surface the event.
138+
mock_verifier.verify.side_effect = AuthplaneError("bad token")
139+
140+
verifier = AuthplaneTokenVerifier(mock_verifier)
141+
142+
with caplog.at_level(logging.INFO, logger="authplane_fastmcp.verifier"):
143+
result = await verifier.verify_token("bad")
144+
145+
assert result is None
146+
assert not [r for r in caplog.records if r.name == "authplane_fastmcp.verifier"]

authplane-mcp/README.md

Lines changed: 16 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -19,28 +19,34 @@ Supported `mcp` range: **`>=1.23.0, <1.28.0`**. MCP 1.28 renamed the elicitation
1919

2020
```python
2121
import asyncio
22+
2223
from authplane_mcp import authplane_mcp_auth, require_scope
2324
from mcp.server.fastmcp import FastMCP
2425

25-
auth_result = asyncio.run(
26-
authplane_mcp_auth(
26+
27+
async def main() -> None:
28+
auth_result = await authplane_mcp_auth(
2729
issuer="https://auth.company.com",
2830
resource="https://mcp.company.com",
2931
scopes=["tools/query", "tools/write"],
3032
)
31-
)
33+
mcp = FastMCP("My MCP Server", port=8080, json_response=True, **auth_result)
34+
35+
@mcp.tool()
36+
async def query_database(query: str) -> str:
37+
require_scope("tools/query")
38+
return f"Result for: {query}"
3239

33-
mcp = FastMCP("My MCP Server", json_response=True, **auth_result)
40+
try:
41+
await mcp.run_streamable_http_async()
42+
finally:
43+
await auth_result.aclose()
3444

35-
@mcp.tool()
36-
async def query_database(query: str) -> str:
37-
require_scope("tools/query")
38-
return f"Result for: {query}"
3945

40-
mcp.run(transport="streamable-http")
46+
asyncio.run(main())
4147
```
4248

43-
`mcp.run()` starts its own event loop, so the auth setup runs synchronously via `asyncio.run(...)` first. `auth_result` holds background JWKS and metadata refresh tasks — call `await auth_result.aclose()` during server shutdown.
49+
`auth_result` holds background JWKS and metadata refresh tasks bound to the running event loop. Keep the setup, server, and `aclose()` inside a single `asyncio.run(main())` so those tasks stay alive for the server's lifetime.
4450

4551
## Documentation
4652

authplane-mcp/authplane_mcp/verifier.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@
55
``AuthplaneResource`` and mapping results to MCP's ``AccessToken``.
66
"""
77

8+
import logging
9+
810
from authplane import AuthplaneError, AuthplaneResource
911
from mcp.server.auth.provider import AccessToken, TokenVerifier
1012

13+
logger = logging.getLogger(__name__)
14+
1115

1216
class AuthplaneTokenVerifier(TokenVerifier):
1317
"""MCP SDK TokenVerifier backed by AuthplaneResource.
@@ -55,7 +59,11 @@ async def verify_token(self, token: str) -> AccessToken | None:
5559
"""
5660
try:
5761
claims = await self._verifier.verify(token)
58-
except AuthplaneError:
62+
except AuthplaneError as error:
63+
logger.debug(
64+
"authplane.token_verification_failed",
65+
extra={"error_class": type(error).__name__, "error": str(error)},
66+
)
5967
return None
6068

6169
# AccessToken.resource must be a string. Since audience is a list,

0 commit comments

Comments
 (0)