Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 15 additions & 3 deletions src/agent_enrollment_protocol/agent/protected_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand Down
71 changes: 71 additions & 0 deletions tests/test_protected_resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading