Skip to content

Commit f0442bd

Browse files
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.
1 parent 0520af7 commit f0442bd

5 files changed

Lines changed: 105 additions & 3 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
88
## [Unreleased]
99

1010
### 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.
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; `AuthplaneClient.resource(...)` now logs a warning when `fail_closed=True` is set without one, since that combination runs no revocation check at all. Both user guides document the availability/security trade-off, the authenticated-introspection credential requirement, the circuit-breaker interaction, and the permanent rejection that results from AS metadata lacking `introspection_endpoint`.
1212

1313
## [0.3.0] - 2026-07-21
1414

authplane-fastmcp/docs/user-guide.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -244,7 +244,8 @@ Trade-offs to understand before enabling `fail_closed=True`:
244244

245245
- **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.
246246
- **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-
- `fail_closed` has no effect when `revocation_checker` is `None` — the flag is only consulted when a revocation check actually runs.
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.
248249

249250
### Custom Revocation Checker
250251

authplane-mcp/docs/user-guide.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -232,7 +232,8 @@ Trade-offs to understand before enabling `fail_closed=True`:
232232

233233
- **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.
234234
- **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.
235-
- `fail_closed` has no effect when `revocation_checker` is `None` — the flag is only consulted when a revocation check actually runs.
235+
- **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.
236+
- `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.
236237

237238
### Custom Revocation Checker
238239

authplane/client.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -422,6 +422,16 @@ def resource(
422422
"""
423423
from .verifier import AuthplaneResource
424424

425+
# fail_closed is only consulted when a revocation check runs; setting
426+
# it without a checker means no revocation check happens at all, which
427+
# is the opposite of what the operator asked for — make it observable.
428+
if fail_closed and revocation_checker is None:
429+
logger.warning(
430+
"fail_closed=True has no effect without a revocation_checker: "
431+
"no revocation check will run",
432+
extra={"resource": resource},
433+
)
434+
425435
return AuthplaneResource(
426436
client=self,
427437
resource=resource,

tests/verifier/test_revocation.py

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -272,6 +272,96 @@ async def test_introspection_no_endpoint_in_metadata_skips(
272272
await c.aclose()
273273

274274

275+
async def test_introspection_no_endpoint_in_metadata_fail_closed_rejects(
276+
mock_jwks: Route, # metadata without introspection_endpoint
277+
token_factory: Any,
278+
) -> None:
279+
"""fail_closed=True + metadata without introspection_endpoint -> every token rejected.
280+
281+
Unlike an outage this never recovers on its own: the missing endpoint is a
282+
permanent property of the AS configuration, not a transient failure.
283+
"""
284+
c = await AuthplaneClient.create(
285+
issuer=ISSUER,
286+
fetch_settings=FetchSettings(ssrf_protection=False),
287+
)
288+
v = c.resource(
289+
resource=RESOURCE,
290+
scopes=["read:data"],
291+
revocation_checker=IntrospectionRevocation(),
292+
fail_closed=True,
293+
)
294+
try:
295+
token = token_factory()
296+
with pytest.raises(TokenRevokedError):
297+
await v.verify(token)
298+
finally:
299+
await c.aclose()
300+
301+
302+
async def test_introspection_open_circuit_fail_closed_rejects(
303+
mock_jwks_with_introspection: None,
304+
token_factory: Any,
305+
) -> None:
306+
"""fail_closed=True + open circuit breaker -> all tokens rejected until cooldown.
307+
308+
With threshold=1 a single introspection 500 opens the breaker; the next
309+
verify() is rejected before any HTTP call (CircuitOpenError -> TokenRevokedError).
310+
"""
311+
from authplane.errors import CircuitOpenError
312+
313+
c = await AuthplaneClient.create(
314+
issuer=ISSUER,
315+
fetch_settings=FetchSettings(ssrf_protection=False),
316+
circuit_breaker_threshold=1,
317+
)
318+
v = c.resource(
319+
resource=RESOURCE,
320+
scopes=["read:data"],
321+
revocation_checker=IntrospectionRevocation(),
322+
fail_closed=True,
323+
)
324+
try:
325+
introspection_route = respx.post(INTROSPECTION_URL).mock(
326+
return_value=respx.MockResponse(500, json={"error": "server_error"})
327+
)
328+
token = token_factory()
329+
330+
# First verify: the 500 rejects the token and trips the breaker.
331+
with pytest.raises(TokenRevokedError):
332+
await v.verify(token)
333+
first_call_count = introspection_route.call_count
334+
335+
# Second verify: rejected by the open breaker, no HTTP call made.
336+
with pytest.raises(TokenRevokedError) as exc_info:
337+
await v.verify(token)
338+
assert isinstance(exc_info.value.__cause__, CircuitOpenError)
339+
assert introspection_route.call_count == first_call_count
340+
finally:
341+
await c.aclose()
342+
343+
344+
async def test_fail_closed_without_checker_warns(
345+
mock_jwks: Route,
346+
caplog: pytest.LogCaptureFixture,
347+
) -> None:
348+
"""fail_closed=True with revocation_checker=None is a no-op -> logged warning."""
349+
c = await AuthplaneClient.create(
350+
issuer=ISSUER,
351+
fetch_settings=FetchSettings(ssrf_protection=False),
352+
)
353+
try:
354+
with caplog.at_level("WARNING", logger="authplane.client"):
355+
c.resource(
356+
resource=RESOURCE,
357+
scopes=["read:data"],
358+
fail_closed=True,
359+
)
360+
assert any("fail_closed=True has no effect" in record.message for record in caplog.records)
361+
finally:
362+
await c.aclose()
363+
364+
275365
async def test_introspection_sends_correct_token(
276366
verifier_with_introspection: AuthplaneResource,
277367
token_factory: Any,

0 commit comments

Comments
 (0)