From c429a086cebf10a86564157ff63b0765b7b0de86 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Mon, 24 Aug 2026 17:45:45 +0200 Subject: [PATCH 1/3] feat(oauth): add secure buyer authorization helpers --- README.md | 56 ++ src/adcp/__init__.py | 50 ++ src/adcp/oauth.py | 888 ++++++++++++++++++++++ tests/fixtures/public_api_snapshot.json | 15 + tests/test_oauth.py | 969 ++++++++++++++++++++++++ tests/type_checks/oauth.py | 31 + 6 files changed, 2009 insertions(+) create mode 100644 src/adcp/oauth.py create mode 100644 tests/test_oauth.py create mode 100644 tests/type_checks/oauth.py diff --git a/README.md b/README.md index f22489cd1..490967f5d 100644 --- a/README.md +++ b/README.md @@ -20,6 +20,7 @@ This README serves both sides of an AdCP integration. Jump to what you're doing: - [Building an AdCP Agent](#building-an-adcp-agent) - [Multi-agent discovery manifest](#multi-agent-discovery-manifest) - [Connecting to AdCP Agents](#connecting-to-adcp-agents) + - [Buyer OAuth authorization code with PKCE](#buyer-oauth-authorization-code-with-pkce) - [The Core Concept](#the-core-concept) - [Installation](#installation) - [Quick Start: Test Helpers](#quick-start-test-helpers) @@ -141,6 +142,61 @@ serve( ## Connecting to AdCP Agents +### Buyer OAuth authorization code with PKCE + +`adcp.oauth` provides a hardened, pre-registered **public-client** flow. Pass a +trusted authorization-server issuer URL (not an MCP resource URL), bind `state` +to the user's browser session, and keep pending flows server-side: + +```python +from adcp.oauth import ( + InMemoryPendingOAuthFlowStore, + OAuthIssuerBinding, + complete_oauth_authorization, + discover_oauth_metadata, + start_oauth_authorization, +) + +pending = InMemoryPendingOAuthFlowStore() # development / one process only +metadata = await discover_oauth_metadata("https://login.example.com/tenant") + +request = await start_oauth_authorization( + metadata, + client_id="registered-public-client-id", + redirect_uri="https://buyer.example.com/oauth/callback", + store=pending, + issuer_binding=OAuthIssuerBinding.AUTHORIZATION_RESPONSE_ISS, + scopes=["media.buy"], + resource="https://seller.example.com/mcp", +) +# Store request.state in the authenticated browser session, then redirect the +# browser to request.authorization_url. + +tokens = await complete_oauth_authorization( + code=callback_query.get("code"), + callback_state=callback_query["state"], + expected_state=browser_session["oauth_state"], + callback_issuer=callback_query.get("iss"), + store=pending, +) +bearer = tokens.access_token.get_secret_value() +``` + +Discovery requires an exact RFC 8414 issuer match, S256 PKCE, authorization +code support, and `token_endpoint_auth_methods_supported: ["none", ...]`. +Metadata and token requests are size-bounded, ignore proxy environment +variables, reject redirects/compression, and use DNS-pinned transports. For an +authorization server without RFC 9207 `iss` support, use +`DISTINCT_REDIRECT_URI` only when that callback URI is exclusive to one issuer. +Multi-process deployments must implement `PendingOAuthFlowStore` using shared +storage with atomic insert-if-absent and consume operations. Encrypt the real +`SecretStr` verifier value at rest; JSON-serializing the model produces the +masked display value, not a recoverable verifier. Once completion consumes a +flow, any failure or cancellation requires starting a new one instead of +retrying it. Plain HTTP is disabled by default; `allow_loopback_http=True` is a +development/native app escape limited to literal loopback IPs and is persisted +with the flow. + ## The Core Concept AdCP operations are **distributed and asynchronous by default**. An agent might: diff --git a/src/adcp/__init__.py b/src/adcp/__init__.py index 7b00d1cf8..6f9cbee89 100644 --- a/src/adcp/__init__.py +++ b/src/adcp/__init__.py @@ -149,6 +149,23 @@ def _resolve_version() -> str: "inject_trace_headers", "is_tracing_available", ), + "adcp.oauth": ( + "InMemoryPendingOAuthFlowStore", + "OAuthAuthorizationError", + "OAuthAuthorizationRequest", + "OAuthAuthorizationServerMetadata", + "OAuthClientError", + "OAuthDiscoveryError", + "OAuthFlowStoreError", + "OAuthIssuerBinding", + "OAuthTokenExchangeError", + "OAuthTokenSet", + "PendingOAuthAuthorization", + "PendingOAuthFlowStore", + "complete_oauth_authorization", + "discover_oauth_metadata", + "start_oauth_authorization", + ), "adcp.exceptions": ( "AdagentsAccessBlockedError", "AdagentsNotFoundError", @@ -857,6 +874,22 @@ def get_adcp_version() -> str: "get_tracer", "inject_trace_headers", "is_tracing_available", + # Buyer OAuth authorization-code helpers + "InMemoryPendingOAuthFlowStore", + "OAuthAuthorizationError", + "OAuthAuthorizationRequest", + "OAuthAuthorizationServerMetadata", + "OAuthClientError", + "OAuthDiscoveryError", + "OAuthFlowStoreError", + "OAuthIssuerBinding", + "OAuthTokenExchangeError", + "OAuthTokenSet", + "PendingOAuthAuthorization", + "PendingOAuthFlowStore", + "complete_oauth_authorization", + "discover_oauth_metadata", + "start_oauth_authorization", "RegistryClient", "PropertyRegistry", "RegistrySync", @@ -1559,6 +1592,23 @@ def get_adcp_version() -> str: FeedStateStore, RefreshResult, ) + from adcp.oauth import ( + InMemoryPendingOAuthFlowStore, + OAuthAuthorizationError, + OAuthAuthorizationRequest, + OAuthAuthorizationServerMetadata, + OAuthClientError, + OAuthDiscoveryError, + OAuthFlowStoreError, + OAuthIssuerBinding, + OAuthTokenExchangeError, + OAuthTokenSet, + PendingOAuthAuthorization, + PendingOAuthFlowStore, + complete_oauth_authorization, + discover_oauth_metadata, + start_oauth_authorization, + ) from adcp.observability import get_tracer, inject_trace_headers, is_tracing_available from adcp.property_registry import PropertyRegistry from adcp.registry import RegistryClient diff --git a/src/adcp/oauth.py b/src/adcp/oauth.py new file mode 100644 index 000000000..031a6ad96 --- /dev/null +++ b/src/adcp/oauth.py @@ -0,0 +1,888 @@ +"""Security-focused OAuth 2.0 authorization-code helpers for buyer clients. + +This module deliberately implements a small, explicit surface: + +* RFC 8414 authorization-server discovery from a *trusted issuer URL*; +* pre-registered public clients (no dynamic registration or client secrets); +* authorization code with RFC 7636 S256 PKCE; +* one-time, atomically consumed pending flows; and +* bounded, DNS-pinned metadata and token HTTP requests. + +It does not discover an authorization server from an MCP resource URL. A +resource-to-issuer flow first needs RFC 9728 protected-resource metadata; pass +the resulting trusted issuer URL to :func:`discover_oauth_metadata`. +""" + +from __future__ import annotations + +import asyncio +import base64 +import hashlib +import hmac +import ipaddress +import json +import math +import re +import secrets +from collections.abc import Awaitable, Callable, Mapping, Sequence +from datetime import datetime, timedelta, timezone +from enum import Enum +from typing import Annotated, Any, Protocol, TypeVar, runtime_checkable +from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit + +import httpx +from pydantic import ( + BaseModel, + ConfigDict, + Field, + SecretStr, + ValidationError, + field_validator, + model_validator, +) + +from adcp.signing._bounded_http import ResponseTooLargeError, async_read_limited_bytes +from adcp.signing.ip_pinned_transport import build_async_ip_pinned_transport +from adcp.signing.jwks import SSRFValidationError + +_MAX_HTTP_BODY_BYTES = 64 * 1024 +_MAX_URL_LENGTH = 2048 +_MAX_STATE_LENGTH = 512 +_MAX_CODE_LENGTH = 8192 +_MAX_CLIENT_ID_LENGTH = 512 +_MAX_SCOPE_LENGTH = 2048 +_MAX_RESOURCE_LENGTH = 2048 +_MAX_TOKEN_LENGTH = 8192 +_DEFAULT_FLOW_TTL_SECONDS = 600 +_DEFAULT_STORE_CAPACITY = 1024 +_SAFE_OAUTH_ERROR_CODES = frozenset( + { + "access_denied", + "invalid_client", + "invalid_grant", + "invalid_request", + "invalid_scope", + "invalid_target", + "server_error", + "temporarily_unavailable", + "unauthorized_client", + "unsupported_grant_type", + "unsupported_response_type", + } +) +_PKCE_VERIFIER_RE = re.compile(r"^[A-Za-z0-9._~-]{43,128}$") +_SCOPE_TOKEN_RE = re.compile(r"^[\x21\x23-\x5B\x5D-\x7E]+$") +_SCOPE_RE = re.compile(r"^[\x21\x23-\x5B\x5D-\x7E]+(?: [\x21\x23-\x5B\x5D-\x7E]+)*$") +_RESPONSE_TYPE_RE = _SCOPE_RE +_VSCHAR_RE = re.compile(r"^[\x20-\x7E]+$") +_TOKEN_TYPE_RE = re.compile(r"^[A-Za-z][A-Za-z0-9._~+:/-]{0,63}$") +_RESERVED_AUTHORIZATION_QUERY_KEYS = frozenset( + { + "client_id", + "code_challenge", + "code_challenge_method", + "redirect_uri", + "resource", + "response_type", + "scope", + "state", + } +) + + +class OAuthClientError(Exception): + """Base error whose message never contains remote response content.""" + + def __init__( + self, + code: str, + *, + phase: str, + status_code: int | None = None, + oauth_error: str | None = None, + retry_after_seconds: float | None = None, + ) -> None: + self.code = code + self.phase = phase + self.status_code = status_code + self.oauth_error = oauth_error + self.retry_after_seconds = retry_after_seconds + message = f"OAuth {phase} failed ({code})" + if status_code is not None: + message += f": HTTP {status_code}" + super().__init__(message) + + +class OAuthDiscoveryError(OAuthClientError): + """Authorization-server discovery failed safely.""" + + +class OAuthAuthorizationError(OAuthClientError): + """Authorization callback validation failed; start a new flow.""" + + +class OAuthTokenExchangeError(OAuthClientError): + """Token exchange failed; the consumed flow must not be retried.""" + + +class OAuthFlowStoreError(OAuthClientError): + """The pending-flow store rejected an insert or lookup.""" + + +_T = TypeVar("_T") +_TIMEOUT_ERRORS: tuple[type[BaseException], ...] = (TimeoutError, asyncio.TimeoutError) + + +class _BodyTimeoutError(Exception): + def __init__(self, cause: BaseException) -> None: + self.cause = cause + super().__init__() + + +class _OAuthDeadlineExpired(BaseException): + """Raised only for the SDK-owned absolute OAuth network deadline.""" + + +async def _run_with_deadline(factory: Callable[[], Awaitable[_T]], timeout: float) -> _T: + """Apply one Python-3.10-compatible deadline without relabeling inner timeouts.""" + + async def run() -> _T: + try: + return await factory() + except _TIMEOUT_ERRORS as exc: + raise _BodyTimeoutError(exc) from exc + + try: + return await asyncio.wait_for(run(), timeout=timeout) + except _BodyTimeoutError as exc: + raise exc.cause from exc.cause.__cause__ + except _TIMEOUT_ERRORS as exc: + raise _OAuthDeadlineExpired from exc + + +class OAuthIssuerBinding(str, Enum): + """How a redirect URI is bound to one authorization-server issuer.""" + + AUTHORIZATION_RESPONSE_ISS = "authorization_response_iss" + DISTINCT_REDIRECT_URI = "distinct_redirect_uri" + + +BoundedUrl = Annotated[str, Field(min_length=1, max_length=_MAX_URL_LENGTH)] +BoundedToken = Annotated[str, Field(min_length=1, max_length=256, pattern=r"^[\x21-\x7E]+$")] +ResponseType = Annotated[ + str, + Field(min_length=1, max_length=256, pattern=_RESPONSE_TYPE_RE.pattern), +] +ScopeToken = Annotated[ + str, + Field(min_length=1, max_length=256, pattern=_SCOPE_TOKEN_RE.pattern), +] +BoundedState = Annotated[ + str, + Field(min_length=43, max_length=_MAX_STATE_LENGTH, pattern=r"^[A-Za-z0-9_-]+$"), +] + + +def _validate_absolute_url( + value: str, + *, + field: str, + issuer: bool = False, + allow_loopback_http: bool = False, +) -> str: + if "\\" in value or any(ord(char) < 0x20 or ord(char) == 0x7F for char in value): + raise ValueError(f"{field} contains an unsafe character") + parts = urlsplit(value) + if parts.username is not None or parts.password is not None: + raise ValueError(f"{field} must not contain user information") + if not parts.hostname or parts.fragment: + raise ValueError(f"{field} must be an absolute URL without a fragment") + try: + parts.port + except ValueError: + raise ValueError(f"{field} contains an invalid port") from None + if issuer and parts.query: + raise ValueError("issuer must not contain a query") + if parts.scheme == "https": + return value + if parts.scheme == "http" and allow_loopback_http and _is_literal_loopback(parts.hostname): + return value + raise ValueError(f"{field} must use HTTPS") + + +def _is_literal_loopback(host: str) -> bool: + try: + return ipaddress.ip_address(host).is_loopback + except ValueError: + return False + + +class OAuthAuthorizationServerMetadata(BaseModel): + """Bounded subset of RFC 8414 metadata used by this helper.""" + + model_config = ConfigDict(extra="ignore", frozen=True) + + issuer: BoundedUrl + authorization_endpoint: BoundedUrl + token_endpoint: BoundedUrl + response_types_supported: tuple[ResponseType, ...] = Field(min_length=1, max_length=32) + grant_types_supported: tuple[BoundedToken, ...] | None = Field( + default=None, min_length=1, max_length=32 + ) + token_endpoint_auth_methods_supported: tuple[BoundedToken, ...] = Field( + min_length=1, max_length=32 + ) + code_challenge_methods_supported: tuple[BoundedToken, ...] = Field(min_length=1, max_length=16) + scopes_supported: tuple[ScopeToken, ...] | None = Field( + default=None, min_length=1, max_length=128 + ) + authorization_response_iss_parameter_supported: bool = False + + @field_validator("issuer") + @classmethod + def _issuer_url(cls, value: str) -> str: + return _validate_absolute_url( + value, + field="issuer", + issuer=True, + allow_loopback_http=True, + ) + + @field_validator("authorization_endpoint", "token_endpoint") + @classmethod + def _endpoint_url(cls, value: str) -> str: + return _validate_absolute_url( + value, + field="OAuth endpoint", + allow_loopback_http=True, + ) + + @field_validator("authorization_endpoint") + @classmethod + def _reserved_authorization_query(cls, value: str) -> str: + query_keys = [key for key, _ in parse_qsl(urlsplit(value).query, keep_blank_values=True)] + if any(key in _RESERVED_AUTHORIZATION_QUERY_KEYS for key in query_keys): + raise ValueError("authorization endpoint contains a reserved OAuth query parameter") + if len(query_keys) != len(set(query_keys)): + raise ValueError("authorization endpoint contains duplicate query parameters") + return value + + +class OAuthAuthorizationRequest(BaseModel): + """Browser-facing result. The PKCE verifier is intentionally absent.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + authorization_url: BoundedUrl + state: BoundedState + expires_at: datetime + + @field_validator("expires_at") + @classmethod + def _aware_expiry(cls, value: datetime) -> datetime: + if value.tzinfo is None: + raise ValueError("expires_at must be timezone-aware") + return value + + +class PendingOAuthAuthorization(BaseModel): + """Authoritative server-side snapshot for one authorization attempt.""" + + model_config = ConfigDict(extra="forbid", frozen=True) + + state: BoundedState + code_verifier: SecretStr + issuer: BoundedUrl + authorization_endpoint: BoundedUrl + token_endpoint: BoundedUrl + client_id: Annotated[str, Field(min_length=1, max_length=_MAX_CLIENT_ID_LENGTH)] + redirect_uri: BoundedUrl + scope: Annotated[str | None, Field(max_length=_MAX_SCOPE_LENGTH)] = None + resource: Annotated[str | None, Field(max_length=_MAX_RESOURCE_LENGTH)] = None + issuer_binding: OAuthIssuerBinding + allow_loopback_http: bool = False + created_at: datetime + expires_at: datetime + + @field_validator("code_verifier") + @classmethod + def _valid_verifier(cls, value: SecretStr) -> SecretStr: + if not _PKCE_VERIFIER_RE.fullmatch(value.get_secret_value()): + raise ValueError("invalid PKCE verifier") + return value + + @model_validator(mode="after") + def _valid_lifetime(self) -> PendingOAuthAuthorization: + if self.created_at.tzinfo is None or self.expires_at.tzinfo is None: + raise ValueError("OAuth flow timestamps must be timezone-aware") + if self.expires_at <= self.created_at: + raise ValueError("OAuth flow expiry must follow creation") + return self + + +class OAuthTokenSet(BaseModel): + """Closed, secret-safe token response returned after exchange.""" + + model_config = ConfigDict(extra="ignore", frozen=True) + + access_token: SecretStr + token_type: Annotated[ + str, + Field(min_length=1, max_length=64, pattern=_TOKEN_TYPE_RE.pattern), + ] + expires_in: Annotated[int | None, Field(default=None, ge=0, le=315_360_000)] + refresh_token: SecretStr | None = None + id_token: SecretStr | None = None + scope: Annotated[str | None, Field(default=None, max_length=_MAX_SCOPE_LENGTH)] + + @field_validator("access_token", "refresh_token", "id_token") + @classmethod + def _bounded_secret(cls, value: SecretStr | None) -> SecretStr | None: + if value is not None: + secret = value.get_secret_value() + if not 1 <= len(secret) <= _MAX_TOKEN_LENGTH or not _VSCHAR_RE.fullmatch(secret): + raise ValueError("token must use bounded RFC 6749 VSCHAR syntax") + return value + + @field_validator("scope") + @classmethod + def _valid_scope(cls, value: str | None) -> str | None: + if value is not None and not _SCOPE_RE.fullmatch(value): + raise ValueError("scope must contain space-delimited RFC 6749 scope tokens") + return value + + +@runtime_checkable +class PendingOAuthFlowStore(Protocol): + """Atomic one-time storage contract for pending OAuth flows.""" + + async def insert_if_absent(self, pending: PendingOAuthAuthorization) -> bool: + """Insert a live state without overwriting; return ``False`` on collision.""" + + async def consume(self, state: str) -> PendingOAuthAuthorization | None: + """Atomically remove and return a live state, or return ``None``.""" + + +class InMemoryPendingOAuthFlowStore: + """Lock-protected reference store for one process only. + + Multi-process and multi-replica applications must provide a shared store + whose insert-if-absent and consume operations are atomic across replicas. + """ + + def __init__( + self, + *, + capacity: int = _DEFAULT_STORE_CAPACITY, + clock: Callable[[], datetime] | None = None, + ) -> None: + if isinstance(capacity, bool) or not 1 <= capacity <= 1_000_000: + raise ValueError("capacity must be between 1 and 1000000") + self._capacity = capacity + self._clock = clock or (lambda: datetime.now(timezone.utc)) + self._entries: dict[str, PendingOAuthAuthorization] = {} + self._lock = asyncio.Lock() + + def _now(self) -> datetime: + now = self._clock() + if now.tzinfo is None: + raise ValueError("OAuth store clock must return a timezone-aware datetime") + return now + + def _prune_expired(self, now: datetime) -> None: + expired = [state for state, pending in self._entries.items() if pending.expires_at <= now] + for state in expired: + del self._entries[state] + + async def insert_if_absent(self, pending: PendingOAuthAuthorization) -> bool: + async with self._lock: + now = self._now() + self._prune_expired(now) + if pending.expires_at <= now or pending.state in self._entries: + return False + if len(self._entries) >= self._capacity: + raise OAuthFlowStoreError("capacity_exceeded", phase="store") + self._entries[pending.state] = pending + return True + + async def consume(self, state: str) -> PendingOAuthAuthorization | None: + async with self._lock: + now = self._now() + self._prune_expired(now) + return self._entries.pop(state, None) + + +def _metadata_url(issuer_url: str, *, allow_loopback_http: bool) -> str: + if len(issuer_url) > _MAX_URL_LENGTH: + raise OAuthDiscoveryError("invalid_issuer", phase="discovery") + try: + _validate_absolute_url( + issuer_url, + field="issuer", + issuer=True, + allow_loopback_http=allow_loopback_http, + ) + parts = urlsplit(issuer_url) + issuer_path = parts.path[:-1] if parts.path.endswith("/") else parts.path + path = "/.well-known/oauth-authorization-server" + issuer_path + return urlunsplit((parts.scheme, parts.netloc, path, "", "")) + except (ValueError, UnicodeError): + raise OAuthDiscoveryError("invalid_issuer", phase="discovery") from None + + +async def _transport_for(url: str, *, allow_loopback_http: bool) -> httpx.AsyncBaseTransport: + parts = urlsplit(url) + if parts.scheme == "http": + if ( + not allow_loopback_http + or not parts.hostname + or not _is_literal_loopback(parts.hostname) + ): + raise OAuthDiscoveryError("insecure_endpoint", phase="network") + return await asyncio.to_thread( + build_async_ip_pinned_transport, + url, + allow_private=True, + allowed_ports=None, + verify=True, + ) + return await asyncio.to_thread( + build_async_ip_pinned_transport, + url, + allow_private=False, + allowed_ports=None, + verify=True, + ) + + +async def _read_json_response(response: httpx.Response, *, phase: str) -> Mapping[str, Any]: + try: + raw = await async_read_limited_bytes(response, limit=_MAX_HTTP_BODY_BYTES) + parsed = json.loads(raw) + except ( + RecursionError, + ResponseTooLargeError, + UnicodeDecodeError, + ValueError, + json.JSONDecodeError, + ): + raise OAuthClientError( + "invalid_response", + phase=phase, + status_code=response.status_code, + ) from None + if not isinstance(parsed, dict): + raise OAuthClientError("invalid_response", phase=phase, status_code=response.status_code) + return parsed + + +def _validate_public_client_metadata( + metadata: OAuthAuthorizationServerMetadata, + *, + allow_loopback_http: bool = False, +) -> None: + try: + _validate_absolute_url( + metadata.issuer, + field="issuer", + issuer=True, + allow_loopback_http=allow_loopback_http, + ) + _validate_absolute_url( + metadata.authorization_endpoint, + field="authorization_endpoint", + allow_loopback_http=allow_loopback_http, + ) + _validate_absolute_url( + metadata.token_endpoint, + field="token_endpoint", + allow_loopback_http=allow_loopback_http, + ) + except ValueError: + raise OAuthDiscoveryError("insecure_endpoint", phase="discovery") from None + if "code" not in metadata.response_types_supported: + raise OAuthDiscoveryError("authorization_code_unsupported", phase="discovery") + if metadata.grant_types_supported is not None and ( + "authorization_code" not in metadata.grant_types_supported + ): + raise OAuthDiscoveryError("authorization_code_unsupported", phase="discovery") + if "none" not in metadata.token_endpoint_auth_methods_supported: + raise OAuthDiscoveryError("public_client_unsupported", phase="discovery") + if "S256" not in metadata.code_challenge_methods_supported: + raise OAuthDiscoveryError("s256_unsupported", phase="discovery") + + +async def discover_oauth_metadata( + issuer_url: str, + *, + timeout: float = 5.0, + allow_loopback_http: bool = False, +) -> OAuthAuthorizationServerMetadata: + """Discover and validate RFC 8414 metadata for an exact issuer URL. + + ``issuer_url`` is an authorization-server issuer, not an MCP/HTTP resource + URL. The returned ``issuer`` must match it exactly. Plain HTTP is available + only for literal loopback IPs when ``allow_loopback_http=True``. + """ + if isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0 or timeout > 60: + raise ValueError("timeout must be greater than 0 and at most 60 seconds") + discovery_url = _metadata_url(issuer_url, allow_loopback_http=allow_loopback_http) + + async def fetch() -> Mapping[str, Any]: + transport = await _transport_for( + discovery_url, + allow_loopback_http=allow_loopback_http, + ) + async with httpx.AsyncClient( + transport=transport, + timeout=timeout, + follow_redirects=False, + trust_env=False, + ) as client: + async with client.stream( + "GET", + discovery_url, + headers={"accept": "application/json", "accept-encoding": "identity"}, + ) as response: + if response.status_code != 200: + raise OAuthDiscoveryError( + "http_error", phase="discovery", status_code=response.status_code + ) + return await _read_json_response(response, phase="discovery") + + try: + raw = await _run_with_deadline(fetch, timeout) + except _OAuthDeadlineExpired: + raise OAuthDiscoveryError("timeout", phase="discovery") from None + except OAuthDiscoveryError: + raise + except OAuthClientError as exc: + raise OAuthDiscoveryError( + exc.code, phase="discovery", status_code=exc.status_code + ) from None + except (httpx.HTTPError, OSError, SSRFValidationError, ValueError): + raise OAuthDiscoveryError("network_error", phase="discovery") from None + + try: + metadata = OAuthAuthorizationServerMetadata.model_validate(raw) + except ValidationError: + raise OAuthDiscoveryError("invalid_metadata", phase="discovery") from None + if metadata.issuer != issuer_url: + raise OAuthDiscoveryError("issuer_mismatch", phase="discovery") + _validate_public_client_metadata(metadata, allow_loopback_http=allow_loopback_http) + return metadata + + +def _random_urlsafe(byte_count: int = 32) -> str: + return base64.urlsafe_b64encode(secrets.token_bytes(byte_count)).rstrip(b"=").decode("ascii") + + +def _pkce_challenge(verifier: str) -> str: + return ( + base64.urlsafe_b64encode(hashlib.sha256(verifier.encode("ascii")).digest()) + .rstrip(b"=") + .decode("ascii") + ) + + +def _validate_scope(scopes: Sequence[str]) -> str | None: + if len(scopes) > 128: + raise ValueError("at most 128 OAuth scopes are accepted") + for scope in scopes: + if len(scope) > 256 or not _SCOPE_TOKEN_RE.fullmatch(scope): + raise ValueError("OAuth scopes must use RFC 6749 scope-token syntax") + joined = " ".join(scopes) or None + if joined is not None and len(joined) > _MAX_SCOPE_LENGTH: + raise ValueError("combined OAuth scope is too long") + return joined + + +def _validate_resource(resource: str | None) -> str | None: + if resource is None: + return None + if not 1 <= len(resource) <= _MAX_RESOURCE_LENGTH: + raise ValueError("OAuth resource is too long") + if "\\" in resource or any(ord(char) < 0x20 or ord(char) == 0x7F for char in resource): + raise ValueError("OAuth resource contains an unsafe character") + parts = urlsplit(resource) + if ( + not parts.scheme + or parts.fragment + or parts.username is not None + or parts.password is not None + ): + raise ValueError("OAuth resource must be an absolute URI without userinfo or fragment") + if parts.scheme in {"http", "https"} and not parts.hostname: + raise ValueError("HTTP OAuth resources must include a host") + try: + parts.port + except ValueError: + raise ValueError("OAuth resource contains an invalid port") from None + return resource + + +async def start_oauth_authorization( + metadata: OAuthAuthorizationServerMetadata, + *, + client_id: str, + redirect_uri: str, + store: PendingOAuthFlowStore, + issuer_binding: OAuthIssuerBinding, + scopes: Sequence[str] = (), + resource: str | None = None, + ttl_seconds: int = _DEFAULT_FLOW_TTL_SECONDS, + clock: Callable[[], datetime] | None = None, + allow_loopback_http: bool = False, +) -> OAuthAuthorizationRequest: + """Create, persist, and return a browser authorization request. + + ``DISTINCT_REDIRECT_URI`` is an explicit assertion that this redirect URI + is not shared with any other issuer. Prefer ``AUTHORIZATION_RESPONSE_ISS`` + when the server advertises RFC 9207 support. + """ + _validate_public_client_metadata(metadata, allow_loopback_http=allow_loopback_http) + if not 1 <= len(client_id) <= _MAX_CLIENT_ID_LENGTH or not _VSCHAR_RE.fullmatch(client_id): + raise ValueError("client_id must use bounded RFC 6749 VSCHAR syntax") + if isinstance(ttl_seconds, bool) or not 1 <= ttl_seconds <= 3600: + raise ValueError("ttl_seconds must be between 1 and 3600") + if not isinstance(issuer_binding, OAuthIssuerBinding): + raise ValueError("issuer_binding must be an OAuthIssuerBinding value") + try: + _validate_absolute_url( + redirect_uri, + field="redirect_uri", + allow_loopback_http=allow_loopback_http, + ) + except ValueError: + policy = "HTTPS or a literal loopback HTTP address" if allow_loopback_http else "HTTPS" + raise ValueError(f"redirect_uri must use {policy}") from None + if ( + issuer_binding is OAuthIssuerBinding.AUTHORIZATION_RESPONSE_ISS + and not metadata.authorization_response_iss_parameter_supported + ): + raise ValueError("authorization server does not advertise RFC 9207 issuer responses") + + scope = _validate_scope(scopes) + resource = _validate_resource(resource) + now = (clock or (lambda: datetime.now(timezone.utc)))() + if now.tzinfo is None: + raise ValueError("clock must return a timezone-aware datetime") + expires_at = now + timedelta(seconds=ttl_seconds) + state = _random_urlsafe(32) + verifier = _random_urlsafe(64) + challenge = _pkce_challenge(verifier) + pending = PendingOAuthAuthorization( + state=state, + code_verifier=SecretStr(verifier), + issuer=metadata.issuer, + authorization_endpoint=metadata.authorization_endpoint, + token_endpoint=metadata.token_endpoint, + client_id=client_id, + redirect_uri=redirect_uri, + scope=scope, + resource=resource, + issuer_binding=issuer_binding, + allow_loopback_http=allow_loopback_http, + created_at=now, + expires_at=expires_at, + ) + if not await store.insert_if_absent(pending): + raise OAuthFlowStoreError("state_collision", phase="store") + + parts = urlsplit(metadata.authorization_endpoint) + query = parse_qsl(parts.query, keep_blank_values=True) + query.extend( + [ + ("response_type", "code"), + ("client_id", client_id), + ("redirect_uri", redirect_uri), + ("state", state), + ("code_challenge", challenge), + ("code_challenge_method", "S256"), + ] + ) + if scope is not None: + query.append(("scope", scope)) + if resource is not None: + query.append(("resource", resource)) + authorization_url = urlunsplit((parts.scheme, parts.netloc, parts.path, urlencode(query), "")) + if len(authorization_url) > _MAX_URL_LENGTH: + # The flow remains stored and must not be reused. Consume it best-effort. + await store.consume(state) + raise ValueError("authorization URL exceeds the accepted length") + return OAuthAuthorizationRequest( + authorization_url=authorization_url, + state=state, + expires_at=expires_at, + ) + + +def _safe_oauth_error(value: object) -> str | None: + if isinstance(value, str) and value in _SAFE_OAUTH_ERROR_CODES: + return value + return None + + +def _retry_after(response: httpx.Response) -> float | None: + value = response.headers.get("retry-after") + if value is None or not value.isascii() or not value.isdigit(): + return None + seconds = int(value) + return float(seconds) if 0 <= seconds <= 86_400 else None + + +async def _exchange_code( + pending: PendingOAuthAuthorization, + *, + code: str, + timeout: float, +) -> OAuthTokenSet: + form = { + "grant_type": "authorization_code", + "client_id": pending.client_id, + "code": code, + "redirect_uri": pending.redirect_uri, + "code_verifier": pending.code_verifier.get_secret_value(), + } + if pending.resource is not None: + form["resource"] = pending.resource + + async def exchange() -> Mapping[str, Any]: + transport = await _transport_for( + pending.token_endpoint, + allow_loopback_http=pending.allow_loopback_http, + ) + async with httpx.AsyncClient( + transport=transport, + timeout=timeout, + follow_redirects=False, + trust_env=False, + ) as client: + async with client.stream( + "POST", + pending.token_endpoint, + data=form, + headers={"accept": "application/json", "accept-encoding": "identity"}, + ) as response: + if response.status_code < 200 or response.status_code >= 300: + try: + raw = await _read_json_response(response, phase="token_exchange") + except OAuthClientError: + raise OAuthTokenExchangeError( + "http_error", + phase="token_exchange", + status_code=response.status_code, + retry_after_seconds=_retry_after(response), + ) from None + raise OAuthTokenExchangeError( + "http_error", + phase="token_exchange", + status_code=response.status_code, + oauth_error=_safe_oauth_error(raw.get("error")), + retry_after_seconds=_retry_after(response), + ) + return await _read_json_response(response, phase="token_exchange") + + try: + raw = await _run_with_deadline(exchange, timeout) + except _OAuthDeadlineExpired: + raise OAuthTokenExchangeError("timeout", phase="token_exchange") from None + except OAuthTokenExchangeError: + raise + except OAuthClientError as exc: + raise OAuthTokenExchangeError( + exc.code, + phase="token_exchange", + status_code=exc.status_code, + ) from None + except (httpx.HTTPError, OSError, SSRFValidationError, ValueError): + raise OAuthTokenExchangeError("network_error", phase="token_exchange") from None + + try: + return OAuthTokenSet.model_validate(raw) + except ValidationError: + raise OAuthTokenExchangeError("invalid_token_response", phase="token_exchange") from None + + +async def complete_oauth_authorization( + *, + code: str | None, + callback_state: str, + expected_state: str, + store: PendingOAuthFlowStore, + callback_issuer: str | None = None, + callback_error: str | None = None, + timeout: float = 10.0, + clock: Callable[[], datetime] | None = None, +) -> OAuthTokenSet: + """Consume one callback and exchange its code for public-client tokens. + + ``expected_state`` must come from a separate browser-session binding, not + from the callback query itself. Once the two states match, the pending flow + is consumed before any issuer/error/code handling or network I/O. Any + failure therefore requires starting a new authorization flow. + """ + if isinstance(timeout, bool) or not math.isfinite(timeout) or timeout <= 0 or timeout > 60: + raise ValueError("timeout must be greater than 0 and at most 60 seconds") + if not ( + 43 <= len(callback_state) <= _MAX_STATE_LENGTH + and 43 <= len(expected_state) <= _MAX_STATE_LENGTH + and callback_state.isascii() + and expected_state.isascii() + and re.fullmatch(r"[A-Za-z0-9_-]+", callback_state) + and re.fullmatch(r"[A-Za-z0-9_-]+", expected_state) + ) or not hmac.compare_digest(callback_state, expected_state): + raise OAuthAuthorizationError("state_mismatch", phase="callback") + + pending = await store.consume(callback_state) + if pending is None: + raise OAuthAuthorizationError("flow_not_found", phase="callback") + + if not hmac.compare_digest(pending.state, callback_state): + raise OAuthAuthorizationError("state_mismatch", phase="callback") + now = (clock or (lambda: datetime.now(timezone.utc)))() + if now.tzinfo is None: + raise ValueError("clock must return a timezone-aware datetime") + if pending.expires_at <= now: + raise OAuthAuthorizationError("flow_expired", phase="callback") + if callback_issuer is not None and ( + len(callback_issuer) > _MAX_URL_LENGTH or callback_issuer != pending.issuer + ): + raise OAuthAuthorizationError("issuer_mismatch", phase="callback") + if ( + pending.issuer_binding is OAuthIssuerBinding.AUTHORIZATION_RESPONSE_ISS + and callback_issuer is None + ): + raise OAuthAuthorizationError("issuer_mismatch", phase="callback") + if callback_error is not None: + raise OAuthAuthorizationError( + "authorization_failed", + phase="callback", + oauth_error=_safe_oauth_error(callback_error), + ) + if code is None or not 1 <= len(code) <= _MAX_CODE_LENGTH or not _VSCHAR_RE.fullmatch(code): + raise OAuthAuthorizationError("invalid_code", phase="callback") + return await _exchange_code( + pending, + code=code, + timeout=timeout, + ) + + +__all__ = [ + "InMemoryPendingOAuthFlowStore", + "OAuthAuthorizationError", + "OAuthAuthorizationRequest", + "OAuthAuthorizationServerMetadata", + "OAuthClientError", + "OAuthDiscoveryError", + "OAuthFlowStoreError", + "OAuthIssuerBinding", + "OAuthTokenExchangeError", + "OAuthTokenSet", + "PendingOAuthAuthorization", + "PendingOAuthFlowStore", + "complete_oauth_authorization", + "discover_oauth_metadata", + "start_oauth_authorization", +] diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 2227a070e..3ac4a387f 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -235,6 +235,7 @@ "IdentityMatchResponse", "IdentityMatchTmpxMacro", "ImageContent", + "InMemoryPendingOAuthFlowStore", "InlineDaastAsset", "InlineVastAsset", "JavascriptContent", @@ -316,6 +317,15 @@ "MemoryBackend", "NativeMacroMapping", "NotificationConfig", + "OAuthAuthorizationError", + "OAuthAuthorizationRequest", + "OAuthAuthorizationServerMetadata", + "OAuthClientError", + "OAuthDiscoveryError", + "OAuthFlowStoreError", + "OAuthIssuerBinding", + "OAuthTokenExchangeError", + "OAuthTokenSet", "OfferingAssetConstraint", "OfferingAssetGroup", "OptimizationGoal", @@ -326,6 +336,8 @@ "PackageSignalTargetingGroup", "PackageSignalTargetingGroups", "PaginationRequest", + "PendingOAuthAuthorization", + "PendingOAuthFlowStore", "PixelTrackerAsset", "Placement", "PlacementPresentationDocument", @@ -538,12 +550,14 @@ "ZipAsset", "aliases", "challenge_webhook_destination", + "complete_oauth_authorization", "create_a2a_webhook_payload", "create_mcp_webhook_payload", "create_test_agent", "create_webhook_challenge_payload", "creative_agent", "detect_publisher_properties_divergence", + "discover_oauth_metadata", "domain_matches", "encode_unreserved", "extract_webhook_result_data", @@ -577,6 +591,7 @@ "resolve_properties_for_agent", "sign_legacy_webhook", "sign_webhook", + "start_oauth_authorization", "test_agent", "test_agent_a2a", "test_agent_a2a_no_auth", diff --git a/tests/test_oauth.py b/tests/test_oauth.py new file mode 100644 index 000000000..c2eab98f2 --- /dev/null +++ b/tests/test_oauth.py @@ -0,0 +1,969 @@ +"""Buyer-side OAuth discovery, PKCE, state, and exchange security tests.""" + +from __future__ import annotations + +import asyncio +import base64 +import gzip +import hashlib +import json +import time +import zlib +from collections.abc import AsyncIterator +from datetime import datetime, timedelta, timezone +from typing import Any +from unittest.mock import patch +from urllib.parse import parse_qs, urlsplit + +import httpx +import pytest +from pydantic import SecretStr + +import adcp +import adcp.oauth as oauth_module +from adcp.oauth import ( + InMemoryPendingOAuthFlowStore, + OAuthAuthorizationError, + OAuthAuthorizationServerMetadata, + OAuthDiscoveryError, + OAuthFlowStoreError, + OAuthIssuerBinding, + OAuthTokenExchangeError, + PendingOAuthAuthorization, + complete_oauth_authorization, + discover_oauth_metadata, + start_oauth_authorization, +) + + +def test_oauth_public_api_is_exported_from_module_and_package() -> None: + expected = { + "InMemoryPendingOAuthFlowStore", + "OAuthAuthorizationError", + "OAuthAuthorizationRequest", + "OAuthAuthorizationServerMetadata", + "OAuthClientError", + "OAuthDiscoveryError", + "OAuthFlowStoreError", + "OAuthIssuerBinding", + "OAuthTokenExchangeError", + "OAuthTokenSet", + "PendingOAuthAuthorization", + "PendingOAuthFlowStore", + "complete_oauth_authorization", + "discover_oauth_metadata", + "start_oauth_authorization", + } + assert set(oauth_module.__all__) == expected + for name in expected: + assert name in adcp.__all__ + assert getattr(adcp, name) is getattr(oauth_module, name) + + +def _metadata(**overrides: Any) -> OAuthAuthorizationServerMetadata: + values: dict[str, Any] = { + "issuer": "https://auth.example/tenant", + "authorization_endpoint": "https://auth.example/authorize", + "token_endpoint": "https://tokens.example/token", + "response_types_supported": ["code"], + "grant_types_supported": ["authorization_code"], + "token_endpoint_auth_methods_supported": ["none"], + "code_challenge_methods_supported": ["S256"], + "scopes_supported": ["openid", "media.buy"], + "authorization_response_iss_parameter_supported": True, + } + values.update(overrides) + return OAuthAuthorizationServerMetadata.model_validate(values) + + +def _metadata_json(**overrides: Any) -> dict[str, Any]: + metadata = _metadata(**overrides) + return metadata.model_dump(mode="json") + + +def _mock_transport(handler: Any) -> Any: + return patch( + "adcp.oauth.build_async_ip_pinned_transport", + return_value=httpx.MockTransport(handler), + ) + + +class _TrackingStream(httpx.AsyncByteStream): + def __init__(self, content: bytes) -> None: + self.content = content + self.closed = False + self.iterated = False + + async def __aiter__(self) -> AsyncIterator[bytes]: + self.iterated = True + yield self.content + + async def aclose(self) -> None: + self.closed = True + + +@pytest.mark.asyncio +async def test_discovery_inserts_well_known_before_issuer_path_and_matches_exact_issuer() -> None: + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response(200, json=_metadata_json()) + + with _mock_transport(handler): + metadata = await discover_oauth_metadata("https://auth.example/tenant") + + assert metadata.issuer == "https://auth.example/tenant" + assert str(captured[0].url) == ( + "https://auth.example/.well-known/oauth-authorization-server/tenant" + ) + assert captured[0].headers["accept-encoding"] == "identity" + + +@pytest.mark.asyncio +async def test_discovery_preserves_trailing_slash_and_has_no_root_fallback() -> None: + urls: list[str] = [] + + def handler(request: httpx.Request) -> httpx.Response: + urls.append(str(request.url)) + return httpx.Response(404) + + with _mock_transport(handler): + with pytest.raises(OAuthDiscoveryError) as caught: + await discover_oauth_metadata("https://auth.example/tenant/") + + assert caught.value.code == "http_error" + assert urls == ["https://auth.example/.well-known/oauth-authorization-server/tenant"] + + +@pytest.mark.asyncio +async def test_discovery_strips_root_issuer_slash_only_for_well_known_path() -> None: + captured: list[str] = [] + body = _metadata_json( + issuer="https://auth.example/", + authorization_endpoint="https://auth.example/authorize", + token_endpoint="https://auth.example/token", + ) + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(str(request.url)) + return httpx.Response(200, json=body) + + with _mock_transport(handler): + result = await discover_oauth_metadata("https://auth.example/") + assert captured == ["https://auth.example/.well-known/oauth-authorization-server"] + assert result.issuer == "https://auth.example/" + + +@pytest.mark.asyncio +async def test_discovery_rejects_byte_different_issuer() -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=_metadata_json(issuer="https://auth.example/tenant/")) + + with _mock_transport(handler): + with pytest.raises(OAuthDiscoveryError) as caught: + await discover_oauth_metadata("https://auth.example/tenant") + assert caught.value.code == "issuer_mismatch" + + +@pytest.mark.parametrize( + ("override", "code"), + [ + ({"response_types_supported": ["token"]}, "authorization_code_unsupported"), + ({"grant_types_supported": ["refresh_token"]}, "authorization_code_unsupported"), + ( + {"token_endpoint_auth_methods_supported": ["client_secret_basic"]}, + "public_client_unsupported", + ), + ({"code_challenge_methods_supported": ["plain"]}, "s256_unsupported"), + ], +) +@pytest.mark.asyncio +async def test_discovery_requires_public_authorization_code_s256_metadata( + override: dict[str, Any], code: str +) -> None: + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json=_metadata_json(**override)) + + with _mock_transport(handler): + with pytest.raises(OAuthDiscoveryError) as caught: + await discover_oauth_metadata("https://auth.example/tenant") + assert caught.value.code == code + + +@pytest.mark.parametrize( + "field,value", + [ + ("authorization_endpoint", "https://user:password@auth.example/authorize"), + ("token_endpoint", "https://auth.example/token#fragment"), + ("authorization_endpoint", "https://auth.example/authorize?state=fixed"), + ("authorization_endpoint", "https://auth.example/authorize?a=1&a=2"), + ], +) +def test_metadata_rejects_unsafe_endpoint_shapes(field: str, value: str) -> None: + with pytest.raises(ValueError): + _metadata(**{field: value}) + + +def test_metadata_accepts_response_type_combinations_but_validates_scopes() -> None: + metadata = _metadata(response_types_supported=["code", "code token"]) + assert metadata.response_types_supported == ("code", "code token") + for invalid_scope in ('bad"scope', "bad\\scope", "line\nscope", "two scopes"): + with pytest.raises(ValueError): + _metadata(scopes_supported=[invalid_scope]) + + +@pytest.mark.asyncio +async def test_discovery_rejects_redirect_encoded_and_oversized_responses() -> None: + responses = [ + httpx.Response(302, headers={"location": "https://other.example/metadata"}), + httpx.Response( + 200, + content=gzip.compress(json.dumps(_metadata_json()).encode()), + headers={"content-encoding": "gzip"}, + ), + httpx.Response(200, content=b"{" + b"x" * (64 * 1024)), + ] + + def handler(_request: httpx.Request) -> httpx.Response: + return responses.pop(0) + + with _mock_transport(handler): + for expected in ("http_error", "invalid_response", "invalid_response"): + with pytest.raises(OAuthDiscoveryError) as caught: + await discover_oauth_metadata("https://auth.example/tenant") + assert caught.value.code == expected + + +@pytest.mark.asyncio +async def test_discovery_closes_stream_on_invalid_body() -> None: + stream = _TrackingStream(b"not-json") + with _mock_transport(lambda _request: httpx.Response(200, stream=stream)): + with pytest.raises(OAuthDiscoveryError): + await discover_oauth_metadata("https://auth.example/tenant") + assert stream.closed + + +@pytest.mark.asyncio +async def test_discovery_rejects_compression_before_reading_a_bomb() -> None: + stream = _TrackingStream(gzip.compress(b"x" * (5 * 1024 * 1024))) + response = httpx.Response(200, headers={"content-encoding": "gzip"}, stream=stream) + with _mock_transport(lambda _request: response): + with pytest.raises(OAuthDiscoveryError) as caught: + await discover_oauth_metadata("https://auth.example/tenant") + assert caught.value.code == "invalid_response" + assert not stream.iterated + assert stream.closed + + +@pytest.mark.parametrize( + "issuer", + [ + "http://auth.example/tenant", + "https://user:password@auth.example/tenant", + "https://auth.example/tenant?query=1", + "https://auth.example/tenant#fragment", + ], +) +@pytest.mark.asyncio +async def test_discovery_rejects_insecure_or_ambiguous_issuer(issuer: str) -> None: + with pytest.raises(OAuthDiscoveryError) as caught: + await discover_oauth_metadata(issuer) + assert caught.value.code == "invalid_issuer" + + +@pytest.mark.asyncio +async def test_loopback_http_requires_explicit_flag_and_literal_address() -> None: + body = _metadata_json( + issuer="http://127.0.0.1:8765/tenant", + authorization_endpoint="http://127.0.0.1:8765/authorize", + token_endpoint="http://127.0.0.1:8765/token", + ) + with pytest.raises(OAuthDiscoveryError): + await discover_oauth_metadata("http://127.0.0.1:8765/tenant") + with pytest.raises(OAuthDiscoveryError): + await discover_oauth_metadata("http://localhost:8765/tenant", allow_loopback_http=True) + + with _mock_transport(lambda _request: httpx.Response(200, json=body)): + result = await discover_oauth_metadata( + "http://127.0.0.1:8765/tenant", allow_loopback_http=True + ) + assert result.issuer == "http://127.0.0.1:8765/tenant" + + +@pytest.mark.asyncio +async def test_https_discovery_still_rejects_private_and_metadata_addresses() -> None: + for issuer in ("https://127.0.0.1/tenant", "https://169.254.169.254/tenant"): + with pytest.raises(OAuthDiscoveryError) as caught: + await discover_oauth_metadata(issuer) + assert caught.value.code == "network_error" + + +@pytest.mark.asyncio +async def test_start_stores_verifier_but_public_result_does_not_expose_it() -> None: + store = InMemoryPendingOAuthFlowStore() + result = await start_oauth_authorization( + _metadata(), + client_id="buyer-public", + redirect_uri="https://buyer.example/oauth/callback", + store=store, + issuer_binding=OAuthIssuerBinding.AUTHORIZATION_RESPONSE_ISS, + scopes=["openid", "media.buy"], + resource="https://seller.example/mcp", + ) + + assert "verifier" not in repr(result).lower() + assert "verifier" not in result.model_dump() + query = parse_qs(urlsplit(result.authorization_url).query) + assert query["response_type"] == ["code"] + assert query["client_id"] == ["buyer-public"] + assert query["redirect_uri"] == ["https://buyer.example/oauth/callback"] + assert query["scope"] == ["openid media.buy"] + assert query["resource"] == ["https://seller.example/mcp"] + assert query["code_challenge_method"] == ["S256"] + assert query["state"] == [result.state] + + pending = await store.consume(result.state) + assert pending is not None + verifier = pending.code_verifier.get_secret_value() + assert 43 <= len(verifier) <= 128 + challenge = base64.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=") + assert query["code_challenge"] == [challenge.decode()] + assert verifier not in repr(pending) + assert isinstance(pending.model_dump()["code_verifier"], SecretStr) + + +@pytest.mark.asyncio +async def test_start_rejects_invalid_client_scope_and_unapproved_loopback_redirect() -> None: + store = InMemoryPendingOAuthFlowStore() + common = { + "metadata": _metadata(), + "redirect_uri": "https://buyer.example/oauth/callback", + "store": store, + "issuer_binding": OAuthIssuerBinding.AUTHORIZATION_RESPONSE_ISS, + } + with pytest.raises(ValueError, match="client_id"): + await start_oauth_authorization(client_id="buyer\nclient", **common) + with pytest.raises(ValueError, match="scope-token"): + await start_oauth_authorization(client_id="buyer", scopes=['bad"scope'], **common) + with pytest.raises(ValueError, match="redirect_uri"): + await start_oauth_authorization( + _metadata(), + client_id="buyer", + redirect_uri="http://127.0.0.1:8765/callback", + store=store, + issuer_binding=OAuthIssuerBinding.AUTHORIZATION_RESPONSE_ISS, + ) + + +@pytest.mark.asyncio +async def test_start_requires_explicit_supported_mixup_binding() -> None: + store = InMemoryPendingOAuthFlowStore() + with pytest.raises(ValueError, match="RFC 9207"): + await start_oauth_authorization( + _metadata(authorization_response_iss_parameter_supported=False), + client_id="buyer-public", + redirect_uri="https://buyer.example/oauth/callback", + store=store, + issuer_binding=OAuthIssuerBinding.AUTHORIZATION_RESPONSE_ISS, + ) + + result = await start_oauth_authorization( + _metadata(authorization_response_iss_parameter_supported=False), + client_id="buyer-public", + redirect_uri="https://buyer.example/oauth/issuer-specific-callback", + store=store, + issuer_binding=OAuthIssuerBinding.DISTINCT_REDIRECT_URI, + ) + assert result.state + + +@pytest.mark.asyncio +async def test_start_revalidates_metadata_transport_security() -> None: + store = InMemoryPendingOAuthFlowStore() + metadata = _metadata( + authorization_endpoint="http://127.0.0.1:8765/authorize", + token_endpoint="http://127.0.0.1:8765/token", + ) + with pytest.raises(OAuthDiscoveryError) as caught: + await start_oauth_authorization( + metadata, + client_id="buyer-public", + redirect_uri="https://buyer.example/oauth/callback", + store=store, + issuer_binding=OAuthIssuerBinding.AUTHORIZATION_RESPONSE_ISS, + ) + assert caught.value.code == "insecure_endpoint" + + request = await start_oauth_authorization( + metadata, + client_id="buyer-public", + redirect_uri="https://buyer.example/oauth/callback", + store=store, + issuer_binding=OAuthIssuerBinding.AUTHORIZATION_RESPONSE_ISS, + allow_loopback_http=True, + ) + assert request.authorization_url.startswith("http://127.0.0.1:8765/") + + +def _pending( + state: str, + *, + now: datetime, + expires_at: datetime | None = None, +) -> PendingOAuthAuthorization: + return PendingOAuthAuthorization( + state=state, + code_verifier=SecretStr("v" * 64), + issuer="https://auth.example/tenant", + authorization_endpoint="https://auth.example/authorize", + token_endpoint="https://tokens.example/token", + client_id="buyer-public", + redirect_uri="https://buyer.example/oauth/callback", + scope="openid", + resource="https://seller.example/mcp", + issuer_binding=OAuthIssuerBinding.AUTHORIZATION_RESPONSE_ISS, + created_at=now, + expires_at=expires_at or now + timedelta(minutes=10), + ) + + +@pytest.mark.asyncio +async def test_store_collision_expiry_capacity_and_atomic_double_consume() -> None: + now = datetime(2026, 8, 24, tzinfo=timezone.utc) + store = InMemoryPendingOAuthFlowStore(capacity=1, clock=lambda: now) + state = "s" * 43 + assert await store.insert_if_absent(_pending(state, now=now)) + assert not await store.insert_if_absent(_pending(state, now=now)) + with pytest.raises(OAuthFlowStoreError) as capacity: + await store.insert_if_absent(_pending("t" * 43, now=now)) + assert capacity.value.code == "capacity_exceeded" + + first, second = await asyncio.gather(store.consume(state), store.consume(state)) + assert sum(item is not None for item in (first, second)) == 1 + assert await store.insert_if_absent( + _pending("e" * 43, now=now, expires_at=now + timedelta(seconds=1)) + ) + now += timedelta(seconds=2) + assert await store.consume("e" * 43) is None + + +async def _started_flow( + *, + issuer_binding: OAuthIssuerBinding = OAuthIssuerBinding.AUTHORIZATION_RESPONSE_ISS, +) -> tuple[InMemoryPendingOAuthFlowStore, str]: + store = InMemoryPendingOAuthFlowStore() + request = await start_oauth_authorization( + _metadata(), + client_id="buyer-public", + redirect_uri="https://buyer.example/oauth/callback", + store=store, + issuer_binding=issuer_binding, + resource="https://seller.example/mcp", + ) + return store, request.state + + +@pytest.mark.asyncio +async def test_state_mismatch_does_not_consume_but_issuer_mismatch_does() -> None: + store, state = await _started_flow() + with pytest.raises(OAuthAuthorizationError) as mismatch: + await complete_oauth_authorization( + code="code", + callback_state=state, + expected_state="x" * 43, + callback_issuer="https://auth.example/tenant", + store=store, + ) + assert mismatch.value.code == "state_mismatch" + + with pytest.raises(OAuthAuthorizationError) as issuer: + await complete_oauth_authorization( + code="code", + callback_state=state, + expected_state=state, + callback_issuer="https://other.example/tenant", + store=store, + ) + assert issuer.value.code == "issuer_mismatch" + with pytest.raises(OAuthAuthorizationError) as replay: + await complete_oauth_authorization( + code="code", + callback_state=state, + expected_state=state, + callback_issuer="https://auth.example/tenant", + store=store, + ) + assert replay.value.code == "flow_not_found" + + +@pytest.mark.asyncio +async def test_missing_rfc9207_issuer_consumes_and_unicode_state_fails_cleanly() -> None: + store, state = await _started_flow() + with pytest.raises(OAuthAuthorizationError) as invalid_state: + await complete_oauth_authorization( + code="code", + callback_state="é" * 43, + expected_state="é" * 43, + callback_issuer="https://auth.example/tenant", + store=store, + ) + assert invalid_state.value.code == "state_mismatch" + assert await store.consume(state) is not None + + store, state = await _started_flow() + with pytest.raises(OAuthAuthorizationError) as missing_issuer: + await complete_oauth_authorization( + code="code", + callback_state=state, + expected_state=state, + store=store, + ) + assert missing_issuer.value.code == "issuer_mismatch" + assert await store.consume(state) is None + + +@pytest.mark.asyncio +async def test_distinct_redirect_binding_does_not_require_callback_issuer() -> None: + store, state = await _started_flow(issuer_binding=OAuthIssuerBinding.DISTINCT_REDIRECT_URI) + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"access_token": "access", "token_type": "Bearer"}) + + with _mock_transport(handler): + token = await complete_oauth_authorization( + code="code", + callback_state=state, + expected_state=state, + store=store, + ) + assert token.access_token.get_secret_value() == "access" + + +@pytest.mark.asyncio +async def test_distinct_redirect_binding_still_rejects_a_supplied_wrong_issuer() -> None: + store, state = await _started_flow(issuer_binding=OAuthIssuerBinding.DISTINCT_REDIRECT_URI) + with pytest.raises(OAuthAuthorizationError) as caught: + await complete_oauth_authorization( + code="code", + callback_state=state, + expected_state=state, + callback_issuer="https://wrong.example/tenant", + store=store, + ) + assert caught.value.code == "issuer_mismatch" + assert await store.consume(state) is None + + +@pytest.mark.asyncio +async def test_loopback_network_authority_is_persisted_from_start_to_completion() -> None: + metadata = _metadata( + issuer="http://127.0.0.1:8765/tenant", + authorization_endpoint="http://127.0.0.1:8765/authorize", + token_endpoint="http://127.0.0.1:8765/token", + ) + store = InMemoryPendingOAuthFlowStore() + request = await start_oauth_authorization( + metadata, + client_id="buyer-public", + redirect_uri="http://127.0.0.1:9999/callback", + store=store, + issuer_binding=OAuthIssuerBinding.AUTHORIZATION_RESPONSE_ISS, + allow_loopback_http=True, + ) + with _mock_transport( + lambda _request: httpx.Response( + 200, json={"access_token": "access", "token_type": "Bearer"} + ) + ): + token = await complete_oauth_authorization( + code="code", + callback_state=request.state, + expected_state=request.state, + callback_issuer=metadata.issuer, + store=store, + ) + assert token.access_token.get_secret_value() == "access" + + +@pytest.mark.asyncio +async def test_callback_error_is_sanitized_and_consumed() -> None: + store, state = await _started_flow() + with pytest.raises(OAuthAuthorizationError) as caught: + await complete_oauth_authorization( + code=None, + callback_state=state, + expected_state=state, + callback_issuer="https://auth.example/tenant", + callback_error="access_denied", + store=store, + ) + assert caught.value.oauth_error == "access_denied" + assert state not in str(caught.value) + assert await store.consume(state) is None + + +@pytest.mark.asyncio +async def test_token_exchange_uses_only_persisted_fields_and_returns_secret_types() -> None: + store, state = await _started_flow() + captured: list[httpx.Request] = [] + + def handler(request: httpx.Request) -> httpx.Response: + captured.append(request) + return httpx.Response( + 200, + json={ + "access_token": "access-secret", + "refresh_token": "refresh-secret", + "id_token": "id-secret", + "token_type": "Bearer", + "expires_in": 3600, + "scope": "openid", + "unknown": "discarded", + }, + ) + + with _mock_transport(handler) as transport_factory: + tokens = await complete_oauth_authorization( + code="authorization-code", + callback_state=state, + expected_state=state, + callback_issuer="https://auth.example/tenant", + store=store, + ) + + request = captured[0] + assert request.method == "POST" + assert str(request.url) == "https://tokens.example/token" + assert "authorization" not in request.headers + form = parse_qs(request.content.decode()) + assert form["grant_type"] == ["authorization_code"] + assert form["client_id"] == ["buyer-public"] + assert form["code"] == ["authorization-code"] + assert form["redirect_uri"] == ["https://buyer.example/oauth/callback"] + assert form["resource"] == ["https://seller.example/mcp"] + assert form["code_verifier"] and 43 <= len(form["code_verifier"][0]) <= 128 + assert tokens.access_token.get_secret_value() == "access-secret" + assert "access-secret" not in repr(tokens) + assert "refresh-secret" not in repr(tokens) + assert "id-secret" not in repr(tokens) + assert "unknown" not in tokens.model_dump() + assert transport_factory.call_args.kwargs["allowed_ports"] is None + + +@pytest.mark.asyncio +async def test_token_error_never_leaks_remote_prose_or_token_shaped_values() -> None: + store, state = await _started_flow() + secret = "sk_live_sensitive_value" + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response( + 400, + headers={"retry-after": "17"}, + json={ + "error": "invalid_grant", + "error_description": f"bad code {secret}", + "access_token": secret, + }, + ) + + with _mock_transport(handler): + with pytest.raises(OAuthTokenExchangeError) as caught: + await complete_oauth_authorization( + code="authorization-code", + callback_state=state, + expected_state=state, + callback_issuer="https://auth.example/tenant", + store=store, + ) + assert caught.value.oauth_error == "invalid_grant" + assert caught.value.retry_after_seconds == 17 + assert secret not in str(caught.value) + assert secret not in repr(caught.value) + assert await store.consume(state) is None + + +@pytest.mark.parametrize( + "body", + [ + {"access_token": "access\r\nX-Evil: yes", "token_type": "Bearer"}, + {"access_token": "access", "token_type": "Bearer\r\nX-Evil: yes"}, + {"access_token": "access", "token_type": "Bearer", "scope": "line\nscope"}, + {"access_token": "access", "token_type": "Bearer", "scope": 'bad"scope'}, + {"access_token": "access", "token_type": "Bearer", "scope": "bad\\scope"}, + ], +) +@pytest.mark.asyncio +async def test_token_projection_rejects_control_or_non_scope_public_fields( + body: dict[str, str], +) -> None: + store, state = await _started_flow() + with _mock_transport(lambda _request: httpx.Response(200, json=body)): + with pytest.raises(OAuthTokenExchangeError) as caught: + await complete_oauth_authorization( + code="authorization-code", + callback_state=state, + expected_state=state, + callback_issuer="https://auth.example/tenant", + store=store, + ) + assert caught.value.code == "invalid_token_response" + + +@pytest.mark.asyncio +async def test_resource_indicator_error_is_preserved_as_safe_code() -> None: + store, state = await _started_flow() + with _mock_transport(lambda _request: httpx.Response(400, json={"error": "invalid_target"})): + with pytest.raises(OAuthTokenExchangeError) as caught: + await complete_oauth_authorization( + code="authorization-code", + callback_state=state, + expected_state=state, + callback_issuer="https://auth.example/tenant", + store=store, + ) + assert caught.value.oauth_error == "invalid_target" + + +@pytest.mark.asyncio +async def test_oversized_token_error_preserves_status_retry_and_closes_stream() -> None: + store, state = await _started_flow() + stream = _TrackingStream(b"{" + b"x" * (64 * 1024)) + + def handler(_request: httpx.Request) -> httpx.Response: + return httpx.Response(429, headers={"retry-after": "9"}, stream=stream) + + with _mock_transport(handler): + with pytest.raises(OAuthTokenExchangeError) as caught: + await complete_oauth_authorization( + code="authorization-code", + callback_state=state, + expected_state=state, + callback_issuer="https://auth.example/tenant", + store=store, + ) + assert caught.value.code == "http_error" + assert caught.value.status_code == 429 + assert caught.value.retry_after_seconds == 9 + assert stream.closed + + +@pytest.mark.asyncio +async def test_token_malformed_encoded_and_oversized_bodies_fail_closed() -> None: + responses = [ + httpx.Response(200, content=b"not-json"), + httpx.Response( + 200, + content=zlib.compress(b'{"access_token":"x","token_type":"Bearer"}'), + headers={"content-encoding": "deflate"}, + ), + httpx.Response(200, content=b"{" + b"x" * (64 * 1024)), + ] + + def handler(_request: httpx.Request) -> httpx.Response: + return responses.pop(0) + + with _mock_transport(handler): + for _ in range(3): + store, state = await _started_flow() + with pytest.raises(OAuthTokenExchangeError) as caught: + await complete_oauth_authorization( + code="authorization-code", + callback_state=state, + expected_state=state, + callback_issuer="https://auth.example/tenant", + store=store, + ) + assert caught.value.code == "invalid_response" + + +@pytest.mark.asyncio +async def test_token_exchange_revalidates_private_endpoint_before_connecting() -> None: + store = InMemoryPendingOAuthFlowStore() + request = await start_oauth_authorization( + _metadata(token_endpoint="https://127.0.0.1/token"), + client_id="buyer-public", + redirect_uri="https://buyer.example/oauth/callback", + store=store, + issuer_binding=OAuthIssuerBinding.AUTHORIZATION_RESPONSE_ISS, + ) + with pytest.raises(OAuthTokenExchangeError) as caught: + await complete_oauth_authorization( + code="authorization-code", + callback_state=request.state, + expected_state=request.state, + callback_issuer="https://auth.example/tenant", + store=store, + ) + assert caught.value.code == "network_error" + + +@pytest.mark.asyncio +async def test_discovery_deadline_covers_dns_without_blocking_event_loop() -> None: + ticks: list[float] = [] + + def slow_transport(*_args: Any, **_kwargs: Any) -> httpx.MockTransport: + time.sleep(0.25) + return httpx.MockTransport(lambda _request: httpx.Response(500)) + + async def sibling() -> None: + await asyncio.sleep(0.02) + ticks.append(asyncio.get_running_loop().time()) + + started = asyncio.get_running_loop().time() + with patch("adcp.oauth.build_async_ip_pinned_transport", side_effect=slow_transport): + sibling_task = asyncio.create_task(sibling()) + with pytest.raises(OAuthDiscoveryError) as caught: + await discover_oauth_metadata("https://auth.example/tenant", timeout=0.05) + await sibling_task + assert caught.value.code == "timeout" + assert ticks[0] - started < 0.1 + + +@pytest.mark.asyncio +async def test_token_deadline_covers_dns_and_consumes_flow() -> None: + store, state = await _started_flow() + + def slow_transport(*_args: Any, **_kwargs: Any) -> httpx.MockTransport: + time.sleep(0.25) + return httpx.MockTransport(lambda _request: httpx.Response(500)) + + with patch("adcp.oauth.build_async_ip_pinned_transport", side_effect=slow_transport): + with pytest.raises(OAuthTokenExchangeError) as caught: + await complete_oauth_authorization( + code="authorization-code", + callback_state=state, + expected_state=state, + callback_issuer="https://auth.example/tenant", + store=store, + timeout=0.05, + ) + assert caught.value.code == "timeout" + assert await store.consume(state) is None + + +@pytest.mark.asyncio +async def test_deep_json_is_sanitized_for_discovery_and_token_exchange() -> None: + nested = ("[" * 1500 + "0" + "]" * 1500).encode() + with _mock_transport(lambda _request: httpx.Response(200, content=nested)): + with pytest.raises(OAuthDiscoveryError) as discovery: + await discover_oauth_metadata("https://auth.example/tenant") + assert discovery.value.code == "invalid_response" + + store, state = await _started_flow() + with _mock_transport(lambda _request: httpx.Response(200, content=nested)): + with pytest.raises(OAuthTokenExchangeError) as token: + await complete_oauth_authorization( + code="authorization-code", + callback_state=state, + expected_state=state, + callback_issuer="https://auth.example/tenant", + store=store, + ) + assert token.value.code == "invalid_response" + + +@pytest.mark.asyncio +async def test_completion_independently_rejects_expired_external_store_record() -> None: + now = datetime.now(timezone.utc) + pending = _pending( + "s" * 43, now=now - timedelta(minutes=2), expires_at=now - timedelta(minutes=1) + ) + + class StaleStore: + async def insert_if_absent(self, _pending: PendingOAuthAuthorization) -> bool: + return True + + async def consume(self, _state: str) -> PendingOAuthAuthorization | None: + return pending + + with pytest.raises(OAuthAuthorizationError) as caught: + await complete_oauth_authorization( + code="authorization-code", + callback_state=pending.state, + expected_state=pending.state, + callback_issuer=pending.issuer, + store=StaleStore(), + ) + assert caught.value.code == "flow_expired" + + +@pytest.mark.asyncio +async def test_start_store_and_completion_share_an_injectable_clock() -> None: + now = datetime(2020, 1, 1, tzinfo=timezone.utc) + store = InMemoryPendingOAuthFlowStore(clock=lambda: now) + request = await start_oauth_authorization( + _metadata(), + client_id="buyer-public", + redirect_uri="https://buyer.example/oauth/callback", + store=store, + issuer_binding=OAuthIssuerBinding.AUTHORIZATION_RESPONSE_ISS, + clock=lambda: now, + ) + with _mock_transport( + lambda _request: httpx.Response( + 200, json={"access_token": "access", "token_type": "Bearer"} + ) + ): + tokens = await complete_oauth_authorization( + code="authorization-code", + callback_state=request.state, + expected_state=request.state, + callback_issuer="https://auth.example/tenant", + store=store, + clock=lambda: now, + ) + assert tokens.access_token.get_secret_value() == "access" + + +@pytest.mark.asyncio +async def test_cancellation_during_token_exchange_does_not_restore_flow() -> None: + store, state = await _started_flow() + + async def handler(_request: httpx.Request) -> httpx.Response: + await asyncio.Event().wait() + raise AssertionError("unreachable") + + with _mock_transport(handler): + task = asyncio.create_task( + complete_oauth_authorization( + code="authorization-code", + callback_state=state, + expected_state=state, + callback_issuer="https://auth.example/tenant", + store=store, + ) + ) + await asyncio.sleep(0) + task.cancel() + with pytest.raises(asyncio.CancelledError): + await task + assert await store.consume(state) is None + + +@pytest.mark.asyncio +async def test_two_concurrent_completions_exchange_only_once() -> None: + store, state = await _started_flow() + calls = 0 + + def handler(_request: httpx.Request) -> httpx.Response: + nonlocal calls + calls += 1 + return httpx.Response(200, json={"access_token": "access", "token_type": "Bearer"}) + + async def complete() -> object: + try: + return await complete_oauth_authorization( + code="authorization-code", + callback_state=state, + expected_state=state, + callback_issuer="https://auth.example/tenant", + store=store, + ) + except OAuthAuthorizationError as exc: + return exc + + with _mock_transport(handler): + results = await asyncio.gather(complete(), complete()) + assert calls == 1 + assert sum(isinstance(result, OAuthAuthorizationError) for result in results) == 1 diff --git a/tests/type_checks/oauth.py b/tests/type_checks/oauth.py new file mode 100644 index 000000000..17215dcc8 --- /dev/null +++ b/tests/type_checks/oauth.py @@ -0,0 +1,31 @@ +"""Adopter-facing type checks for the buyer OAuth helpers.""" + +from adcp import ( + InMemoryPendingOAuthFlowStore, + OAuthAuthorizationRequest, + OAuthIssuerBinding, + OAuthTokenSet, + complete_oauth_authorization, + discover_oauth_metadata, + start_oauth_authorization, +) + + +async def authorize() -> tuple[OAuthAuthorizationRequest, OAuthTokenSet]: + store = InMemoryPendingOAuthFlowStore() + metadata = await discover_oauth_metadata("https://login.example/tenant") + request = await start_oauth_authorization( + metadata, + client_id="buyer-public", + redirect_uri="https://buyer.example/oauth/callback", + store=store, + issuer_binding=OAuthIssuerBinding.AUTHORIZATION_RESPONSE_ISS, + ) + tokens = await complete_oauth_authorization( + code="code", + callback_state=request.state, + expected_state=request.state, + callback_issuer=metadata.issuer, + store=store, + ) + return request, tokens From 6a1d35c528bcbb5a02007cb037fe4f9441c0c841 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 25 Aug 2026 06:24:40 +0100 Subject: [PATCH 2/3] fix(types): export structured error details --- src/adcp/types/__init__.py | 35 +++++++++++++++++++++++++ src/adcp/types/_eager.py | 35 +++++++++++++++++++++++++ tests/fixtures/public_api_snapshot.json | 17 ++++++++++++ tests/test_public_api.py | 31 ++++++++++++++++++++++ 4 files changed, 118 insertions(+) diff --git a/src/adcp/types/__init__.py b/src/adcp/types/__init__.py index 05e30a572..ca22b447e 100644 --- a/src/adcp/types/__init__.py +++ b/src/adcp/types/__init__.py @@ -72,6 +72,24 @@ "RequestProposalsRequest", "RequestProposalsResponse", "UnsupportedRefinementDimensionDetails", + # Structured protocol error details + "AccessibilityViolationDetails", + "AccountMovedDetails", + "AccountSetupRequiredDetails", + "ActionNotAllowedDetails", + "ActionNotAllowedReason", + "AgentPermissionDeniedDetails", + "AudienceTooSmallDetails", + "BillingNotPermittedForAgentDetails", + "BillingNotSupportedDetails", + "BudgetTooLowDetails", + "ConflictDetails", + "CreativeRejectedDetails", + "PolicyViolationDetails", + "RateLimitedDetails", + "StaleResponseDetails", + "VendorErrorCodeRegistry", + "VersionUnsupportedDetails", # A2UI types "A2UiComponent", "A2UiSurface", @@ -1080,14 +1098,17 @@ def __dir__() -> list[str]: AcceptedLoss, AcceptProposalRequest, AcceptProposalResponse, + AccessibilityViolationDetails, Account, AccountAuthorization, AccountIdReference, + AccountMovedDetails, AccountReference, AccountReferenceById, AccountReferenceByNaturalKey, AccountResponse, AccountScope, + AccountSetupRequiredDetails, AccountWithAuthorization, AcquireRightsAcquiredResponse, AcquireRightsErrorResponse, @@ -1097,6 +1118,8 @@ def __dir__() -> list[str]: AcquireRightsResponse, AcquireRightsResponse1, Action, + ActionNotAllowedDetails, + ActionNotAllowedReason, ActivateSignalErrorResponse, ActivateSignalRequest, ActivateSignalResponse, @@ -1107,6 +1130,7 @@ def __dir__() -> list[str]: AgentConfig, AgentDeployment, AgentDestination, + AgentPermissionDeniedDetails, AggregatedTotals, AiTool, Artifact, @@ -1120,6 +1144,7 @@ def __dir__() -> list[str]: AssignedPackage, Assignments, AudienceSource, + AudienceTooSmallDetails, AudioContent, AudioFormatAsset, AudioFormatGroupAsset, @@ -1137,12 +1162,15 @@ def __dir__() -> list[str]: AvailableMetric, AvailablePackage, AvailableReportingFrequency, + BillingNotPermittedForAgentDetails, + BillingNotSupportedDetails, BothPreviewRender, BrandIdentity, BrandReference, BrandSource, BriefAsset, BriefFormatAsset, + BudgetTooLowDetails, BuildCreativeCreative, BusinessEntity, BusinessEntityResponse, @@ -1209,6 +1237,7 @@ def __dir__() -> list[str]: ComplyTestControllerRequest, ComplyTestControllerResponse, ComplyTestControllerResponse1, + ConflictDetails, ConsentBasis, Contact, ContentIdType, @@ -1260,6 +1289,7 @@ def __dir__() -> list[str]: CreativeFilters, CreativeManifest, CreativePolicy, + CreativeRejectedDetails, CreativeStatus, CreativeVariant, CreditLimit, @@ -1571,6 +1601,7 @@ def __dir__() -> list[str]: PolicyHistory, PolicyRevision, PolicySummary, + PolicyViolationDetails, PostalArea, PreviewOutputFormat, PreviewRender, @@ -1631,6 +1662,7 @@ def __dir__() -> list[str]: PushNotificationConfig, QuartileData, QuerySummary, + RateLimitedDetails, ReachUnit, RealEstateUnit, Recovery, @@ -1712,6 +1744,7 @@ def __dir__() -> list[str]: SortApplied, SortDirection, Source, + StaleResponseDetails, Status, StatusSummary, SyncAccountsAccount, @@ -1837,6 +1870,7 @@ def __dir__() -> list[str]: VcpmFixedRatePricingOption, VcpmPricingOption, VehicleUnit, + VendorErrorCodeRegistry, VendorPricingOptionUnion, VenueBreakdownItem, VerifyBrandClaimPayload, @@ -1852,6 +1886,7 @@ def __dir__() -> list[str]: VerifyBrandClaimsResponseBulk, VerifyBrandClaimsSignedResponse, VerifyBrandClaimsSignedSuccessPayload, + VersionUnsupportedDetails, VideoContent, VideoFormatAsset, VideoFormatGroupAsset, diff --git a/src/adcp/types/_eager.py b/src/adcp/types/_eager.py index f2bb2c0bb..e96facdf3 100644 --- a/src/adcp/types/_eager.py +++ b/src/adcp/types/_eager.py @@ -48,17 +48,23 @@ AcceptedLoss, AcceptProposalRequest, AcceptProposalResponse, + AccessibilityViolationDetails, Account, AccountAuthorization, + AccountMovedDetails, AccountReference, AccountScope, + AccountSetupRequiredDetails, AccountWithAuthorization, AcquireRightsRequest, AcquireRightsResponse, + ActionNotAllowedDetails, + ActionNotAllowedReason, ActivateSignalRequest, ActivateSignalResponse, AdcpProtocol, AdvertiserIndustry, + AgentPermissionDeniedDetails, AggregatedTotals, AiTool, Artifact, @@ -67,13 +73,17 @@ AssignedPackage, Assignments, AudienceSource, + AudienceTooSmallDetails, Authentication, AuthenticationScheme, AuthorizationRequiredDetails, AuthorizedAgents, AvailableMetric, AvailablePackage, + BillingNotPermittedForAgentDetails, + BillingNotSupportedDetails, BrandReference, + BudgetTooLowDetails, BusinessEntity, BuyingMode, BuyProductsRequest, @@ -99,6 +109,7 @@ CompatibilityPurchaseCoordinatorInput, ComplyTestControllerRequest, ComplyTestControllerResponse, + ConflictDetails, Contact, ContentIdType, ContentStandards, @@ -126,6 +137,7 @@ CreativeApprovalStatus, CreativeAssignment, CreativePolicy, + CreativeRejectedDetails, CreativeStatus, CreditLimit, DaastTrackingEvent, @@ -260,6 +272,7 @@ PlacementPresentationDocument, PlacementPresentationReference, PlacementReference, + PolicyViolationDetails, PostalArea, PreviewOutputFormat, PreviewRender, @@ -289,6 +302,7 @@ PushNotificationConfig, QuartileData, QuerySummary, + RateLimitedDetails, ReachUnit, ReferenceRenderer, Refine, @@ -348,6 +362,7 @@ Sort, SortApplied, SortDirection, + StaleResponseDetails, StatusSummary, SyncAccountsRequest, SyncAccountsResponse, @@ -394,6 +409,7 @@ VastTrackingEvent, VastVersion, VcpmPricingOption, + VendorErrorCodeRegistry, VenueBreakdownItem, VerifyBrandClaimPayload, VerifyBrandClaimRequest, @@ -408,6 +424,7 @@ VerifyBrandClaimsResponseBulk, VerifyBrandClaimsSignedResponse, VerifyBrandClaimsSignedSuccessPayload, + VersionUnsupportedDetails, ViewThreshold, WcagLevel, WebhookChallenge, @@ -1014,6 +1031,24 @@ def __init__(self, *args: object, **kwargs: object) -> None: "RequestProposalsRequest", "RequestProposalsResponse", "UnsupportedRefinementDimensionDetails", + # Structured protocol error details + "AccessibilityViolationDetails", + "AccountMovedDetails", + "AccountSetupRequiredDetails", + "ActionNotAllowedDetails", + "ActionNotAllowedReason", + "AgentPermissionDeniedDetails", + "AudienceTooSmallDetails", + "BillingNotPermittedForAgentDetails", + "BillingNotSupportedDetails", + "BudgetTooLowDetails", + "ConflictDetails", + "CreativeRejectedDetails", + "PolicyViolationDetails", + "RateLimitedDetails", + "StaleResponseDetails", + "VendorErrorCodeRegistry", + "VersionUnsupportedDetails", "A2UiComponent", "A2UiSurface", "Account", diff --git a/tests/fixtures/public_api_snapshot.json b/tests/fixtures/public_api_snapshot.json index 80f995d46..ab2841562 100644 --- a/tests/fixtures/public_api_snapshot.json +++ b/tests/fixtures/public_api_snapshot.json @@ -618,14 +618,17 @@ "AcceptProposalRequest", "AcceptProposalResponse", "AcceptedLoss", + "AccessibilityViolationDetails", "Account", "AccountAuthorization", "AccountIdReference", + "AccountMovedDetails", "AccountReference", "AccountReferenceById", "AccountReferenceByNaturalKey", "AccountResponse", "AccountScope", + "AccountSetupRequiredDetails", "AccountWithAuthorization", "AcquireRightsAcquiredResponse", "AcquireRightsErrorResponse", @@ -635,6 +638,8 @@ "AcquireRightsResponse", "AcquireRightsResponse1", "Action", + "ActionNotAllowedDetails", + "ActionNotAllowedReason", "ActivateSignalErrorResponse", "ActivateSignalRequest", "ActivateSignalResponse", @@ -645,6 +650,7 @@ "AgentConfig", "AgentDeployment", "AgentDestination", + "AgentPermissionDeniedDetails", "AggregatedTotals", "AiTool", "Artifact", @@ -658,6 +664,7 @@ "AssignedPackage", "Assignments", "AudienceSource", + "AudienceTooSmallDetails", "AudioContent", "AudioFormatAsset", "AudioFormatGroupAsset", @@ -675,12 +682,15 @@ "AvailableMetric", "AvailablePackage", "AvailableReportingFrequency", + "BillingNotPermittedForAgentDetails", + "BillingNotSupportedDetails", "BothPreviewRender", "BrandIdentity", "BrandReference", "BrandSource", "BriefAsset", "BriefFormatAsset", + "BudgetTooLowDetails", "BuildCreativeCreative", "BusinessEntity", "BusinessEntityResponse", @@ -747,6 +757,7 @@ "ComplyTestControllerRequest", "ComplyTestControllerResponse", "ComplyTestControllerResponse1", + "ConflictDetails", "ConsentBasis", "Contact", "ContentIdType", @@ -798,6 +809,7 @@ "CreativeFilters", "CreativeManifest", "CreativePolicy", + "CreativeRejectedDetails", "CreativeStatus", "CreativeVariant", "CreditLimit", @@ -1109,6 +1121,7 @@ "PolicyHistory", "PolicyRevision", "PolicySummary", + "PolicyViolationDetails", "PostalArea", "PreviewOutputFormat", "PreviewRender", @@ -1169,6 +1182,7 @@ "PushNotificationConfig", "QuartileData", "QuerySummary", + "RateLimitedDetails", "ReachUnit", "RealEstateUnit", "Recovery", @@ -1250,6 +1264,7 @@ "SortApplied", "SortDirection", "Source", + "StaleResponseDetails", "Status", "StatusSummary", "SyncAccountsAccount", @@ -1375,6 +1390,7 @@ "VcpmFixedRatePricingOption", "VcpmPricingOption", "VehicleUnit", + "VendorErrorCodeRegistry", "VendorPricingOptionUnion", "VenueBreakdownItem", "VerifyBrandClaimPayload", @@ -1390,6 +1406,7 @@ "VerifyBrandClaimsResponseBulk", "VerifyBrandClaimsSignedResponse", "VerifyBrandClaimsSignedSuccessPayload", + "VersionUnsupportedDetails", "VideoContent", "VideoFormatAsset", "VideoFormatGroupAsset", diff --git a/tests/test_public_api.py b/tests/test_public_api.py index 43acc86db..e020ebba1 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -112,6 +112,37 @@ def test_request_response_types_are_exported(): assert hasattr(adcp, type_name), f"{type_name} not exported from adcp package" +def test_structured_error_details_are_exported(): + """Every generated error-detail model is available on supported import paths.""" + import adcp + from adcp import types + + error_detail_types = [ + "AccessibilityViolationDetails", + "AccountMovedDetails", + "AccountSetupRequiredDetails", + "ActionNotAllowedDetails", + "ActionNotAllowedReason", + "AgentPermissionDeniedDetails", + "AudienceTooSmallDetails", + "AuthorizationRequiredDetails", + "BillingNotPermittedForAgentDetails", + "BillingNotSupportedDetails", + "BudgetTooLowDetails", + "ConflictDetails", + "CreativeRejectedDetails", + "PolicyViolationDetails", + "RateLimitedDetails", + "StaleResponseDetails", + "UnsupportedRefinementDimensionDetails", + "VendorErrorCodeRegistry", + "VersionUnsupportedDetails", + ] + + for type_name in error_detail_types: + assert getattr(adcp, type_name) is getattr(types, type_name) + + def test_pricing_option_types_are_exported(): """All pricing option types are accessible from main package.""" import adcp From b53fdc9b952168d264260111de1988299a8115e4 Mon Sep 17 00:00:00 2001 From: Brian O'Kelley Date: Tue, 25 Aug 2026 06:34:22 +0100 Subject: [PATCH 3/3] test(types): verify error detail exports --- tests/test_public_api.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/tests/test_public_api.py b/tests/test_public_api.py index e020ebba1..2a3c0a010 100644 --- a/tests/test_public_api.py +++ b/tests/test_public_api.py @@ -113,8 +113,7 @@ def test_request_response_types_are_exported(): def test_structured_error_details_are_exported(): - """Every generated error-detail model is available on supported import paths.""" - import adcp + """Every generated error-detail model is available from ``adcp.types``.""" from adcp import types error_detail_types = [ @@ -140,7 +139,7 @@ def test_structured_error_details_are_exported(): ] for type_name in error_detail_types: - assert getattr(adcp, type_name) is getattr(types, type_name) + assert getattr(types, type_name).__name__ == type_name def test_pricing_option_types_are_exported():