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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 00c32b4..47358c7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ 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(...)`. +- `AuthplaneClient.resource(...)` logs a warning when `fail_closed=True` is set without a `revocation_checker`. + ## [0.3.0] - 2026-07-21 ### Added 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/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..0b62c80 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()) ``` @@ -82,6 +84,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. | @@ -98,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.""" @@ -119,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") @@ -149,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 @@ -210,9 +217,36 @@ 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. +- **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 Implement your own revocation logic with an async callable: @@ -220,10 +254,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", @@ -253,8 +289,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 ) ) @@ -287,6 +323,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: @@ -379,6 +416,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: @@ -387,6 +425,7 @@ async def main() -> None: finally: await result.aclose() + asyncio.run(main()) ``` @@ -474,6 +513,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/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-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..939859f 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. | @@ -101,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.""" @@ -127,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}" ``` @@ -201,9 +205,36 @@ 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. +- **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 Implement your own revocation logic with an async callable: @@ -211,10 +242,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", @@ -244,8 +277,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 ) ) @@ -278,6 +311,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( @@ -379,6 +413,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: @@ -387,6 +422,7 @@ async def main() -> None: finally: await auth_result.aclose() + asyncio.run(main()) ``` @@ -476,6 +512,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/pyproject.toml b/authplane-mcp/pyproject.toml index 7b29aa4..bbb3e0b 100644 --- a/authplane-mcp/pyproject.toml +++ b/authplane-mcp/pyproject.toml @@ -26,8 +26,10 @@ 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–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", ] @@ -46,7 +48,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/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/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/authplane/docs/user-guide.md b/authplane/docs/user-guide.md index 7925063..2ab36de 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( @@ -246,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 @@ -261,6 +265,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 +420,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 +602,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: 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] diff --git a/tests/verifier/test_revocation.py b/tests/verifier/test_revocation.py index 27fe7d0..d2e64b1 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, @@ -226,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,