diff --git a/src/agent_enrollment_protocol/agent/protected_resource.py b/src/agent_enrollment_protocol/agent/protected_resource.py index 2044eba..9803249 100644 --- a/src/agent_enrollment_protocol/agent/protected_resource.py +++ b/src/agent_enrollment_protocol/agent/protected_resource.py @@ -4,6 +4,7 @@ import math import re from collections.abc import Mapping +from contextlib import suppress from dataclasses import dataclass, field from types import MappingProxyType from typing import cast @@ -121,8 +122,8 @@ async def _fetch( # OpenAPI is advisory. A live challenge remains authoritative when discovery fails. methods = None anonymous: HttpResponse | None = None - policy_used = methods is not None - if methods is None or require_challenge: + policy_used = bool(methods) + if not methods or require_challenge: anonymous = await _send( transport, agent._maximum_response_bytes, @@ -146,7 +147,13 @@ async def _fetch( inspection = await session.inspect() if inspection.document.service.did != service_did: raise ValueError("AEP challenge Service DID did not match Inspect") - if methods is None: + if methods == (): + # Document refresh failure does not override the validated live challenge. + with suppress( + ValueError, OSError, httpx.HTTPError, TimeoutError, AgentCommandError + ): + await _openapi_methods(inspection, request.method, target, agent) + if not methods: methods = ( inspection.document.authentication.methods if inspection.document.authentication @@ -381,6 +388,11 @@ async def _openapi_methods( resolution = resolve_openapi_security( root=root, operation=selected, supported_schemes=supported ) + if resolution.disposition in { + OpenApiSecurityDisposition.PUBLIC, + OpenApiSecurityDisposition.OPTIONAL_ANONYMOUS, + }: + return () if resolution.disposition not in { OpenApiSecurityDisposition.REQUIRED, OpenApiSecurityDisposition.ALTERNATIVES, diff --git a/tests/test_protected_resource.py b/tests/test_protected_resource.py index c58a0cf..2f10220 100644 --- a/tests/test_protected_resource.py +++ b/tests/test_protected_resource.py @@ -229,6 +229,77 @@ def api_document(**changes: object) -> dict[str, object]: } +@pytest.mark.asyncio +@pytest.mark.parametrize( + "outcome", ["updated", "unchanged", "failed", "cancelled", "wrong-did", "unrelated", "denied"] +) +async def test_public_openapi_contradiction_revalidates_once(outcome: str) -> None: + configuration = openapi_configuration(api_document(security=[])) + initial = configuration.agent._inspect_transport + assert isinstance(initial, QueueTransport) + + class RefreshTransport(QueueTransport): + async def send(self, request: HttpRequest) -> HttpResponse: + if request.url.endswith("/openapi.json") and not self.responses: + self.requests.append(request) + if outcome == "cancelled": + raise asyncio.CancelledError + if outcome == "failed": + raise OSError("offline") + return HttpResponse( + status=200, + headers={"Content-Type": "application/json"}, + body=json.dumps( + api_document() if outcome == "updated" else api_document(security=[]) + ).encode(), + ) + return await super().send(request) + + metadata = RefreshTransport(*initial.responses) + configuration.agent._inspect_transport = metadata + transport = QueueTransport( + challenge(did="did:web:wrong.example") + if outcome == "wrong-did" + else HttpResponse(status=401) + if outcome == "unrelated" + else challenge(), + challenge() if outcome == "denied" else HttpResponse(status=200), + ) + configuration = replace( + configuration, + transport=transport, + request=HttpRequest( + method="GET", + url=URL, + headers={"Authorization": "Bearer secret", "Cookie": "secret", "X-Caller": "private"}, + ), + ) + if outcome == "cancelled": + with pytest.raises(asyncio.CancelledError): + await fetch_protected_resource(configuration) + elif outcome == "wrong-did": + with pytest.raises(ValueError, match="did not match"): + await fetch_protected_resource(configuration) + else: + assert (await fetch_protected_resource(configuration)).status == ( + 401 if outcome in {"unrelated", "denied"} else 200 + ) + documents = [request for request in metadata.requests if request.url.endswith("/openapi.json")] + assert len(documents) == (1 if outcome in {"wrong-did", "unrelated"} else 2) + assert all( + dict(request.headers) + == {"Accept": "application/vnd.oai.openapi+json;version=3.1, application/json"} + for request in documents + ) + assert len(transport.requests) == ( + 1 if outcome in {"cancelled", "wrong-did", "unrelated"} else 2 + ) + assert "Authorization" not in transport.requests[0].headers + assert "Cookie" not in transport.requests[0].headers + if len(transport.requests) == 2: + assert transport.requests[1].headers["Authorization"].startswith("AEP ") + + @pytest.mark.asyncio async def test_definitive_openapi_security_can_authenticate_without_probe() -> None: configuration = openapi_configuration(api_document())