"""Authenticated HTTP helpers driven by ``~/.specify/auth.json``. No credentials are sent unless the user has created ``auth.json``. For each outbound URL the helper matches the hostname against configured entries, resolves the token via the appropriate provider class, and attaches auth headers. Redirect safety is enforced: the ``Authorization`` header is stripped when a redirect leaves the entry's declared hosts. On 401/403 the next matching entry is tried, then unauthenticated. """ from __future__ import annotations import urllib.error import urllib.request from fnmatch import fnmatch from urllib.parse import urlparse from . import get_provider from .config import AuthConfigEntry, _default_config_path, find_entries_for_url, load_auth_config _config_override: list[AuthConfigEntry] | None = None _config_cache: list[AuthConfigEntry] | None = None # None = not yet loaded def _load_config() -> list[AuthConfigEntry]: """Load auth config, using override if set (for testing). The result is cached per-process so ``auth.json`` is read at most once, and any warning about a malformed file fires only once. """ global _config_cache if _config_override is not None: return _config_override if _config_cache is not None: return _config_cache try: _config_cache = load_auth_config() except (ValueError, OSError) as exc: import warnings config_path = _default_config_path() warnings.warn( f"Failed to load {config_path}: {exc}. " "All requests will be unauthenticated.", UserWarning, stacklevel=2, ) _config_cache = [] return _config_cache def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool: """Return True if *hostname* matches any pattern in *hosts*.""" hostname = hostname.lower() return any(p == hostname or fnmatch(hostname, p) for p in hosts) class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler): """Drop ``Authorization`` when a redirect leaves the entry's declared hosts.""" def __init__(self, hosts: tuple[str, ...]) -> None: super().__init__() self._hosts = hosts def redirect_request(self, req, fp, code, msg, headers, newurl): original_auth = ( req.get_header("Authorization") or req.unredirected_hdrs.get("Authorization") ) new_req = super().redirect_request(req, fp, code, msg, headers, newurl) if new_req is not None: hostname = (urlparse(newurl).hostname or "").lower() if _hostname_in_hosts(hostname, self._hosts): if original_auth: new_req.add_unredirected_header("Authorization", original_auth) else: new_req.headers.pop("Authorization", None) new_req.unredirected_hdrs.pop("Authorization", None) return new_req def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urllib.request.Request: """Build a :class:`~urllib.request.Request`, attaching auth when config matches. Uses the first matching entry from ``auth.json`` whose token resolves. Returns a plain request when no entry matches or the file doesn't exist. """ headers: dict[str, str] = {} if extra_headers: # Strip Authorization from extra_headers to prevent bypass headers.update({k: v for k, v in extra_headers.items() if k.lower() != "authorization"}) # Auth headers applied last — cannot be overridden by extra_headers entries = find_entries_for_url(url, _load_config()) for entry in entries: provider = get_provider(entry.provider) if provider is None: continue token = provider.resolve_token(entry) if token: headers.update(provider.auth_headers(token, entry.auth)) break return urllib.request.Request(url, headers=headers) def open_url(url: str, timeout: int = 10, extra_headers: dict[str, str] | None = None): """Open *url* with config-driven auth, redirect stripping, and fallthrough. 1. Find ``auth.json`` entries whose hosts match the URL. 2. For each entry, resolve the token and try the request. 3. On 401/403 move to the next matching entry. 4. After all entries exhausted (or none matched), try unauthenticated. 5. Non-auth errors (404, 500, network) raise immediately. *extra_headers* (e.g. ``Accept``) are merged into every attempt. """ entries = find_entries_for_url(url, _load_config()) def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request: merged = {} if extra_headers: # Strip Authorization from extra_headers to prevent bypass merged.update({k: v for k, v in extra_headers.items() if k.lower() != "authorization"}) # Auth headers applied last — cannot be overridden by extra_headers merged.update(auth_headers) return urllib.request.Request(url, headers=merged) # Try each matching entry for entry in entries: provider = get_provider(entry.provider) if provider is None: continue token = provider.resolve_token(entry) if not token: continue req = _make_req(provider.auth_headers(token, entry.auth)) opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts)) try: return opener.open(req, timeout=timeout) except urllib.error.HTTPError as exc: if exc.code in (401, 403): exc.close() continue # try next entry raise # No entry worked (or none matched) — unauthenticated fallback req = _make_req({}) return urllib.request.urlopen(req, timeout=timeout) # noqa: S310