Source code for litestar_security.providers.jwks._httpx

"""HTTPX-backed bounded transport for exact configured JWKS sources."""

import ipaddress
from collections.abc import Sequence
from dataclasses import dataclass, field
from math import isfinite
from urllib.parse import urlsplit

import httpx

from litestar_security.providers._internal import AddressResolver, public_address, raise_config, resolve_addresses
from litestar_security.providers.jwks._fetching import JWKSFetchOutcome, JWKSFetchTarget

__all__ = ("HttpxJWKSFetcher",)


_HOST_RESOLUTION_UNAVAILABLE = "JWKS host resolution unavailable"
_RESPONSE_TOO_LARGE = "JWKS response exceeds the configured byte limit"
_NO_RESOLVED_ADDRESSES = "JWKS host resolution returned no addresses"
_INVALID_RESOLVED_ADDRESS = "JWKS host resolution returned an invalid address"
_NON_PUBLIC_RESOLVED_ADDRESS = "JWKS host resolved outside the public network boundary"
_INVALID_URL = "JWKS URI must be an absolute HTTPS URL"
_UNSUPPORTED_CONTENT_ENCODING = "JWKS response encoding is not allowed"
_DEFAULT_HTTPS_PORT = 443


[docs] @dataclass(slots=True) class HttpxJWKSFetcher: """HTTPX-backed async fetcher for operator-configured JWKS endpoints.""" timeout: float = 5.0 maximum_response_bytes: int = 1_048_576 allow_private_hosts: bool = False transport: httpx.AsyncBaseTransport | None = None resolver: AddressResolver | None = None _client: httpx.AsyncClient = field(init=False, repr=False) _closed: bool = field(init=False, default=False, repr=False) _resolve: AddressResolver = field(init=False, repr=False) def __post_init__(self) -> None: """Validate resource limits and construct the owned HTTP client.""" if ( isinstance(self.timeout, bool) or not isinstance(self.timeout, (int, float)) # pyright: ignore[reportUnnecessaryIsInstance] - defend runtime configuration boundary or not isfinite(self.timeout) or self.timeout <= 0 ): raise_config("JWKS HTTPX timeout must be finite and positive") if ( isinstance(self.maximum_response_bytes, bool) or not isinstance( # pyright: ignore[reportUnnecessaryIsInstance] - defend runtime configuration boundary self.maximum_response_bytes, int ) or self.maximum_response_bytes <= 0 ): raise_config("JWKS HTTPX maximum_response_bytes must be a positive integer") self._resolve = self.resolver or resolve_addresses self._client = httpx.AsyncClient( follow_redirects=False, timeout=httpx.Timeout(float(self.timeout)), transport=self.transport, trust_env=False, )
[docs] async def fetch(self, request: JWKSFetchTarget) -> JWKSFetchOutcome: """Return one bounded response without following redirects. Args: request: The exact configured JWKS URI and optional ETag condition. Returns: The bounded transport response, including un-followed redirect status codes for the provider to reject. Raises: _FetchGuardError: If the configured URI or host fails its network boundary, the response is encoded, or it exceeds its byte ceiling. httpx.HTTPError: If the outbound request fails. Any exception raised here becomes ``VerificationUnavailable`` at the JWKS provider. """ await self._guard_host(request.jwks_uri) headers = {"accept-encoding": "identity"} if request.etag is not None: headers["if-none-match"] = request.etag async with self._client.stream("GET", request.jwks_uri, headers=headers) as response: body = await self._read_bounded_body(response) return JWKSFetchOutcome(status_code=response.status_code, body=body, headers=dict(response.headers))
[docs] async def aclose(self) -> None: """Close the owned HTTP client idempotently. Returns: None. """ if not self._closed: self._closed = True await self._client.aclose()
async def _guard_host(self, url: str) -> None: parsed = self._parse_url(url) if self.allow_private_hosts: return host = parsed.host port = parsed.port or _DEFAULT_HTTPS_PORT try: literal = ipaddress.ip_address(host) except ValueError: try: addresses = tuple(await self._resolve(host, port)) except (OSError, RuntimeError) as exc: raise _FetchGuardError(_HOST_RESOLUTION_UNAVAILABLE) from exc else: addresses = (str(literal),) self._validate_resolved_addresses(addresses) async def _read_bounded_body(self, response: httpx.Response) -> bytes: content_encoding = response.headers.get("content-encoding", "identity").strip().lower() if content_encoding not in {"", "identity"}: raise _FetchGuardError(_UNSUPPORTED_CONTENT_ENCODING) body = bytearray() async for chunk in response.aiter_bytes(): if len(chunk) > self.maximum_response_bytes - len(body): raise _FetchGuardError(_RESPONSE_TOO_LARGE) body.extend(chunk) return bytes(body) @staticmethod def _validate_resolved_addresses(addresses: Sequence[str]) -> None: if not addresses: raise _FetchGuardError(_NO_RESOLVED_ADDRESSES) try: parsed = tuple(ipaddress.ip_address(address) for address in addresses) except ValueError as exc: raise _FetchGuardError(_INVALID_RESOLVED_ADDRESS) from exc if any(not public_address(address) for address in parsed): raise _FetchGuardError(_NON_PUBLIC_RESOLVED_ADDRESS) @staticmethod def _parse_url(value: str) -> httpx.URL: if ( not isinstance(value, str) # pyright: ignore[reportUnnecessaryIsInstance] - defend runtime request boundary or not value or value != value.strip() ): raise _FetchGuardError(_INVALID_URL) try: split = urlsplit(value) url = httpx.URL(value) except (TypeError, ValueError, httpx.InvalidURL) as exc: raise _FetchGuardError(_INVALID_URL) from exc if ( split.scheme.lower() != "https" or not split.netloc or split.username is not None or split.password is not None or split.fragment or not url.host ): raise _FetchGuardError(_INVALID_URL) return url
class _FetchGuardError(Exception): """Sanitized outbound JWKS boundary failure."""