Skip to content

Commit 02eb15c

Browse files
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.
1 parent c77a062 commit 02eb15c

8 files changed

Lines changed: 211 additions & 6 deletions

File tree

CHANGELOG.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,9 @@ 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(...)`, 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.
12+
1013
## [0.3.0] - 2026-07-21
1114

1215
### Added

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: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,7 @@ All parameters of `authplane_auth()`:
8282
| `clock_skew_seconds` | `int` | `30` | Leeway for `exp`/`nbf`/`iat` validation |
8383
| `dev_mode` | `bool` | `False` | Relaxes SSRF checks for local development |
8484
| `revocation_checker` | see [below](#token-revocation-checking) | `None` | Token revocation strategy |
85+
| `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)) |
8586
| `fetch_settings` | `FetchSettings` | `None` | Full SSRF / fetch settings applied to both metadata and JWKS fetches (overrides `dev_mode`) |
8687
| `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. |
8788

@@ -210,9 +211,35 @@ await authplane_auth(
210211

211212
- The introspection endpoint is automatically discovered from AS metadata.
212213
- 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).
214+
- **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)).
214215
- `as_credentials` enables authenticated introspection (recommended for production).
215216

217+
### Failure Policy: Fail-Open vs Fail-Closed
218+
219+
`fail_closed` controls what happens when the revocation check itself fails — the introspection endpoint is unreachable, returns an error, or a custom checker raises:
220+
221+
```python
222+
await authplane_auth(
223+
issuer="https://auth.company.com",
224+
base_url="https://mcp.company.com",
225+
revocation_checker=IntrospectionRevocation(),
226+
as_credentials=ASCredentials(
227+
client_id="my_resource_server",
228+
client_secret="secret",
229+
),
230+
fail_closed=True,
231+
)
232+
```
233+
234+
- `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.
235+
- `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.
236+
237+
Trade-offs to understand before enabling `fail_closed=True`:
238+
239+
- **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.
240+
- **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.
241+
- `fail_closed` has no effect when `revocation_checker` is `None` — the flag is only consulted when a revocation check actually runs.
242+
216243
### Custom Revocation Checker
217244

218245
Implement your own revocation logic with an async callable:
@@ -474,6 +501,7 @@ async def authplane_auth(
474501
fetch_settings: FetchSettings | None = None,
475502
inbound_dpop: InboundDPoPOptions | None = None,
476503
revocation_checker: IntrospectionRevocation | RevocationChecker | None = None,
504+
fail_closed: bool = False,
477505
) -> AuthplaneAuthResult
478506
```
479507

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

authplane-mcp/docs/user-guide.md

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ All parameters of `authplane_mcp_auth()`:
8585
| `clock_skew_seconds` | `int` | `30` | Leeway for `exp`/`nbf`/`iat` validation |
8686
| `dev_mode` | `bool` | `False` | Relaxes SSRF checks for local development |
8787
| `revocation_checker` | see [below](#token-revocation-checking) | `None` | Token revocation strategy |
88+
| `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)) |
8889
| `fetch_settings` | `FetchSettings` | `None` | Full SSRF / fetch settings applied to both metadata and JWKS fetches (overrides `dev_mode`) |
8990
| `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. |
9091

@@ -201,9 +202,35 @@ await authplane_mcp_auth(
201202

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

208+
### Failure Policy: Fail-Open vs Fail-Closed
209+
210+
`fail_closed` controls what happens when the revocation check itself fails — the introspection endpoint is unreachable, returns an error, or a custom checker raises:
211+
212+
```python
213+
await authplane_mcp_auth(
214+
issuer="https://auth.company.com",
215+
resource="https://mcp.company.com",
216+
revocation_checker=IntrospectionRevocation(),
217+
as_credentials=ASCredentials(
218+
client_id="my_resource_server",
219+
client_secret="secret",
220+
),
221+
fail_closed=True,
222+
)
223+
```
224+
225+
- `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.
226+
- `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.
227+
228+
Trade-offs to understand before enabling `fail_closed=True`:
229+
230+
- **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.
231+
- **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.
232+
- `fail_closed` has no effect when `revocation_checker` is `None` — the flag is only consulted when a revocation check actually runs.
233+
207234
### Custom Revocation Checker
208235

209236
Implement your own revocation logic with an async callable:
@@ -476,6 +503,7 @@ async def authplane_mcp_auth(
476503
fetch_settings: FetchSettings | None = None,
477504
inbound_dpop: InboundDPoPOptions | None = None,
478505
revocation_checker: IntrospectionRevocation | RevocationChecker | None = None,
506+
fail_closed: bool = False,
479507
) -> AuthplaneAuthResult
480508
```
481509

authplane-mcp/tests/test_auth.py

Lines changed: 37 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
from mcp.server.auth.provider import AccessToken
1010
from mcp.server.auth.settings import AuthSettings
1111

@@ -225,6 +225,42 @@ async def my_checker(claims: VerifiedClaims, raw_token: str) -> bool:
225225
assert verifier_kwargs["revocation_checker"] is my_checker
226226

227227

228+
@pytest.mark.asyncio
229+
async def test_authplane_mcp_auth_fail_closed_default_is_false():
230+
"""When fail_closed is not passed, False is forwarded (fail-open behavior)."""
231+
mock_client = MagicMock()
232+
mock_client.resource = MagicMock(return_value=MagicMock(resource="https://api.example.com"))
233+
234+
with patch("authplane_mcp.auth.AuthplaneClient") as mock_client_cls:
235+
mock_client_cls.create = AsyncMock(return_value=mock_client)
236+
await authplane_mcp_auth(
237+
issuer="https://auth.example.com",
238+
resource="https://api.example.com",
239+
)
240+
241+
verifier_kwargs = mock_client.resource.call_args.kwargs
242+
assert verifier_kwargs["fail_closed"] is False
243+
244+
245+
@pytest.mark.asyncio
246+
async def test_authplane_mcp_auth_fail_closed_forwarded():
247+
"""fail_closed=True is forwarded to client.resource()."""
248+
mock_client = MagicMock()
249+
mock_client.resource = MagicMock(return_value=MagicMock(resource="https://api.example.com"))
250+
251+
with patch("authplane_mcp.auth.AuthplaneClient") as mock_client_cls:
252+
mock_client_cls.create = AsyncMock(return_value=mock_client)
253+
await authplane_mcp_auth(
254+
issuer="https://auth.example.com",
255+
resource="https://api.example.com",
256+
revocation_checker=IntrospectionRevocation(),
257+
fail_closed=True,
258+
)
259+
260+
verifier_kwargs = mock_client.resource.call_args.kwargs
261+
assert verifier_kwargs["fail_closed"] is True
262+
263+
228264
@pytest.mark.asyncio
229265
async def test_authplane_mcp_auth_as_credentials_passthrough():
230266
"""as_credentials is forwarded to AuthplaneClient.create as auth."""

tests/verifier/test_revocation.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,33 @@ async def crashing_checker(claims: Any, raw_token: str) -> bool:
161161
await c.aclose()
162162

163163

164+
async def test_custom_revocation_checker_error_fail_closed_rejects(
165+
mock_jwks: Route,
166+
token_factory: Any,
167+
) -> None:
168+
"""fail_closed=True -> a crashing revocation checker rejects the token."""
169+
170+
async def crashing_checker(claims: Any, raw_token: str) -> bool:
171+
raise RuntimeError("revocation backend unavailable")
172+
173+
c = await AuthplaneClient.create(
174+
issuer=ISSUER,
175+
fetch_settings=FetchSettings(ssrf_protection=False),
176+
)
177+
v = c.resource(
178+
resource=RESOURCE,
179+
scopes=["read:data"],
180+
revocation_checker=crashing_checker,
181+
fail_closed=True,
182+
)
183+
try:
184+
token = token_factory()
185+
with pytest.raises(TokenRevokedError):
186+
await v.verify(token)
187+
finally:
188+
await c.aclose()
189+
190+
164191
# ---------------------------------------------------------------------------
165192
# Built-in introspection tests
166193
# ---------------------------------------------------------------------------
@@ -204,6 +231,25 @@ async def test_introspection_http_error_fails_open(
204231
assert claims.sub == "user123"
205232

206233

234+
async def test_introspection_http_error_fail_closed_rejects(
235+
client_with_introspection: AuthplaneClient,
236+
token_factory: Any,
237+
) -> None:
238+
"""fail_closed=True -> introspection endpoint outage (HTTP 500) rejects the token."""
239+
v = client_with_introspection.resource(
240+
resource=RESOURCE,
241+
scopes=["read:data"],
242+
revocation_checker=IntrospectionRevocation(),
243+
fail_closed=True,
244+
)
245+
respx.post(INTROSPECTION_URL).mock(
246+
return_value=respx.MockResponse(500, json={"error": "server_error"})
247+
)
248+
token = token_factory()
249+
with pytest.raises(TokenRevokedError):
250+
await v.verify(token)
251+
252+
207253
async def test_introspection_no_endpoint_in_metadata_skips(
208254
mock_jwks: Route, # metadata without introspection_endpoint
209255
token_factory: Any,

0 commit comments

Comments
 (0)