Skip to content

Commit 0bba7df

Browse files
feat(fastmcp,mcp): expose fail_closed through the adapter auth factories (#21)
* 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. * 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. * 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. * 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. * docs: shorten the unreleased changelog entries * 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. * fix(mcp): revert mcp ceiling to <1.28.0; document no-checker warning 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. * 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.
1 parent c77a062 commit 0bba7df

16 files changed

Lines changed: 384 additions & 40 deletions

File tree

.github/workflows/security.yml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,16 @@ jobs:
7474
# latest released version, so `pip install --upgrade pip` can't pull
7575
# a patched build. Drop this ignore once pip >= 26.1 is on PyPI.
7676
# See https://github.com/pypa/pip/pull/13870.
77+
#
78+
# PYSEC-2026-3483: affects mcp <= 1.27.2 (fixed in 1.28.1). The
79+
# authplane-mcp adapter pins mcp <1.28.0 because 1.28 renamed the
80+
# elicitation field elicitationId -> elicitation_id (snake_case),
81+
# which breaks url_elicitation.py's ElicitRequestURLParams wire
82+
# handling (every consent-driven exchange would raise a pydantic
83+
# ValidationError). Accepted risk until the adapter is migrated to
84+
# the 1.28 field name and the floor is raised to 1.28.1; drop this
85+
# ignore then.
7786
run: >-
7887
pip-audit --skip-editable --progress-spinner off
7988
--ignore-vuln CVE-2026-3219
89+
--ignore-vuln PYSEC-2026-3483

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
### Added
11+
- `authplane-fastmcp`, `authplane-mcp`: `authplane_auth()` and `authplane_mcp_auth()` accept `fail_closed: bool = False` and forward it to `AuthplaneClient.resource(...)`.
12+
- `AuthplaneClient.resource(...)` logs a warning when `fail_closed=True` is set without a `revocation_checker`.
13+
1014
## [0.3.0] - 2026-07-21
1115

1216
### Added

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ from authplane_fastmcp import authplane_auth
2020
from fastmcp import FastMCP
2121
from fastmcp.server.auth import require_scopes
2222

23+
2324
async def main() -> None:
2425
result = await authplane_auth(
2526
issuer="https://auth.company.com",
@@ -37,6 +38,7 @@ async def main() -> None:
3738
finally:
3839
await result.aclose()
3940

41+
4042
asyncio.run(main())
4143
```
4244

authplane-fastmcp/README.md

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,7 @@ async def main():
3232
)
3333

3434
@mcp.tool(auth=require_scopes("tools/query"))
35-
async def query_database(
36-
query: str, token: AccessToken = CurrentAccessToken()
37-
) -> str:
35+
async def query_database(query: str, token: AccessToken = CurrentAccessToken()) -> str:
3836
user_id = token.claims.get("sub")
3937
return f"Query: {query}, User: {user_id}"
4038

authplane-fastmcp/authplane_fastmcp/auth.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ async def authplane_auth(
121121
inbound_dpop: InboundDPoPOptions | None = None,
122122
mcp_path: str = "/mcp",
123123
revocation_checker: IntrospectionRevocation | RevocationChecker | None = None,
124+
fail_closed: bool = False,
124125
) -> AuthplaneAuthResult:
125126
"""Build the kwargs to enable Authplane auth on a FastMCP server.
126127
@@ -201,10 +202,20 @@ async def authplane_auth(
201202
``introspection_endpoint`` (RFC 7662) discovered from AS
202203
metadata. Raises ``TokenRevokedError`` if ``active=false``.
203204
Pass ``as_credentials`` for authenticated introspection.
204-
Fails open if the endpoint is unavailable.
205+
Fails open if the endpoint is unavailable, unless
206+
``fail_closed=True``.
205207
- async callable: custom checker called with
206208
``(VerifiedClaims, raw_token)``; return ``True`` to reject
207209
the token (raises ``TokenRevokedError``).
210+
fail_closed: Policy applied when the configured
211+
``revocation_checker`` itself fails (e.g. the introspection
212+
endpoint is unreachable). ``False`` (default) accepts the
213+
token — offline signature/claims validation still applies.
214+
``True`` rejects it with ``TokenRevokedError``, trading
215+
availability during an AS outage for a hard revocation
216+
guarantee. Only consulted when a ``revocation_checker`` is
217+
configured; note that once the client's circuit breaker
218+
opens, every request is rejected until the cooldown elapses.
208219
209220
Returns:
210221
``AuthplaneAuthResult`` with ``auth`` (``RemoteAuthProvider``),
@@ -268,6 +279,7 @@ async def authplane_auth(
268279
resource=resource,
269280
scopes=resolved_scopes,
270281
revocation_checker=revocation_checker,
282+
fail_closed=fail_closed,
271283
**verifier_kwargs,
272284
)
273285

authplane-fastmcp/docs/user-guide.md

Lines changed: 53 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import asyncio
3737
from fastmcp import FastMCP
3838
from authplane_fastmcp import authplane_auth
3939

40+
4041
async def main() -> None:
4142
result = await authplane_auth(
4243
issuer="https://auth.company.com",
@@ -55,6 +56,7 @@ async def main() -> None:
5556
finally:
5657
await result.aclose()
5758

59+
5860
asyncio.run(main())
5961
```
6062

@@ -82,6 +84,7 @@ All parameters of `authplane_auth()`:
8284
| `clock_skew_seconds` | `int` | `30` | Leeway for `exp`/`nbf`/`iat` validation |
8385
| `dev_mode` | `bool` | `False` | Relaxes SSRF checks for local development |
8486
| `revocation_checker` | see [below](#token-revocation-checking) | `None` | Token revocation strategy |
87+
| `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)) |
8588
| `fetch_settings` | `FetchSettings` | `None` | Full SSRF / fetch settings applied to both metadata and JWKS fetches (overrides `dev_mode`) |
8689
| `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. |
8790

@@ -98,11 +101,13 @@ Use FastMCP's built-in `require_scopes` decorator to enforce per-tool scope requ
98101
```python
99102
from fastmcp.server.auth import require_scopes
100103

104+
101105
@mcp.tool(auth=require_scopes("tools/query"))
102106
def query(sql: str) -> str:
103107
"""Requires the tools/query scope."""
104108
return f"Ran: {sql}" # replace with your real handler
105109

110+
106111
@mcp.tool(auth=require_scopes("tools/admin", "tools/delete"))
107112
def delete_all() -> str:
108113
"""Requires BOTH tools/admin AND tools/delete scopes."""
@@ -119,21 +124,22 @@ FastMCP enforces scopes **before** the handler runs by **filtering tools the cal
119124
from fastmcp.dependencies import CurrentAccessToken
120125
from fastmcp.server.auth import AccessToken
121126

127+
122128
@mcp.tool()
123129
async def my_tool(data: str, token: AccessToken = CurrentAccessToken()) -> str:
124130
# Standard JWT claims
125-
sub = token.claims.get("sub") # Subject (user ID)
126-
jti = token.claims.get("jti") # JWT ID
127-
iss = token.claims.get("iss") # Issuer
128-
aud = token.claims.get("aud") # Audience
129-
exp = token.claims.get("exp") # Expiration (Unix timestamp)
130-
nbf = token.claims.get("nbf") # Not before
131-
iat = token.claims.get("iat") # Issued at
131+
sub = token.claims.get("sub") # Subject (user ID)
132+
jti = token.claims.get("jti") # JWT ID
133+
iss = token.claims.get("iss") # Issuer
134+
aud = token.claims.get("aud") # Audience
135+
exp = token.claims.get("exp") # Expiration (Unix timestamp)
136+
nbf = token.claims.get("nbf") # Not before
137+
iat = token.claims.get("iat") # Issued at
132138

133139
# OAuth claims
134-
client_id = token.client_id # Client ID
135-
scopes = token.scopes # List of granted scopes
136-
expires_at = token.expires_at # Expiration (Unix timestamp)
140+
client_id = token.client_id # Client ID
141+
scopes = token.scopes # List of granted scopes
142+
expires_at = token.expires_at # Expiration (Unix timestamp)
137143

138144
# Custom claims
139145
tenant = token.claims.get("tenant_id")
@@ -149,6 +155,7 @@ The `claims` dict contains the **full JWT payload** including all standard and c
149155
```python
150156
from fastmcp.server.dependencies import get_access_token
151157

158+
152159
@mcp.tool()
153160
async def my_tool(data: str) -> str:
154161
token = get_access_token() # Returns None if unauthenticated
@@ -210,20 +217,49 @@ await authplane_auth(
210217

211218
- The introspection endpoint is automatically discovered from AS metadata.
212219
- If the endpoint returns `active=false`, the token is rejected with `TokenRevokedError`.
213-
- **Fails open**: if the introspection endpoint is unavailable, the token is accepted (offline validation still applies).
220+
- **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)).
214221
- `as_credentials` enables authenticated introspection (recommended for production).
215222

223+
### Failure Policy: Fail-Open vs Fail-Closed
224+
225+
`fail_closed` controls what happens when the revocation check itself fails — the introspection endpoint is unreachable, returns an error, or a custom checker raises:
226+
227+
```python
228+
await authplane_auth(
229+
issuer="https://auth.company.com",
230+
base_url="https://mcp.company.com",
231+
revocation_checker=IntrospectionRevocation(),
232+
as_credentials=ASCredentials(
233+
client_id="my_resource_server",
234+
client_secret="secret",
235+
),
236+
fail_closed=True,
237+
)
238+
```
239+
240+
- `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.
241+
- `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.
242+
243+
Trade-offs to understand before enabling `fail_closed=True`:
244+
245+
- **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.
246+
- **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.
247+
- **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.
248+
- `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.
249+
216250
### Custom Revocation Checker
217251

218252
Implement your own revocation logic with an async callable:
219253

220254
```python
221255
from authplane import VerifiedClaims
222256

257+
223258
async def check_blocklist(claims: VerifiedClaims, raw_token: str) -> bool:
224259
"""Return True to reject the token (it is revoked)."""
225260
return await redis_client.sismember("revoked_tokens", claims.jti)
226261

262+
227263
await authplane_auth(
228264
issuer="https://auth.company.com",
229265
base_url="https://mcp.company.com",
@@ -253,8 +289,8 @@ result = await authplane_auth(
253289
downstream = await result.client.exchange(
254290
TokenExchangeOptions(
255291
subject_token=inbound_token,
256-
scope="tools/add", # narrow to the minimum
257-
resources=("https://downstream.example",), # RFC 8707 audience binding
292+
scope="tools/add", # narrow to the minimum
293+
resources=("https://downstream.example",), # RFC 8707 audience binding
258294
)
259295
)
260296

@@ -287,6 +323,7 @@ from authplane import ConsentRequiredError
287323
from authplane.oauth import TokenExchangeOptions
288324
from mcp.shared.exceptions import UrlElicitationRequiredError
289325

326+
290327
@mcp.tool(auth=require_scopes("tools/call_downstream"))
291328
async def call_downstream(payload: str) -> str:
292329
try:
@@ -379,6 +416,7 @@ When `fetch_settings` is provided, `dev_mode` is ignored for both metadata and J
379416
```python
380417
import asyncio
381418

419+
382420
async def main() -> None:
383421
result = await authplane_auth(...)
384422
try:
@@ -387,6 +425,7 @@ async def main() -> None:
387425
finally:
388426
await result.aclose()
389427

428+
390429
asyncio.run(main())
391430
```
392431

@@ -474,6 +513,7 @@ async def authplane_auth(
474513
fetch_settings: FetchSettings | None = None,
475514
inbound_dpop: InboundDPoPOptions | None = None,
476515
revocation_checker: IntrospectionRevocation | RevocationChecker | None = None,
516+
fail_closed: bool = False,
477517
) -> AuthplaneAuthResult
478518
```
479519

authplane-fastmcp/pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ dev = [
4747
"respx>=0.21",
4848
"cryptography>=42",
4949
"coverage>=7",
50-
"ruff>=0.8",
50+
"ruff>=0.16,<0.17",
5151
]
5252

5353
[tool.pytest.ini_options]

authplane-fastmcp/tests/test_auth_factory.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from unittest.mock import AsyncMock, MagicMock, patch
66

77
import pytest
8-
from authplane import DPoPProvider, FetchSettings, VerifiedClaims
8+
from authplane import DPoPProvider, FetchSettings, IntrospectionRevocation, VerifiedClaims
99

1010
from authplane_fastmcp import authplane_auth
1111
from authplane_fastmcp.auth import AuthplaneAuthResult
@@ -147,6 +147,46 @@ async def my_checker(claims: VerifiedClaims, raw_token: str) -> bool:
147147
assert verifier_kwargs["revocation_checker"] is my_checker
148148

149149

150+
@pytest.mark.asyncio
151+
async def test_authplane_auth_fail_closed_default_is_false():
152+
"""When fail_closed is not passed, False is forwarded (fail-open behavior)."""
153+
mock_client = MagicMock()
154+
_mock_resource = MagicMock()
155+
_mock_resource.resource = "https://api.example.com/mcp"
156+
mock_client.resource = MagicMock(return_value=_mock_resource)
157+
158+
with patch("authplane_fastmcp.auth.AuthplaneClient") as mock_client_cls:
159+
mock_client_cls.create = AsyncMock(return_value=mock_client)
160+
await authplane_auth(
161+
issuer="https://auth.example.com",
162+
base_url="https://api.example.com",
163+
)
164+
165+
verifier_kwargs = mock_client.resource.call_args.kwargs
166+
assert verifier_kwargs["fail_closed"] is False
167+
168+
169+
@pytest.mark.asyncio
170+
async def test_authplane_auth_fail_closed_forwarded():
171+
"""fail_closed=True is forwarded to client.resource()."""
172+
mock_client = MagicMock()
173+
_mock_resource = MagicMock()
174+
_mock_resource.resource = "https://api.example.com/mcp"
175+
mock_client.resource = MagicMock(return_value=_mock_resource)
176+
177+
with patch("authplane_fastmcp.auth.AuthplaneClient") as mock_client_cls:
178+
mock_client_cls.create = AsyncMock(return_value=mock_client)
179+
await authplane_auth(
180+
issuer="https://auth.example.com",
181+
base_url="https://api.example.com",
182+
revocation_checker=IntrospectionRevocation(),
183+
fail_closed=True,
184+
)
185+
186+
verifier_kwargs = mock_client.resource.call_args.kwargs
187+
assert verifier_kwargs["fail_closed"] is True
188+
189+
150190
@pytest.mark.asyncio
151191
async def test_authplane_auth_resource_derivation():
152192
"""Verify resource URL construction from base_url and mcp_path."""

authplane-mcp/authplane_mcp/auth.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -222,6 +222,7 @@ async def authplane_mcp_auth(
222222
fetch_settings: FetchSettings | None = None,
223223
inbound_dpop: InboundDPoPOptions | None = None,
224224
revocation_checker: IntrospectionRevocation | RevocationChecker | None = None,
225+
fail_closed: bool = False,
225226
) -> AuthplaneAuthResult:
226227
"""Build the kwargs to enable Authplane auth on a FastMCP server.
227228
@@ -323,10 +324,20 @@ async def authplane_mcp_auth(
323324
``introspection_endpoint`` (RFC 7662) discovered from AS
324325
metadata. Raises ``TokenRevokedError`` if ``active=false``.
325326
Pass ``as_credentials`` for authenticated introspection.
326-
Fails open if the endpoint is unavailable.
327+
Fails open if the endpoint is unavailable, unless
328+
``fail_closed=True``.
327329
- async callable: custom checker called with
328330
``(VerifiedClaims, raw_token)``; return ``True`` to reject
329331
the token (raises ``TokenRevokedError``).
332+
fail_closed: Policy applied when the configured
333+
``revocation_checker`` itself fails (e.g. the introspection
334+
endpoint is unreachable). ``False`` (default) accepts the
335+
token — offline signature/claims validation still applies.
336+
``True`` rejects it with ``TokenRevokedError``, trading
337+
availability during an AS outage for a hard revocation
338+
guarantee. Only consulted when a ``revocation_checker`` is
339+
configured; note that once the client's circuit breaker
340+
opens, every request is rejected until the cooldown elapses.
330341
331342
Returns:
332343
``AuthplaneAuthResult`` with ``token_verifier`` (``AuthplaneTokenVerifier``),
@@ -384,6 +395,7 @@ async def authplane_mcp_auth(
384395
resource=resource,
385396
scopes=resolved_scopes,
386397
revocation_checker=revocation_checker,
398+
fail_closed=fail_closed,
387399
**verifier_kwargs,
388400
)
389401

0 commit comments

Comments
 (0)