Skip to content
Open
10 changes: 10 additions & 0 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -37,6 +38,7 @@ async def main() -> None:
finally:
await result.aclose()


asyncio.run(main())
```

Expand Down
4 changes: 1 addition & 3 deletions authplane-fastmcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}"

Expand Down
14 changes: 13 additions & 1 deletion authplane-fastmcp/authplane_fastmcp/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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``),
Expand Down Expand Up @@ -268,6 +279,7 @@ async def authplane_auth(
resource=resource,
scopes=resolved_scopes,
revocation_checker=revocation_checker,
fail_closed=fail_closed,
**verifier_kwargs,
)

Expand Down
66 changes: 53 additions & 13 deletions authplane-fastmcp/docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -55,6 +56,7 @@ async def main() -> None:
finally:
await result.aclose()


asyncio.run(main())
```

Expand Down Expand Up @@ -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. |

Expand All @@ -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."""
Expand All @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -210,20 +217,49 @@ 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:

```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",
Expand Down Expand Up @@ -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
)
)

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand All @@ -387,6 +425,7 @@ async def main() -> None:
finally:
await result.aclose()


asyncio.run(main())
```

Expand Down Expand Up @@ -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
```

Expand Down
2 changes: 1 addition & 1 deletion authplane-fastmcp/pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,7 @@ dev = [
"respx>=0.21",
"cryptography>=42",
"coverage>=7",
"ruff>=0.8",
"ruff>=0.16,<0.17",
]

[tool.pytest.ini_options]
Expand Down
42 changes: 41 additions & 1 deletion authplane-fastmcp/tests/test_auth_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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."""
Expand Down
14 changes: 13 additions & 1 deletion authplane-mcp/authplane_mcp/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -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``),
Expand Down Expand Up @@ -384,6 +395,7 @@ async def authplane_mcp_auth(
resource=resource,
scopes=resolved_scopes,
revocation_checker=revocation_checker,
fail_closed=fail_closed,
**verifier_kwargs,
)

Expand Down
Loading
Loading