Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -74,7 +74,7 @@ jobs:

- name: Test and coverage
env:
CONFORMANCE_CATALOG_PATH: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml
AUTHPLANE_CONFORMANCE_CATALOG: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml
run: |
if [ "${{ matrix.package }}" = "root" ]; then
coverage run -m pytest tests conformance-tests && coverage report
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ jobs:
# below, as part of the single release commit.
- name: Install packages and run tests
env:
CONFORMANCE_CATALOG_PATH: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml
AUTHPLANE_CONFORMANCE_CATALOG: ${{ runner.temp }}/conformance/oauth-sdk-conformance-catalog.yaml
run: |
pip install -e ".[dev]"
pip install -e "./authplane-mcp[dev]"
Expand Down
12 changes: 12 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Security
- `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.

### Fixed
- `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.
- `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.
- Outbound `Host` header now preserves non-default ports and brackets IPv6 hostnames, fixing DPoP `htu` validation against authservers on non-standard ports.
- Packaging issues discovered after the first release.
- Documentation links and demo references.
- `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).

### Added
- `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`.
- `InsufficientScopeError` now carries a structured `required_scopes` attribute, populated automatically by `VerifiedClaims.require_scope()` so the wire challenge can advertise the missing scope.
- `response_headers_for(error, *, realm, resource_metadata_url, scope)` — bundled helper returning `(status, {"WWW-Authenticate": challenge})` in one call.
- 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.

### Changed
- CI and release workflow improvements from first-release learnings.

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ Then run:
pytest conformance-tests/
```

`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.
`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.

**Coverage (matches CI):**

Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ async def main() -> None:

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

try:
await mcp.run_async(transport="http", port=8080)
Expand Down
9 changes: 8 additions & 1 deletion authplane-fastmcp/authplane_fastmcp/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@
to FastMCP's ``AccessToken`` with the full JWT payload in ``claims``.
"""

import logging
from typing import Any, cast

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

logger = logging.getLogger(__name__)


class AuthplaneTokenVerifier(TokenVerifier):
"""FastMCP TokenVerifier backed by AuthplaneResource.
Expand Down Expand Up @@ -83,7 +86,11 @@ async def verify_token(self, token: str) -> AccessToken | None:
"""
try:
claims = await self._verifier.verify(token)
except AuthplaneError:
except AuthplaneError as error:
logger.debug(
"authplane.token_verification_failed",
extra={"error_class": type(error).__name__, "error": str(error)},
)
return None

return AccessToken(
Expand Down
80 changes: 58 additions & 22 deletions authplane-fastmcp/docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,30 @@ Requires Python 3.11+.
## Quick Start

```python
import asyncio

from fastmcp import FastMCP
from authplane_fastmcp import authplane_auth

mcp = FastMCP(
"My Server",
**await authplane_auth(
async def main() -> None:
result = await authplane_auth(
issuer="https://auth.company.com",
base_url="https://mcp.company.com",
scopes=["tools/query", "tools/write"],
),
)
)
mcp = FastMCP("My Server", **result)

@mcp.tool()
def query(sql: str) -> str:
"""Execute a query."""
return run_query(sql)
@mcp.tool()
def query(sql: str) -> str:
"""Execute a query."""
return f"Ran: {sql}" # replace with your real handler

try:
await mcp.run_async(transport="http", port=8080)
finally:
await result.aclose()

mcp.run(transport="http", port=8080)
asyncio.run(main())
```

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

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

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.
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.

## Accessing Token Claims

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

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.

**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:
**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:

```python
from authplane import ConsentRequiredError
from authplane.oauth import TokenExchangeOptions
from mcp.shared.exceptions import UrlElicitationRequiredError

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

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

```python
result = await authplane_auth(...)
try:
mcp = FastMCP("My Server", **result)
await mcp.run_async(transport="http", port=8080)
finally:
await result.aclose()
import asyncio

async def main() -> None:
result = await authplane_auth(...)
try:
mcp = FastMCP("My Server", **result)
await mcp.run_async(transport="http", port=8080)
finally:
await result.aclose()

asyncio.run(main())
```

`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.
Expand All @@ -378,11 +396,29 @@ finally:

### Verification path

`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.
`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.

### Scope enforcement

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

### Building a `WWW-Authenticate` challenge in custom middleware

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.

```python
from authplane import AuthplaneError, response_headers_for

try:
claims = await resource.verify(token, dpop_request=ctx)
except AuthplaneError as error:
status, headers = response_headers_for(
error,
realm="api.example.com",
resource_metadata_url=resource.prm_url(),
)
return Response(status_code=status, headers=headers)
```

### Catching SDK errors directly

Expand Down
43 changes: 42 additions & 1 deletion authplane-fastmcp/tests/test_verifier.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
"""Unit tests for AuthplaneTokenVerifier."""

import logging
from unittest.mock import AsyncMock

import pytest
from authplane import VerifiedClaims
from authplane import AuthplaneError, TokenExpiredError, VerifiedClaims

from authplane_fastmcp import AuthplaneTokenVerifier

Expand Down Expand Up @@ -103,3 +104,43 @@ def test_verifier_property(
) -> None:
"""verifier property exposes the underlying AuthplaneResource."""
assert token_verifier.verifier is mock_verifier


@pytest.mark.asyncio
async def test_verify_token_failure_logs_typed_error_at_debug(
mock_verifier: AsyncMock, caplog: pytest.LogCaptureFixture
) -> None:
# Regression: contract-required None return must still
# produce an operator-side signal carrying the typed error class and
# message. Debug-level so steady-state invalid tokens stay quiet.
mock_verifier.verify.side_effect = TokenExpiredError("expired at 2026")

verifier = AuthplaneTokenVerifier(mock_verifier)

with caplog.at_level(logging.DEBUG, logger="authplane_fastmcp.verifier"):
result = await verifier.verify_token("expired_jwt")

assert result is None
matching = [r for r in caplog.records if r.name == "authplane_fastmcp.verifier"]
assert len(matching) == 1
record = matching[0]
assert record.levelno == logging.DEBUG
assert record.message == "authplane.token_verification_failed"
assert record.error_class == "TokenExpiredError" # type: ignore[attr-defined]
assert record.error == "expired at 2026" # type: ignore[attr-defined]


@pytest.mark.asyncio
async def test_verify_token_failure_silent_above_debug(
mock_verifier: AsyncMock, caplog: pytest.LogCaptureFixture
) -> None:
# Default INFO level must not surface the event.
mock_verifier.verify.side_effect = AuthplaneError("bad token")

verifier = AuthplaneTokenVerifier(mock_verifier)

with caplog.at_level(logging.INFO, logger="authplane_fastmcp.verifier"):
result = await verifier.verify_token("bad")

assert result is None
assert not [r for r in caplog.records if r.name == "authplane_fastmcp.verifier"]
26 changes: 16 additions & 10 deletions authplane-mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -19,28 +19,34 @@ Supported `mcp` range: **`>=1.23.0, <1.28.0`**. MCP 1.28 renamed the elicitation

```python
import asyncio

from authplane_mcp import authplane_mcp_auth, require_scope
from mcp.server.fastmcp import FastMCP

auth_result = asyncio.run(
authplane_mcp_auth(

async def main() -> None:
auth_result = await authplane_mcp_auth(
issuer="https://auth.company.com",
resource="https://mcp.company.com",
scopes=["tools/query", "tools/write"],
)
)
mcp = FastMCP("My MCP Server", port=8080, json_response=True, **auth_result)

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

mcp = FastMCP("My MCP Server", json_response=True, **auth_result)
try:
await mcp.run_streamable_http_async()
finally:
await auth_result.aclose()

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

mcp.run(transport="streamable-http")
asyncio.run(main())
```

`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.
`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.

## Documentation

Expand Down
10 changes: 9 additions & 1 deletion authplane-mcp/authplane_mcp/verifier.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,9 +5,13 @@
``AuthplaneResource`` and mapping results to MCP's ``AccessToken``.
"""

import logging

from authplane import AuthplaneError, AuthplaneResource
from mcp.server.auth.provider import AccessToken, TokenVerifier

logger = logging.getLogger(__name__)


class AuthplaneTokenVerifier(TokenVerifier):
"""MCP SDK TokenVerifier backed by AuthplaneResource.
Expand Down Expand Up @@ -55,7 +59,11 @@ async def verify_token(self, token: str) -> AccessToken | None:
"""
try:
claims = await self._verifier.verify(token)
except AuthplaneError:
except AuthplaneError as error:
logger.debug(
"authplane.token_verification_failed",
extra={"error_class": type(error).__name__, "error": str(error)},
)
return None

# AccessToken.resource must be a string. Since audience is a list,
Expand Down
Loading
Loading