mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 04:45:43 +08:00
* Initial plan * feat: add authentication provider registry (GitHub + Azure DevOps) Agent-Logs-Url: https://github.com/github/spec-kit/sessions/da7ecfd0-e1c9-48dc-b692-27be0879e976 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * feat: add try-each-provider HTTP helper and wire all catalog fetches through auth registry - Add authentication/http.py with open_url() that tries each configured provider in registry order, falling through on 401/403 to the next, and finally to unauthenticated - Add build_request() for one-shot request construction - Add configured_providers() to registry __init__ - Remove api_base_url() from AuthProvider ABC (unused) - Remove hosts attribute from providers (no host matching) - Replace _github_http.py usage in ExtensionCatalog and PresetCatalog - Wire IntegrationCatalog and WorkflowCatalog through open_url (were unauthenticated) - Wire _fetch_latest_release_tag() through open_url - Wire all inline --from-url downloads through open_url - Fix unused stub variable flagged by code-quality bot - 49 auth tests (positive + negative), 1805 total tests passing * fix: address review — fix stale docstrings, restore Accept header, add extra_headers to open_url - Fix _open_url() docstrings in extensions.py and presets.py that incorrectly claimed redirect stripping behavior - Add extra_headers parameter to open_url() so callers can pass additional headers (e.g. Accept) that persist across retries - Restore Accept: application/vnd.github+json header in _fetch_latest_release_tag() via extra_headers * feat: config-driven opt-in auth via ~/.specify/auth.json Security-first redesign: no credentials are sent unless the user explicitly creates ~/.specify/auth.json mapping hosts to providers. - Add authentication/config.py: loads and validates auth.json with host-to-provider mappings, supports token/token_env/azure-ad/azure-cli - Refactor AuthProvider ABC: auth_headers(token, scheme) + resolve_token(entry) - Refactor GitHubAuth: bearer scheme only, token from config entry - Refactor AzureDevOpsAuth: 4 schemes (basic-pat, bearer, azure-cli, azure-ad) with dynamic token acquisition for azure-cli and azure-ad - Rewrite authentication/http.py: host matching, redirect stripping, provider fallthrough on 401/403, unauthenticated fallback - Add docs/reference/authentication.md with full reference and template - 1823 tests passing (67 auth-specific) * fix: address review — unused imports, host normalization, provider+scheme validation, security hardening - Remove unused imports (os, field, Any) in config.py - Normalize hosts during load (strip + lowercase) - Validate token/token_env are non-empty strings during load - Validate provider+scheme compatibility during load - Fix extra_headers order: auth headers applied last, cannot be overridden - Remove unused 'tried' variable in http.py - Warn (once) on malformed auth.json instead of silent fallback - URL-encode OAuth2 client credentials body in azure_devops.py - Update 403 message to mention auth.json configuration - Fix registry leak in test_register_duplicate (try/finally) - Fix import style consistency in test_authentication.py - Add azure-cli and azure-ad token acquisition tests (mock subprocess/urlopen) - Add autouse fixture to isolate upgrade tests from real auth.json - 1829 tests passing * fix: reject unknown providers, validate azure-ad fields, strip Authorization from extra_headers - Reject unknown provider keys during auth.json load with clear error message - Validate azure-ad tenant_id/client_id/client_secret_env as non-empty strings - Strip Authorization from extra_headers in both build_request and open_url to prevent accidental or intentional bypass of provider-configured auth - Add tests for unknown provider and incompatible scheme validation - 1831 tests passing * fix: extract shared auth test helpers, global config isolation, align docstring - Move _inject_github_config / make_github_auth_entry to tests/auth_helpers.py to eliminate duplication across test_extensions, test_presets, test_upgrade - Move auth config isolation fixture to global conftest.py (autouse) so ALL tests are isolated from ~/.specify/auth.json, not just test_upgrade - Align load_auth_config docstring with actual behavior: ValueError may be caught by higher-level HTTP helpers that warn and continue unauthenticated - 1831 tests passing * fix: preserve auth header across multi-hop redirect chains - Read Authorization from both headers and unredirected_hdrs in _StripAuthOnRedirect to survive multi-hop chains within allowed hosts - Add test_multi_hop_redirect_within_hosts_preserves_auth - 1832 tests passing * fix: use resolved config path in warning/error messages and patch build_opener in no-network test Agent-Logs-Url: https://github.com/github/spec-kit/sessions/86df9557-54f1-4fe4-a25f-9501cb2356cf Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: assert full resolved config path in rate-limit output test Agent-Logs-Url: https://github.com/github/spec-kit/sessions/86df9557-54f1-4fe4-a25f-9501cb2356cf Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: close HTTPError on 401/403, remove _VALID_AUTH_SCHEMES, catch TimeoutExpired, skip POSIX test on Windows, remove unused import Agent-Logs-Url: https://github.com/github/spec-kit/sessions/a1e29737-dd6e-4287-96c1-509e0c96fb21 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: use stable ~/.specify/auth.json in rate-limit message, skip POSIX permission check on Windows Agent-Logs-Url: https://github.com/github/spec-kit/sessions/4636bcdb-87ae-45d6-9545-a40e4effd617 Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: validate host patterns, cache auth config per-process Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: clarify _is_valid_host_pattern docstring, clean up test sentinel type Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * fix: improve _is_valid_host_pattern docstring and test observability Agent-Logs-Url: https://github.com/github/spec-kit/sessions/889b58a7-7f8c-47e2-8056-931ebcc671cc Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: mnriem <15701806+mnriem@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
150 lines
5.7 KiB
Python
150 lines
5.7 KiB
Python
"""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
|