You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
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.
Copy file name to clipboardExpand all lines: CHANGELOG.md
+12Lines changed: 12 additions & 0 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -7,11 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
8
8
## [Unreleased]
9
9
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
+
10
13
### 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.
11
17
- Packaging issues discovered after the first release.
12
18
- Documentation links and demo references.
13
19
-`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).
14
20
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
+
15
27
### Changed
16
28
- CI and release workflow improvements from first-release learnings.
Copy file name to clipboardExpand all lines: CONTRIBUTING.md
+1-1Lines changed: 1 addition & 1 deletion
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -99,7 +99,7 @@ Then run:
99
99
pytest conformance-tests/
100
100
```
101
101
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.
Copy file name to clipboardExpand all lines: authplane-fastmcp/docs/user-guide.md
+58-22Lines changed: 58 additions & 22 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -32,24 +32,30 @@ Requires Python 3.11+.
32
32
## Quick Start
33
33
34
34
```python
35
+
import asyncio
36
+
35
37
from fastmcp import FastMCP
36
38
from authplane_fastmcp import authplane_auth
37
39
38
-
mcp = FastMCP(
39
-
"My Server",
40
-
**await authplane_auth(
40
+
asyncdefmain() -> None:
41
+
result =await authplane_auth(
41
42
issuer="https://auth.company.com",
42
43
base_url="https://mcp.company.com",
43
44
scopes=["tools/query", "tools/write"],
44
-
),
45
-
)
45
+
)
46
+
mcp = FastMCP("My Server", **result)
46
47
47
-
@mcp.tool()
48
-
defquery(sql: str) -> str:
49
-
"""Execute a query."""
50
-
return run_query(sql)
48
+
@mcp.tool()
49
+
defquery(sql: str) -> str:
50
+
"""Execute a query."""
51
+
returnf"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()
51
57
52
-
mcp.run(transport="http", port=8080)
58
+
asyncio.run(main())
53
59
```
54
60
55
61
`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
95
101
@mcp.tool(auth=require_scopes("tools/query"))
96
102
defquery(sql: str) -> str:
97
103
"""Requires the tools/query scope."""
98
-
returnrun_query(sql)
104
+
returnf"Ran: {sql}"# replace with your real handler
"""Requires BOTH tools/admin AND tools/delete scopes."""
103
109
return clear_database()
104
110
```
105
111
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.
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.
276
282
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:
278
284
279
285
```python
280
286
from authplane import ConsentRequiredError
281
287
from authplane.oauth import TokenExchangeOptions
288
+
from mcp.shared.exceptions import UrlElicitationRequiredError
@@ -364,12 +377,17 @@ When `fetch_settings` is provided, `dev_mode` is ignored for both metadata and J
364
377
`authplane_auth()` returns an `AuthplaneAuthResult` that holds background JWKS / metadata refresh tasks and an HTTP connection pool. Call `aclose()` on shutdown:
365
378
366
379
```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
+
asyncdefmain() -> 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())
373
391
```
374
392
375
393
`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:
378
396
379
397
### Verification path
380
398
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.
382
400
383
401
### Scope enforcement
384
402
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
`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.
0 commit comments