Source code for aiohttp_tiny_mcp.client.oauth_helpers

"""OAuth validation and HTTP challenge parsing shared by the client helpers."""

from __future__ import annotations

import ipaddress
import re
from collections.abc import Iterable
from urllib.request import parse_http_list

from yarl import URL


[docs] class OAuthError(Exception): """An OAuth failure. Messages exclude response bodies and credentials."""
[docs] class AuthorizationRequired(OAuthError): """The application must obtain authorization before it can continue.""" def __init__(self, scopes: Iterable[str] = ()) -> None: super().__init__("Interactive authorization is required.") self.scopes = tuple(sorted(set(scopes)))
[docs] class RegistrationRequired(OAuthError): """The application must supply client registration information."""
def checked_url(value: object, *, allow_http: bool = False) -> URL: """Require HTTPS, or explicitly allowed HTTP on a loopback host.""" try: if not isinstance(value, str) or not value or any(ord(c) < 33 for c in value): raise ValueError url = URL(value) host = url.host port = url.port if not host or not port or url.user is not None or url.fragment: raise ValueError loopback = host == "localhost" try: loopback = loopback or ipaddress.ip_address(host).is_loopback except ValueError: pass if url.scheme != "https" and not (allow_http and loopback and url.scheme == "http"): raise ValueError except (ValueError, TypeError): raise OAuthError("OAuth URLs must use HTTPS without credentials or fragments.") from None return url def scopes(value: object) -> tuple[str, ...]: if not isinstance(value, str): raise OAuthError("Invalid OAuth scope value.") result = tuple(value.split(" ")) if value else () if any(not re.fullmatch(r"[\x21\x23-\x5b\x5d-\x7e]+", item) for item in result): raise OAuthError("Invalid OAuth scope value.") return result def strings(value: object) -> list[str]: if not isinstance(value, list) or not all(isinstance(item, str) for item in value): raise OAuthError("Invalid OAuth metadata list.") return value CHALLENGE_PARAM = re.compile(r'([!#$%&\'*+.^_`|~\w-]+)\s*=\s*(?:"((?:[^"\\]|\\.)*)"|([^\s,]+))\Z') def bearer_challenge(values: Iterable[str]) -> dict[str, str]: """Parse Bearer parameters without mixing parameters from other schemes.""" found: dict[str, str] | None = None for value in values: if len(value) > 16384: raise OAuthError("Authentication challenge is too large.") current: dict[str, str] | None = None for part in parse_http_list(value): part = part.strip() match = CHALLENGE_PARAM.fullmatch(part) if match is None: scheme, _, part = part.partition(" ") current = {} if scheme.lower() == "bearer" else None if current is not None: if found is not None: raise OAuthError("Multiple Bearer challenges are ambiguous.") found = current match = CHALLENGE_PARAM.fullmatch(part.strip()) if current is not None and part and match is None: raise OAuthError("Invalid Bearer challenge.") if current is not None and match: key = match[1].lower() if key in current: raise OAuthError("Duplicate Bearer challenge parameter.") current[key] = ( re.sub(r"\\(.)", r"\1", match[2]) if match[2] is not None else match[3] ) return found or {}