From 02eb15cc148c5f784bcb0320a3065567bec9eaa5 Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Tue, 28 Jul 2026 14:31:17 -0300 Subject: [PATCH 1/8] feat(fastmcp,mcp): expose fail_closed through the adapter auth factories The core SDK already implements fail_closed on AuthplaneClient.resource(...), but neither authplane_auth() nor authplane_mcp_auth() forwarded it, so factory users opting into revocation checking were locked into fail-open behavior on introspection/revocation-check outages. Both factories now accept fail_closed: bool = False and pass it through at the client.resource(...) boundary. The default preserves today's fail-open behavior. Also adds the previously missing core coverage for fail_closed=True (crashing custom checker and introspection HTTP 500 both reject with TokenRevokedError), forwarding tests for both adapters, and user-guide docs covering the availability/security trade-off, the authenticated-introspection credential requirement, and the circuit-breaker interaction. Requested in #20. --- CHANGELOG.md | 3 ++ authplane-fastmcp/authplane_fastmcp/auth.py | 14 +++++- authplane-fastmcp/docs/user-guide.md | 30 ++++++++++++- authplane-fastmcp/tests/test_auth_factory.py | 42 +++++++++++++++++- authplane-mcp/authplane_mcp/auth.py | 14 +++++- authplane-mcp/docs/user-guide.md | 30 ++++++++++++- authplane-mcp/tests/test_auth.py | 38 +++++++++++++++- tests/verifier/test_revocation.py | 46 ++++++++++++++++++++ 8 files changed, 211 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 00c32b4..666907f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- `authplane-fastmcp`, `authplane-mcp`: `authplane_auth()` and `authplane_mcp_auth()` accept `fail_closed: bool = False` and forward it to `AuthplaneClient.resource(...)`, so factory users can opt into rejecting tokens (`TokenRevokedError`) when the configured `revocation_checker` itself fails — e.g. an unreachable introspection endpoint — instead of the default fail-open acceptance. The flag is only consulted when a `revocation_checker` is configured. Both user guides document the availability/security trade-off, the authenticated-introspection credential requirement, and the circuit-breaker interaction. + ## [0.3.0] - 2026-07-21 ### Added diff --git a/authplane-fastmcp/authplane_fastmcp/auth.py b/authplane-fastmcp/authplane_fastmcp/auth.py index ecf4fdd..f491134 100644 --- a/authplane-fastmcp/authplane_fastmcp/auth.py +++ b/authplane-fastmcp/authplane_fastmcp/auth.py @@ -121,6 +121,7 @@ async def authplane_auth( inbound_dpop: InboundDPoPOptions | None = None, mcp_path: str = "/mcp", revocation_checker: IntrospectionRevocation | RevocationChecker | None = None, + fail_closed: bool = False, ) -> AuthplaneAuthResult: """Build the kwargs to enable Authplane auth on a FastMCP server. @@ -201,10 +202,20 @@ async def authplane_auth( ``introspection_endpoint`` (RFC 7662) discovered from AS metadata. Raises ``TokenRevokedError`` if ``active=false``. Pass ``as_credentials`` for authenticated introspection. - Fails open if the endpoint is unavailable. + Fails open if the endpoint is unavailable, unless + ``fail_closed=True``. - async callable: custom checker called with ``(VerifiedClaims, raw_token)``; return ``True`` to reject the token (raises ``TokenRevokedError``). + fail_closed: Policy applied when the configured + ``revocation_checker`` itself fails (e.g. the introspection + endpoint is unreachable). ``False`` (default) accepts the + token — offline signature/claims validation still applies. + ``True`` rejects it with ``TokenRevokedError``, trading + availability during an AS outage for a hard revocation + guarantee. Only consulted when a ``revocation_checker`` is + configured; note that once the client's circuit breaker + opens, every request is rejected until the cooldown elapses. Returns: ``AuthplaneAuthResult`` with ``auth`` (``RemoteAuthProvider``), @@ -268,6 +279,7 @@ async def authplane_auth( resource=resource, scopes=resolved_scopes, revocation_checker=revocation_checker, + fail_closed=fail_closed, **verifier_kwargs, ) diff --git a/authplane-fastmcp/docs/user-guide.md b/authplane-fastmcp/docs/user-guide.md index 28699f0..5dec286 100644 --- a/authplane-fastmcp/docs/user-guide.md +++ b/authplane-fastmcp/docs/user-guide.md @@ -82,6 +82,7 @@ All parameters of `authplane_auth()`: | `clock_skew_seconds` | `int` | `30` | Leeway for `exp`/`nbf`/`iat` validation | | `dev_mode` | `bool` | `False` | Relaxes SSRF checks for local development | | `revocation_checker` | see [below](#token-revocation-checking) | `None` | Token revocation strategy | +| `fail_closed` | `bool` | `False` | Reject tokens when the revocation check itself fails, instead of accepting them (see [below](#failure-policy-fail-open-vs-fail-closed)) | | `fetch_settings` | `FetchSettings` | `None` | Full SSRF / fetch settings applied to both metadata and JWKS fetches (overrides `dev_mode`) | | `inbound_dpop` | `InboundDPoPOptions` | `None` | Per-resource inbound DPoP policy (replay store, max proof age, clock skew, accepted proof algorithms, `required`). When set, the resource advertises DPoP support in PRM (RFC 9728 §2). See **Inbound DPoP through the FastMCP adapter** below for current limitations. | @@ -210,9 +211,35 @@ await authplane_auth( - The introspection endpoint is automatically discovered from AS metadata. - If the endpoint returns `active=false`, the token is rejected with `TokenRevokedError`. -- **Fails open**: if the introspection endpoint is unavailable, the token is accepted (offline validation still applies). +- **Fails open by default**: if the introspection endpoint is unavailable, the token is accepted (offline validation still applies). Pass `fail_closed=True` to reject instead (see [below](#failure-policy-fail-open-vs-fail-closed)). - `as_credentials` enables authenticated introspection (recommended for production). +### Failure Policy: Fail-Open vs Fail-Closed + +`fail_closed` controls what happens when the revocation check itself fails — the introspection endpoint is unreachable, returns an error, or a custom checker raises: + +```python +await authplane_auth( + issuer="https://auth.company.com", + base_url="https://mcp.company.com", + revocation_checker=IntrospectionRevocation(), + as_credentials=ASCredentials( + client_id="my_resource_server", + client_secret="secret", + ), + fail_closed=True, +) +``` + +- `False` (default) accepts the token and logs a warning. Signature and claims validation still apply, so this only skips the *revocation* freshness check — it never admits an otherwise-invalid token. +- `True` rejects the token with `TokenRevokedError`. Choose this for servers exposing mutation-capable or otherwise high-impact tools, where serving a revoked-but-unverifiable token is worse than downtime. + +Trade-offs to understand before enabling `fail_closed=True`: + +- **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. +- **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. +- `fail_closed` has no effect when `revocation_checker` is `None` — the flag is only consulted when a revocation check actually runs. + ### Custom Revocation Checker Implement your own revocation logic with an async callable: @@ -474,6 +501,7 @@ async def authplane_auth( fetch_settings: FetchSettings | None = None, inbound_dpop: InboundDPoPOptions | None = None, revocation_checker: IntrospectionRevocation | RevocationChecker | None = None, + fail_closed: bool = False, ) -> AuthplaneAuthResult ``` diff --git a/authplane-fastmcp/tests/test_auth_factory.py b/authplane-fastmcp/tests/test_auth_factory.py index 2b1bb06..d677eda 100644 --- a/authplane-fastmcp/tests/test_auth_factory.py +++ b/authplane-fastmcp/tests/test_auth_factory.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from authplane import DPoPProvider, FetchSettings, VerifiedClaims +from authplane import DPoPProvider, FetchSettings, IntrospectionRevocation, VerifiedClaims from authplane_fastmcp import authplane_auth from authplane_fastmcp.auth import AuthplaneAuthResult @@ -147,6 +147,46 @@ async def my_checker(claims: VerifiedClaims, raw_token: str) -> bool: assert verifier_kwargs["revocation_checker"] is my_checker +@pytest.mark.asyncio +async def test_authplane_auth_fail_closed_default_is_false(): + """When fail_closed is not passed, False is forwarded (fail-open behavior).""" + mock_client = MagicMock() + _mock_resource = MagicMock() + _mock_resource.resource = "https://api.example.com/mcp" + mock_client.resource = MagicMock(return_value=_mock_resource) + + with patch("authplane_fastmcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock(return_value=mock_client) + await authplane_auth( + issuer="https://auth.example.com", + base_url="https://api.example.com", + ) + + verifier_kwargs = mock_client.resource.call_args.kwargs + assert verifier_kwargs["fail_closed"] is False + + +@pytest.mark.asyncio +async def test_authplane_auth_fail_closed_forwarded(): + """fail_closed=True is forwarded to client.resource().""" + mock_client = MagicMock() + _mock_resource = MagicMock() + _mock_resource.resource = "https://api.example.com/mcp" + mock_client.resource = MagicMock(return_value=_mock_resource) + + with patch("authplane_fastmcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock(return_value=mock_client) + await authplane_auth( + issuer="https://auth.example.com", + base_url="https://api.example.com", + revocation_checker=IntrospectionRevocation(), + fail_closed=True, + ) + + verifier_kwargs = mock_client.resource.call_args.kwargs + assert verifier_kwargs["fail_closed"] is True + + @pytest.mark.asyncio async def test_authplane_auth_resource_derivation(): """Verify resource URL construction from base_url and mcp_path.""" diff --git a/authplane-mcp/authplane_mcp/auth.py b/authplane-mcp/authplane_mcp/auth.py index a186b82..a944584 100644 --- a/authplane-mcp/authplane_mcp/auth.py +++ b/authplane-mcp/authplane_mcp/auth.py @@ -222,6 +222,7 @@ async def authplane_mcp_auth( fetch_settings: FetchSettings | None = None, inbound_dpop: InboundDPoPOptions | None = None, revocation_checker: IntrospectionRevocation | RevocationChecker | None = None, + fail_closed: bool = False, ) -> AuthplaneAuthResult: """Build the kwargs to enable Authplane auth on a FastMCP server. @@ -323,10 +324,20 @@ async def authplane_mcp_auth( ``introspection_endpoint`` (RFC 7662) discovered from AS metadata. Raises ``TokenRevokedError`` if ``active=false``. Pass ``as_credentials`` for authenticated introspection. - Fails open if the endpoint is unavailable. + Fails open if the endpoint is unavailable, unless + ``fail_closed=True``. - async callable: custom checker called with ``(VerifiedClaims, raw_token)``; return ``True`` to reject the token (raises ``TokenRevokedError``). + fail_closed: Policy applied when the configured + ``revocation_checker`` itself fails (e.g. the introspection + endpoint is unreachable). ``False`` (default) accepts the + token — offline signature/claims validation still applies. + ``True`` rejects it with ``TokenRevokedError``, trading + availability during an AS outage for a hard revocation + guarantee. Only consulted when a ``revocation_checker`` is + configured; note that once the client's circuit breaker + opens, every request is rejected until the cooldown elapses. Returns: ``AuthplaneAuthResult`` with ``token_verifier`` (``AuthplaneTokenVerifier``), @@ -384,6 +395,7 @@ async def authplane_mcp_auth( resource=resource, scopes=resolved_scopes, revocation_checker=revocation_checker, + fail_closed=fail_closed, **verifier_kwargs, ) diff --git a/authplane-mcp/docs/user-guide.md b/authplane-mcp/docs/user-guide.md index 76b852b..e22c970 100644 --- a/authplane-mcp/docs/user-guide.md +++ b/authplane-mcp/docs/user-guide.md @@ -85,6 +85,7 @@ All parameters of `authplane_mcp_auth()`: | `clock_skew_seconds` | `int` | `30` | Leeway for `exp`/`nbf`/`iat` validation | | `dev_mode` | `bool` | `False` | Relaxes SSRF checks for local development | | `revocation_checker` | see [below](#token-revocation-checking) | `None` | Token revocation strategy | +| `fail_closed` | `bool` | `False` | Reject tokens when the revocation check itself fails, instead of accepting them (see [below](#failure-policy-fail-open-vs-fail-closed)) | | `fetch_settings` | `FetchSettings` | `None` | Full SSRF / fetch settings applied to both metadata and JWKS fetches (overrides `dev_mode`) | | `inbound_dpop` | `InboundDPoPOptions` | `None` | Per-resource inbound DPoP policy (replay store, max proof age, clock skew, accepted proof algorithms, `required`). When set, the resource advertises DPoP support in PRM (RFC 9728 §2). See **Inbound DPoP through the MCP adapter** below for current limitations. | @@ -201,9 +202,35 @@ await authplane_mcp_auth( - The introspection endpoint is automatically discovered from AS metadata. - If the endpoint returns `active=false`, the token is rejected with `TokenRevokedError`. -- **Fails open**: if the introspection endpoint is unavailable, the token is accepted (offline validation still applies). +- **Fails open by default**: if the introspection endpoint is unavailable, the token is accepted (offline validation still applies). Pass `fail_closed=True` to reject instead (see [below](#failure-policy-fail-open-vs-fail-closed)). - `as_credentials` enables authenticated introspection (recommended for production). +### Failure Policy: Fail-Open vs Fail-Closed + +`fail_closed` controls what happens when the revocation check itself fails — the introspection endpoint is unreachable, returns an error, or a custom checker raises: + +```python +await authplane_mcp_auth( + issuer="https://auth.company.com", + resource="https://mcp.company.com", + revocation_checker=IntrospectionRevocation(), + as_credentials=ASCredentials( + client_id="my_resource_server", + client_secret="secret", + ), + fail_closed=True, +) +``` + +- `False` (default) accepts the token and logs a warning. Signature and claims validation still apply, so this only skips the *revocation* freshness check — it never admits an otherwise-invalid token. +- `True` rejects the token with `TokenRevokedError`. Choose this for servers exposing mutation-capable or otherwise high-impact tools, where serving a revoked-but-unverifiable token is worse than downtime. + +Trade-offs to understand before enabling `fail_closed=True`: + +- **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. +- **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. +- `fail_closed` has no effect when `revocation_checker` is `None` — the flag is only consulted when a revocation check actually runs. + ### Custom Revocation Checker Implement your own revocation logic with an async callable: @@ -476,6 +503,7 @@ async def authplane_mcp_auth( fetch_settings: FetchSettings | None = None, inbound_dpop: InboundDPoPOptions | None = None, revocation_checker: IntrospectionRevocation | RevocationChecker | None = None, + fail_closed: bool = False, ) -> AuthplaneAuthResult ``` diff --git a/authplane-mcp/tests/test_auth.py b/authplane-mcp/tests/test_auth.py index 3271bbf..f2fdb98 100644 --- a/authplane-mcp/tests/test_auth.py +++ b/authplane-mcp/tests/test_auth.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from authplane import DPoPProvider, FetchSettings, VerifiedClaims +from authplane import DPoPProvider, FetchSettings, IntrospectionRevocation, VerifiedClaims from mcp.server.auth.provider import AccessToken from mcp.server.auth.settings import AuthSettings @@ -225,6 +225,42 @@ async def my_checker(claims: VerifiedClaims, raw_token: str) -> bool: assert verifier_kwargs["revocation_checker"] is my_checker +@pytest.mark.asyncio +async def test_authplane_mcp_auth_fail_closed_default_is_false(): + """When fail_closed is not passed, False is forwarded (fail-open behavior).""" + mock_client = MagicMock() + mock_client.resource = MagicMock(return_value=MagicMock(resource="https://api.example.com")) + + with patch("authplane_mcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock(return_value=mock_client) + await authplane_mcp_auth( + issuer="https://auth.example.com", + resource="https://api.example.com", + ) + + verifier_kwargs = mock_client.resource.call_args.kwargs + assert verifier_kwargs["fail_closed"] is False + + +@pytest.mark.asyncio +async def test_authplane_mcp_auth_fail_closed_forwarded(): + """fail_closed=True is forwarded to client.resource().""" + mock_client = MagicMock() + mock_client.resource = MagicMock(return_value=MagicMock(resource="https://api.example.com")) + + with patch("authplane_mcp.auth.AuthplaneClient") as mock_client_cls: + mock_client_cls.create = AsyncMock(return_value=mock_client) + await authplane_mcp_auth( + issuer="https://auth.example.com", + resource="https://api.example.com", + revocation_checker=IntrospectionRevocation(), + fail_closed=True, + ) + + verifier_kwargs = mock_client.resource.call_args.kwargs + assert verifier_kwargs["fail_closed"] is True + + @pytest.mark.asyncio async def test_authplane_mcp_auth_as_credentials_passthrough(): """as_credentials is forwarded to AuthplaneClient.create as auth.""" diff --git a/tests/verifier/test_revocation.py b/tests/verifier/test_revocation.py index 27fe7d0..25ef763 100644 --- a/tests/verifier/test_revocation.py +++ b/tests/verifier/test_revocation.py @@ -161,6 +161,33 @@ async def crashing_checker(claims: Any, raw_token: str) -> bool: await c.aclose() +async def test_custom_revocation_checker_error_fail_closed_rejects( + mock_jwks: Route, + token_factory: Any, +) -> None: + """fail_closed=True -> a crashing revocation checker rejects the token.""" + + async def crashing_checker(claims: Any, raw_token: str) -> bool: + raise RuntimeError("revocation backend unavailable") + + c = await AuthplaneClient.create( + issuer=ISSUER, + fetch_settings=FetchSettings(ssrf_protection=False), + ) + v = c.resource( + resource=RESOURCE, + scopes=["read:data"], + revocation_checker=crashing_checker, + fail_closed=True, + ) + try: + token = token_factory() + with pytest.raises(TokenRevokedError): + await v.verify(token) + finally: + await c.aclose() + + # --------------------------------------------------------------------------- # Built-in introspection tests # --------------------------------------------------------------------------- @@ -204,6 +231,25 @@ async def test_introspection_http_error_fails_open( assert claims.sub == "user123" +async def test_introspection_http_error_fail_closed_rejects( + client_with_introspection: AuthplaneClient, + token_factory: Any, +) -> None: + """fail_closed=True -> introspection endpoint outage (HTTP 500) rejects the token.""" + v = client_with_introspection.resource( + resource=RESOURCE, + scopes=["read:data"], + revocation_checker=IntrospectionRevocation(), + fail_closed=True, + ) + respx.post(INTROSPECTION_URL).mock( + return_value=respx.MockResponse(500, json={"error": "server_error"}) + ) + token = token_factory() + with pytest.raises(TokenRevokedError): + await v.verify(token) + + async def test_introspection_no_endpoint_in_metadata_skips( mock_jwks: Route, # metadata without introspection_endpoint token_factory: Any, From 0520af71552500f016e39612aac238b0868e72cc Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Tue, 28 Jul 2026 16:08:16 -0300 Subject: [PATCH 2/8] style: apply ruff 0.16 formatting to Markdown code blocks ruff 0.16.0 started formatting Python code blocks inside Markdown files, so CI (which installs the latest ruff satisfying >=0.8) rejects the previously formatted docs. Blank-line normalization only, no content changes. --- README.md | 2 ++ authplane-fastmcp/README.md | 4 +--- authplane-fastmcp/docs/user-guide.md | 35 ++++++++++++++++++---------- authplane-mcp/docs/user-guide.md | 20 +++++++++++----- authplane/docs/user-guide.md | 20 +++++++++------- 5 files changed, 52 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 83c55c4..2e339c5 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ from authplane_fastmcp import authplane_auth from fastmcp import FastMCP from fastmcp.server.auth import require_scopes + async def main() -> None: result = await authplane_auth( issuer="https://auth.company.com", @@ -37,6 +38,7 @@ async def main() -> None: finally: await result.aclose() + asyncio.run(main()) ``` diff --git a/authplane-fastmcp/README.md b/authplane-fastmcp/README.md index 310db36..f061e60 100644 --- a/authplane-fastmcp/README.md +++ b/authplane-fastmcp/README.md @@ -32,9 +32,7 @@ async def main(): ) @mcp.tool(auth=require_scopes("tools/query")) - async def query_database( - query: str, token: AccessToken = CurrentAccessToken() - ) -> str: + async def query_database(query: str, token: AccessToken = CurrentAccessToken()) -> str: user_id = token.claims.get("sub") return f"Query: {query}, User: {user_id}" diff --git a/authplane-fastmcp/docs/user-guide.md b/authplane-fastmcp/docs/user-guide.md index 5dec286..67c3ed0 100644 --- a/authplane-fastmcp/docs/user-guide.md +++ b/authplane-fastmcp/docs/user-guide.md @@ -37,6 +37,7 @@ import asyncio from fastmcp import FastMCP from authplane_fastmcp import authplane_auth + async def main() -> None: result = await authplane_auth( issuer="https://auth.company.com", @@ -55,6 +56,7 @@ async def main() -> None: finally: await result.aclose() + asyncio.run(main()) ``` @@ -99,11 +101,13 @@ Use FastMCP's built-in `require_scopes` decorator to enforce per-tool scope requ ```python 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 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.""" @@ -120,21 +124,22 @@ FastMCP enforces scopes **before** the handler runs by **filtering tools the cal from fastmcp.dependencies import CurrentAccessToken from fastmcp.server.auth import AccessToken + @mcp.tool() async def my_tool(data: str, token: AccessToken = CurrentAccessToken()) -> str: # Standard JWT claims - sub = token.claims.get("sub") # Subject (user ID) - jti = token.claims.get("jti") # JWT ID - iss = token.claims.get("iss") # Issuer - aud = token.claims.get("aud") # Audience - exp = token.claims.get("exp") # Expiration (Unix timestamp) - nbf = token.claims.get("nbf") # Not before - iat = token.claims.get("iat") # Issued at + sub = token.claims.get("sub") # Subject (user ID) + jti = token.claims.get("jti") # JWT ID + iss = token.claims.get("iss") # Issuer + aud = token.claims.get("aud") # Audience + exp = token.claims.get("exp") # Expiration (Unix timestamp) + nbf = token.claims.get("nbf") # Not before + iat = token.claims.get("iat") # Issued at # OAuth claims - client_id = token.client_id # Client ID - scopes = token.scopes # List of granted scopes - expires_at = token.expires_at # Expiration (Unix timestamp) + client_id = token.client_id # Client ID + scopes = token.scopes # List of granted scopes + expires_at = token.expires_at # Expiration (Unix timestamp) # Custom claims tenant = token.claims.get("tenant_id") @@ -150,6 +155,7 @@ The `claims` dict contains the **full JWT payload** including all standard and c ```python from fastmcp.server.dependencies import get_access_token + @mcp.tool() async def my_tool(data: str) -> str: token = get_access_token() # Returns None if unauthenticated @@ -247,10 +253,12 @@ Implement your own revocation logic with an async callable: ```python from authplane import VerifiedClaims + async def check_blocklist(claims: VerifiedClaims, raw_token: str) -> bool: """Return True to reject the token (it is revoked).""" return await redis_client.sismember("revoked_tokens", claims.jti) + await authplane_auth( issuer="https://auth.company.com", base_url="https://mcp.company.com", @@ -280,8 +288,8 @@ result = await authplane_auth( downstream = await result.client.exchange( TokenExchangeOptions( subject_token=inbound_token, - scope="tools/add", # narrow to the minimum - resources=("https://downstream.example",), # RFC 8707 audience binding + scope="tools/add", # narrow to the minimum + resources=("https://downstream.example",), # RFC 8707 audience binding ) ) @@ -314,6 +322,7 @@ 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: @@ -406,6 +415,7 @@ When `fetch_settings` is provided, `dev_mode` is ignored for both metadata and J ```python import asyncio + async def main() -> None: result = await authplane_auth(...) try: @@ -414,6 +424,7 @@ async def main() -> None: finally: await result.aclose() + asyncio.run(main()) ``` diff --git a/authplane-mcp/docs/user-guide.md b/authplane-mcp/docs/user-guide.md index e22c970..877da61 100644 --- a/authplane-mcp/docs/user-guide.md +++ b/authplane-mcp/docs/user-guide.md @@ -102,12 +102,14 @@ Use the `require_scope()` helper at the top of tool handlers to enforce per-tool ```python from authplane_mcp import require_scope + @mcp.tool() async def query(sql: str) -> str: """Requires the tools/query scope.""" require_scope("tools/query") return f"Ran: {sql}" # replace with your real handler + @mcp.tool() async def delete_all() -> str: """Requires the tools/admin scope.""" @@ -128,14 +130,15 @@ Use the MCP SDK's `get_access_token()` to access the validated token in tool han ```python from mcp.server.auth.middleware.auth_context import get_access_token + @mcp.tool() async def my_tool(data: str) -> str: token = get_access_token() if token: - client_id = token.client_id # Client ID - scopes = token.scopes # List of granted scopes - expires_at = token.expires_at # Expiration (Unix timestamp) - resource = token.resource # Resource (audience) URL + client_id = token.client_id # Client ID + scopes = token.scopes # List of granted scopes + expires_at = token.expires_at # Expiration (Unix timestamp) + resource = token.resource # Resource (audience) URL return f"Processing {data}" ``` @@ -238,10 +241,12 @@ Implement your own revocation logic with an async callable: ```python from authplane import VerifiedClaims + async def check_blocklist(claims: VerifiedClaims, raw_token: str) -> bool: """Return True to reject the token (it is revoked).""" return await redis_client.sismember("revoked_tokens", claims.jti) + await authplane_mcp_auth( issuer="https://auth.company.com", resource="https://mcp.company.com", @@ -271,8 +276,8 @@ result = await authplane_mcp_auth( downstream = await result.client.exchange( TokenExchangeOptions( subject_token=inbound_token, - scope="tools/add", # narrow to the minimum - resources=("https://downstream.example",), # RFC 8707 audience binding + scope="tools/add", # narrow to the minimum + resources=("https://downstream.example",), # RFC 8707 audience binding ) ) @@ -305,6 +310,7 @@ The adapter handles this for you. The `client` returned by `authplane_mcp_auth(. ```python from authplane.oauth import TokenExchangeOptions + @mcp.tool() async def call_downstream(user_token: str, payload: str) -> str: downstream = await result.client.exchange( @@ -406,6 +412,7 @@ When `fetch_settings` is provided, `dev_mode` is ignored for both metadata and J ```python import asyncio + async def main() -> None: auth_result = await authplane_mcp_auth(...) try: @@ -414,6 +421,7 @@ async def main() -> None: finally: await auth_result.aclose() + asyncio.run(main()) ``` diff --git a/authplane/docs/user-guide.md b/authplane/docs/user-guide.md index 7925063..5a1146a 100644 --- a/authplane/docs/user-guide.md +++ b/authplane/docs/user-guide.md @@ -137,11 +137,11 @@ res = client.resource( resource="https://api.example.com", scopes=["read"], inbound_dpop=InboundDPoPOptions( - replay_store=InMemoryDPoPReplayStore(), # process-scoped by default + replay_store=InMemoryDPoPReplayStore(), # process-scoped by default max_proof_age_seconds=300, clock_skew_seconds=30, allowed_proof_algorithms=("RS256", "ES256"), - required=True, # reject bearer-only tokens + required=True, # reject bearer-only tokens ), ) ``` @@ -159,13 +159,16 @@ For each incoming request that may carry a DPoP-bound token, build a ```python from dataclasses import dataclass + @dataclass class IncomingRequest: """Implements DPoPRequestContext.""" + method: str url: str proof: str | None + claims = await res.verify( token, dpop_request=IncomingRequest( @@ -261,6 +264,7 @@ res = client.resource( ```python from authplane import VerifiedClaims + async def my_revocation_checker(claims: VerifiedClaims, raw_token: str) -> bool: return claims.jti in revoked_jtis ``` @@ -415,12 +419,12 @@ For multi-instance or shared-state deployments, provide your own `DPoPNonceStore ```python from authplane import DPoPKeyMaterial, DPoPNonceStore, DPoPProvider + class MyNonceStore: - def get(self, key: str) -> str: - ... + def get(self, key: str) -> str: ... + + def put(self, key: str, nonce: str) -> None: ... - def put(self, key: str, nonce: str) -> None: - ... provider = DPoPProvider( DPoPKeyMaterial.from_pem(private_key_pem), @@ -597,8 +601,8 @@ except AuthplaneError as e: Generate an RFC 9728 protected resource metadata document with: ```python -prm = res.prm_response() # the document body (a dict) -url = res.prm_url() # the well-known URL where clients can fetch it +prm = res.prm_response() # the document body (a dict) +url = res.prm_url() # the well-known URL where clients can fetch it ``` Example output: From f0442bdab3926b2c35b46b158248f3049f250caf Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Tue, 28 Jul 2026 16:42:04 -0300 Subject: [PATCH 3/8] fix(client): warn on fail_closed without a checker; pin outage edge cases - AuthplaneClient.resource() logs a warning when fail_closed=True is combined with revocation_checker=None. The flag is a security-relevant no-op in that combination, and the misconfiguration should be observable in production, not only documented. The warning lives in the core factory rather than the adapters so direct resource() users get it too. - New core tests pin two fail-closed failure modes that were emergent: AS metadata without introspection_endpoint rejects every token (permanently, since it is a configuration property rather than an outage), and an open circuit breaker rejects all traffic without further HTTP calls (TokenRevokedError with CircuitOpenError as cause). - Both user guides gain the metadata trade-off bullet and note the new warning. --- CHANGELOG.md | 2 +- authplane-fastmcp/docs/user-guide.md | 3 +- authplane-mcp/docs/user-guide.md | 3 +- authplane/client.py | 10 ++++ tests/verifier/test_revocation.py | 90 ++++++++++++++++++++++++++++ 5 files changed, 105 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 666907f..c2e8885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- `authplane-fastmcp`, `authplane-mcp`: `authplane_auth()` and `authplane_mcp_auth()` accept `fail_closed: bool = False` and forward it to `AuthplaneClient.resource(...)`, so factory users can opt into rejecting tokens (`TokenRevokedError`) when the configured `revocation_checker` itself fails — e.g. an unreachable introspection endpoint — instead of the default fail-open acceptance. The flag is only consulted when a `revocation_checker` is configured. Both user guides document the availability/security trade-off, the authenticated-introspection credential requirement, and the circuit-breaker interaction. +- `authplane-fastmcp`, `authplane-mcp`: `authplane_auth()` and `authplane_mcp_auth()` accept `fail_closed: bool = False` and forward it to `AuthplaneClient.resource(...)`, so factory users can opt into rejecting tokens (`TokenRevokedError`) when the configured `revocation_checker` itself fails — e.g. an unreachable introspection endpoint — instead of the default fail-open acceptance. The flag is only consulted when a `revocation_checker` is configured; `AuthplaneClient.resource(...)` now logs a warning when `fail_closed=True` is set without one, since that combination runs no revocation check at all. Both user guides document the availability/security trade-off, the authenticated-introspection credential requirement, the circuit-breaker interaction, and the permanent rejection that results from AS metadata lacking `introspection_endpoint`. ## [0.3.0] - 2026-07-21 diff --git a/authplane-fastmcp/docs/user-guide.md b/authplane-fastmcp/docs/user-guide.md index 67c3ed0..0b62c80 100644 --- a/authplane-fastmcp/docs/user-guide.md +++ b/authplane-fastmcp/docs/user-guide.md @@ -244,7 +244,8 @@ Trade-offs to understand before enabling `fail_closed=True`: - **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. - **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. -- `fail_closed` has no effect when `revocation_checker` is `None` — the flag is only consulted when a revocation check actually runs. +- **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. +- `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. ### Custom Revocation Checker diff --git a/authplane-mcp/docs/user-guide.md b/authplane-mcp/docs/user-guide.md index 877da61..939859f 100644 --- a/authplane-mcp/docs/user-guide.md +++ b/authplane-mcp/docs/user-guide.md @@ -232,7 +232,8 @@ Trade-offs to understand before enabling `fail_closed=True`: - **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. - **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. -- `fail_closed` has no effect when `revocation_checker` is `None` — the flag is only consulted when a revocation check actually runs. +- **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. +- `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. ### Custom Revocation Checker diff --git a/authplane/client.py b/authplane/client.py index e1dc630..07010b4 100644 --- a/authplane/client.py +++ b/authplane/client.py @@ -422,6 +422,16 @@ def resource( """ from .verifier import AuthplaneResource + # fail_closed is only consulted when a revocation check runs; setting + # it without a checker means no revocation check happens at all, which + # is the opposite of what the operator asked for — make it observable. + if fail_closed and revocation_checker is None: + logger.warning( + "fail_closed=True has no effect without a revocation_checker: " + "no revocation check will run", + extra={"resource": resource}, + ) + return AuthplaneResource( client=self, resource=resource, diff --git a/tests/verifier/test_revocation.py b/tests/verifier/test_revocation.py index 25ef763..d2e64b1 100644 --- a/tests/verifier/test_revocation.py +++ b/tests/verifier/test_revocation.py @@ -272,6 +272,96 @@ async def test_introspection_no_endpoint_in_metadata_skips( await c.aclose() +async def test_introspection_no_endpoint_in_metadata_fail_closed_rejects( + mock_jwks: Route, # metadata without introspection_endpoint + token_factory: Any, +) -> None: + """fail_closed=True + metadata without introspection_endpoint -> every token rejected. + + Unlike an outage this never recovers on its own: the missing endpoint is a + permanent property of the AS configuration, not a transient failure. + """ + c = await AuthplaneClient.create( + issuer=ISSUER, + fetch_settings=FetchSettings(ssrf_protection=False), + ) + v = c.resource( + resource=RESOURCE, + scopes=["read:data"], + revocation_checker=IntrospectionRevocation(), + fail_closed=True, + ) + try: + token = token_factory() + with pytest.raises(TokenRevokedError): + await v.verify(token) + finally: + await c.aclose() + + +async def test_introspection_open_circuit_fail_closed_rejects( + mock_jwks_with_introspection: None, + token_factory: Any, +) -> None: + """fail_closed=True + open circuit breaker -> all tokens rejected until cooldown. + + With threshold=1 a single introspection 500 opens the breaker; the next + verify() is rejected before any HTTP call (CircuitOpenError -> TokenRevokedError). + """ + from authplane.errors import CircuitOpenError + + c = await AuthplaneClient.create( + issuer=ISSUER, + fetch_settings=FetchSettings(ssrf_protection=False), + circuit_breaker_threshold=1, + ) + v = c.resource( + resource=RESOURCE, + scopes=["read:data"], + revocation_checker=IntrospectionRevocation(), + fail_closed=True, + ) + try: + introspection_route = respx.post(INTROSPECTION_URL).mock( + return_value=respx.MockResponse(500, json={"error": "server_error"}) + ) + token = token_factory() + + # First verify: the 500 rejects the token and trips the breaker. + with pytest.raises(TokenRevokedError): + await v.verify(token) + first_call_count = introspection_route.call_count + + # Second verify: rejected by the open breaker, no HTTP call made. + with pytest.raises(TokenRevokedError) as exc_info: + await v.verify(token) + assert isinstance(exc_info.value.__cause__, CircuitOpenError) + assert introspection_route.call_count == first_call_count + finally: + await c.aclose() + + +async def test_fail_closed_without_checker_warns( + mock_jwks: Route, + caplog: pytest.LogCaptureFixture, +) -> None: + """fail_closed=True with revocation_checker=None is a no-op -> logged warning.""" + c = await AuthplaneClient.create( + issuer=ISSUER, + fetch_settings=FetchSettings(ssrf_protection=False), + ) + try: + with caplog.at_level("WARNING", logger="authplane.client"): + c.resource( + resource=RESOURCE, + scopes=["read:data"], + fail_closed=True, + ) + assert any("fail_closed=True has no effect" in record.message for record in caplog.records) + finally: + await c.aclose() + + async def test_introspection_sends_correct_token( verifier_with_introspection: AuthplaneResource, token_factory: Any, From b2ccb965f4e1e43daf449bb3be96dd9fcfd21aa3 Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Wed, 29 Jul 2026 09:46:09 -0300 Subject: [PATCH 4/8] chore: pin ruff to >=0.16,<0.17 in all dev extras An unpinned ruff means CI silently adopts each new minor's lint and formatting behavior mid-development (0.16.0 started formatting Markdown code blocks, which is what forced the docs reformat on this branch). Pinning to the current minor keeps patch fixes and makes format checks reproducible; bumping the minor becomes a deliberate change. --- authplane-fastmcp/pyproject.toml | 2 +- authplane-mcp/pyproject.toml | 2 +- pyproject.toml | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/authplane-fastmcp/pyproject.toml b/authplane-fastmcp/pyproject.toml index 1572eb3..f591ed9 100644 --- a/authplane-fastmcp/pyproject.toml +++ b/authplane-fastmcp/pyproject.toml @@ -47,7 +47,7 @@ dev = [ "respx>=0.21", "cryptography>=42", "coverage>=7", - "ruff>=0.8", + "ruff>=0.16,<0.17", ] [tool.pytest.ini_options] diff --git a/authplane-mcp/pyproject.toml b/authplane-mcp/pyproject.toml index 7b29aa4..4f55476 100644 --- a/authplane-mcp/pyproject.toml +++ b/authplane-mcp/pyproject.toml @@ -46,7 +46,7 @@ dev = [ "respx>=0.21", "cryptography>=42", "coverage>=7", - "ruff>=0.8", + "ruff>=0.16,<0.17", ] [tool.pytest.ini_options] diff --git a/pyproject.toml b/pyproject.toml index d7e1a0e..8af60e7 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -38,7 +38,7 @@ dev = [ "cryptography>=42", "coverage>=7", "pyright>=1.1", - "ruff>=0.8", + "ruff>=0.16,<0.17", ] [project.urls] From 9fa816cacc717ec31a3416b6314bb4b5fcf912c7 Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Wed, 29 Jul 2026 09:56:46 -0300 Subject: [PATCH 5/8] docs: shorten the unreleased changelog entries --- CHANGELOG.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c2e8885..47358c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,7 +8,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] ### Added -- `authplane-fastmcp`, `authplane-mcp`: `authplane_auth()` and `authplane_mcp_auth()` accept `fail_closed: bool = False` and forward it to `AuthplaneClient.resource(...)`, so factory users can opt into rejecting tokens (`TokenRevokedError`) when the configured `revocation_checker` itself fails — e.g. an unreachable introspection endpoint — instead of the default fail-open acceptance. The flag is only consulted when a `revocation_checker` is configured; `AuthplaneClient.resource(...)` now logs a warning when `fail_closed=True` is set without one, since that combination runs no revocation check at all. Both user guides document the availability/security trade-off, the authenticated-introspection credential requirement, the circuit-breaker interaction, and the permanent rejection that results from AS metadata lacking `introspection_endpoint`. +- `authplane-fastmcp`, `authplane-mcp`: `authplane_auth()` and `authplane_mcp_auth()` accept `fail_closed: bool = False` and forward it to `AuthplaneClient.resource(...)`. +- `AuthplaneClient.resource(...)` logs a warning when `fail_closed=True` is set without a `revocation_checker`. ## [0.3.0] - 2026-07-21 From 8f45e214c912bc492a433186e805eaa0a2dc4831 Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Wed, 29 Jul 2026 10:06:14 -0300 Subject: [PATCH 6/8] chore(mcp): raise the mcp ceiling to <1.29.0 mcp 1.27.2, the newest release the previous <1.28.0 ceiling allowed, carries PYSEC-2026-3483; the fix ships in 1.28.1. Adapter tests pass against 1.28.1. --- authplane-mcp/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/authplane-mcp/pyproject.toml b/authplane-mcp/pyproject.toml index 4f55476..f43d34d 100644 --- a/authplane-mcp/pyproject.toml +++ b/authplane-mcp/pyproject.toml @@ -28,7 +28,7 @@ dependencies = [ "authplane-sdk", # mcp 1.23–1.27 use camelCase `elicitationId`; main has reverted # to snake_case, so 1.28+ will likely require an adapter update. - "mcp>=1.23.0,<1.28.0", + "mcp>=1.23.0,<1.29.0", "pydantic>=2.0", ] From 935572c618d4d44c01b439e7b233d5a672a2c837 Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Wed, 29 Jul 2026 12:23:43 -0300 Subject: [PATCH 7/8] fix(mcp): revert mcp ceiling to <1.28.0; document no-checker warning MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The <1.29.0 ceiling let mcp 1.28 resolve, but 1.28 renamed the elicitation field to snake_case elicitation_id, which the adapter's url_elicitation path does not handle — every consent-driven exchange would raise a pydantic ValidationError instead of -32042. Keep the ceiling below 1.28 until the adapter is migrated (README already documents the <1.28.0 range). Also document the fail_closed-without-revocation_checker warning in the core user-guide (the direct client.resource() audience); both adapter guides already cover it. --- authplane-mcp/pyproject.toml | 8 +++++--- authplane/docs/user-guide.md | 1 + 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/authplane-mcp/pyproject.toml b/authplane-mcp/pyproject.toml index f43d34d..bbb3e0b 100644 --- a/authplane-mcp/pyproject.toml +++ b/authplane-mcp/pyproject.toml @@ -26,9 +26,11 @@ classifiers = [ requires-python = ">=3.11" dependencies = [ "authplane-sdk", - # mcp 1.23–1.27 use camelCase `elicitationId`; main has reverted - # to snake_case, so 1.28+ will likely require an adapter update. - "mcp>=1.23.0,<1.29.0", + # mcp 1.23–1.27 use camelCase `elicitationId`; 1.28 renamed it to + # snake_case `elicitation_id`, which breaks this adapter's wire handling + # (see url_elicitation.py + README). Keep the ceiling below 1.28 until the + # adapter is migrated. + "mcp>=1.23.0,<1.28.0", "pydantic>=2.0", ] diff --git a/authplane/docs/user-guide.md b/authplane/docs/user-guide.md index 5a1146a..2ab36de 100644 --- a/authplane/docs/user-guide.md +++ b/authplane/docs/user-guide.md @@ -249,6 +249,7 @@ Important behavior: - set `fail_closed=True` to reject tokens when the revocation check fails - the client must have AS credentials configured - the AS metadata must expose `introspection_endpoint` +- `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. ```python # Fail-closed: reject tokens when introspection is unavailable From 97d01badadebca24af663c80dd196b4a9a09189b Mon Sep 17 00:00:00 2001 From: Roberto Iskandarani Date: Wed, 29 Jul 2026 16:09:25 -0300 Subject: [PATCH 8/8] ci(security): ignore PYSEC-2026-3483 pending mcp 1.28 adapter migration The adapter pins mcp <1.28.0 (1.28 renamed elicitationId -> elicitation_id, breaking url_elicitation.py), so pip-audit resolves the vulnerable mcp 1.27.2. Suppress the finding with a comment until the adapter is migrated to 1.28.1. --- .github/workflows/security.yml | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 4afa658..287f376 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -74,6 +74,16 @@ jobs: # latest released version, so `pip install --upgrade pip` can't pull # a patched build. Drop this ignore once pip >= 26.1 is on PyPI. # See https://github.com/pypa/pip/pull/13870. + # + # PYSEC-2026-3483: affects mcp <= 1.27.2 (fixed in 1.28.1). The + # authplane-mcp adapter pins mcp <1.28.0 because 1.28 renamed the + # elicitation field elicitationId -> elicitation_id (snake_case), + # which breaks url_elicitation.py's ElicitRequestURLParams wire + # handling (every consent-driven exchange would raise a pydantic + # ValidationError). Accepted risk until the adapter is migrated to + # the 1.28 field name and the floor is raised to 1.28.1; drop this + # ignore then. run: >- pip-audit --skip-editable --progress-spinner off --ignore-vuln CVE-2026-3219 + --ignore-vuln PYSEC-2026-3483