mirror of
https://github.com/github/spec-kit.git
synced 2026-07-07 06:35:06 +08:00
feat: Config-driven opt-in authentication registry with multi-platform support (#2393)
* 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>
This commit is contained in:
117
src/specify_cli/authentication/azure_devops.py
Normal file
117
src/specify_cli/authentication/azure_devops.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Azure DevOps authentication provider."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json as _json
|
||||
import os
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .base import AuthProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .config import AuthConfigEntry
|
||||
|
||||
# Azure DevOps resource ID for OAuth / Azure AD token acquisition.
|
||||
_ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
|
||||
|
||||
|
||||
class AzureDevOpsAuth(AuthProvider):
|
||||
"""Azure DevOps authentication provider.
|
||||
|
||||
Supports four auth schemes:
|
||||
|
||||
* ``basic-pat`` — PAT with empty username, Base64-encoded as ``:<PAT>``
|
||||
* ``bearer`` — pre-acquired OAuth / Azure AD token
|
||||
* ``azure-cli`` — acquires a token via ``az account get-access-token``
|
||||
* ``azure-ad`` — acquires a token via OAuth2 client credentials flow
|
||||
"""
|
||||
|
||||
key = "azure-devops"
|
||||
supported_auth_schemes = ("basic-pat", "bearer", "azure-cli", "azure-ad")
|
||||
|
||||
def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]:
|
||||
"""Build the ``Authorization`` header for the given scheme."""
|
||||
if auth_scheme == "basic-pat":
|
||||
encoded = base64.b64encode(f":{token}".encode("ascii")).decode("ascii")
|
||||
return {"Authorization": f"Basic {encoded}"}
|
||||
if auth_scheme in ("bearer", "azure-cli", "azure-ad"):
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
raise ValueError(
|
||||
f"AzureDevOpsAuth does not support auth scheme {auth_scheme!r}"
|
||||
)
|
||||
|
||||
def resolve_token(self, entry: AuthConfigEntry) -> str | None:
|
||||
"""Resolve token, with special handling for azure-cli and azure-ad."""
|
||||
if entry.auth == "azure-cli":
|
||||
return self._acquire_via_az_cli()
|
||||
if entry.auth == "azure-ad":
|
||||
return self._acquire_via_client_credentials(entry)
|
||||
return super().resolve_token(entry)
|
||||
|
||||
# -- Token acquisition ------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def _acquire_via_az_cli() -> str | None:
|
||||
"""Run ``az account get-access-token`` and return the access token."""
|
||||
try:
|
||||
result = subprocess.run( # noqa: S603, S607
|
||||
[
|
||||
"az",
|
||||
"account",
|
||||
"get-access-token",
|
||||
"--resource",
|
||||
_ADO_RESOURCE_ID,
|
||||
"--output",
|
||||
"json",
|
||||
],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=30,
|
||||
check=False,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
payload = _json.loads(result.stdout)
|
||||
token = payload.get("accessToken", "").strip()
|
||||
return token or None
|
||||
except (OSError, subprocess.TimeoutExpired, _json.JSONDecodeError, KeyError):
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _acquire_via_client_credentials(entry: AuthConfigEntry) -> str | None:
|
||||
"""Acquire a token via OAuth2 client credentials flow."""
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
if not entry.tenant_id or not entry.client_id or not entry.client_secret_env:
|
||||
return None
|
||||
client_secret = os.environ.get(entry.client_secret_env, "").strip()
|
||||
if not client_secret:
|
||||
return None
|
||||
|
||||
url = (
|
||||
f"https://login.microsoftonline.com/{entry.tenant_id}"
|
||||
"/oauth2/v2.0/token"
|
||||
)
|
||||
from urllib.parse import urlencode
|
||||
body = urlencode({
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": entry.client_id,
|
||||
"client_secret": client_secret,
|
||||
"scope": f"{_ADO_RESOURCE_ID}/.default",
|
||||
}).encode("utf-8")
|
||||
|
||||
req = urllib.request.Request(
|
||||
url,
|
||||
data=body,
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
|
||||
payload = _json.loads(resp.read().decode("utf-8"))
|
||||
token = payload.get("access_token", "").strip()
|
||||
return token or None
|
||||
except (urllib.error.URLError, OSError, _json.JSONDecodeError, KeyError):
|
||||
return None
|
||||
Reference in New Issue
Block a user