From 85afacfd1344b150119bf90011b5cce213f4ab52 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 14 Oct 2026 11:59:38 -0500 Subject: [PATCH 01/17] feat(auth): support GitHub CLI credentials Add a dedicated github-cli authentication mode with host-aware token resolution, in-memory caching, sanitized failures, tests, and documentation. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 434e2508-0633-4d40-8e0d-5edb92cbc177 --- docs/reference/authentication.md | 37 +++ .../authentication/azure_devops.py | 8 +- src/specify_cli/authentication/base.py | 11 +- src/specify_cli/authentication/bitbucket.py | 8 +- src/specify_cli/authentication/config.py | 2 +- src/specify_cli/authentication/github.py | 112 +++++++- src/specify_cli/authentication/http.py | 14 +- .../authentication/test_authentication.py | 245 +++++++++++++++++- 8 files changed, 422 insertions(+), 15 deletions(-) diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index 9e0ff21449..be769af819 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -59,6 +59,7 @@ Either `token` or `token_env` must be set for the `bearer`, `basic-pat`, and `ba | Scheme | Header | Use for | |---|---|---| | `bearer` | `Authorization: Bearer ` | PATs, fine-grained PATs, OAuth tokens, GitHub App tokens | +| `github-cli` | `Authorization: Bearer ` | Token acquired on demand from `gh auth token` | **Example — PAT via environment variable:** @@ -71,6 +72,30 @@ Either `token` or `token_env` must be set for the `bearer`, `basic-pat`, and `ba } ``` +**Example — GitHub CLI (recommended for interactive use):** + +```json +{ + "providers": [ + { + "hosts": [ + "github.com", + "api.github.com", + "raw.githubusercontent.com", + "codeload.github.com" + ], + "provider": "github", + "auth": "github-cli" + } + ] +} +``` + +Install [GitHub CLI](https://cli.github.com/) and run `gh auth login` before +using this mode. Specify invokes the fixed command `gh auth token` directly, +without a shell, and caches the resulting token in memory for the current +process. The token is not written to disk by Specify. + ### GitHub Enterprise Server (GHES) To use a private catalog or extension hosted on a GitHub Enterprise Server @@ -98,6 +123,18 @@ and `codeload.` subdomains your catalog/extension URLs use. A `*.ghes.example.com` wildcard matches subdomains but **not** the bare host, so always include the bare host explicitly. +With `auth: "github-cli"`, Specify requests the credential for the exact +enterprise request host by running: + +```bash +gh auth token --hostname ghes.example.com +``` + +The host must already be configured in GitHub CLI. If it is not configured, +or GitHub CLI is not authenticated for it, Specify fails the request and +suggests `gh auth login --hostname ghes.example.com`. GitHub CLI command output +is not included in the error. + ### GitHub Enterprise Cloud with data residency (GHE.com) GHE.com tenants use separate web and REST API hostnames. List both hosts so diff --git a/src/specify_cli/authentication/azure_devops.py b/src/specify_cli/authentication/azure_devops.py index 91578bd418..904a1310c2 100644 --- a/src/specify_cli/authentication/azure_devops.py +++ b/src/specify_cli/authentication/azure_devops.py @@ -58,13 +58,17 @@ def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: f"AzureDevOpsAuth does not support auth scheme {auth_scheme!r}" ) - def resolve_token(self, entry: AuthConfigEntry) -> str | None: + def resolve_token( + self, + entry: AuthConfigEntry, + hostname: str | None = None, + ) -> 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) + return super().resolve_token(entry, hostname) # -- Token acquisition ------------------------------------------------ diff --git a/src/specify_cli/authentication/base.py b/src/specify_cli/authentication/base.py index d6e0f4b118..a34d266100 100644 --- a/src/specify_cli/authentication/base.py +++ b/src/specify_cli/authentication/base.py @@ -20,7 +20,7 @@ class AuthProvider(ABC): And implement: * ``auth_headers(token, auth_scheme)`` — build headers from a resolved token - * ``resolve_token(entry)`` — obtain the token for a config entry + * ``resolve_token(entry, hostname)`` — obtain the token for a config entry """ key: str = "" @@ -36,13 +36,20 @@ def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: Must return a dict with at least an ``Authorization`` key. """ - def resolve_token(self, entry: AuthConfigEntry) -> str | None: + def resolve_token( + self, + entry: AuthConfigEntry, + hostname: str | None = None, + ) -> str | None: """Resolve the token for *entry*. Default implementation reads from ``entry.token`` directly or from the environment variable named by ``entry.token_env``. Override for schemes that acquire tokens dynamically (e.g. ``azure-cli``, ``azure-ad``). + + ``hostname`` identifies the request target for providers whose + credential helper selects credentials by host. """ import os diff --git a/src/specify_cli/authentication/bitbucket.py b/src/specify_cli/authentication/bitbucket.py index 24474e6b5b..c561535a6a 100644 --- a/src/specify_cli/authentication/bitbucket.py +++ b/src/specify_cli/authentication/bitbucket.py @@ -59,7 +59,11 @@ def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: f"BitbucketAuth does not support auth scheme {auth_scheme!r}" ) - def resolve_token(self, entry: AuthConfigEntry) -> str | None: + def resolve_token( + self, + entry: AuthConfigEntry, + hostname: str | None = None, + ) -> str | None: """Resolve the credential, combining ``username`` for ``basic``. Returns ``None`` when the secret is missing, or — for ``basic`` — @@ -69,7 +73,7 @@ def resolve_token(self, entry: AuthConfigEntry) -> str | None: ``:`` credential or a ``user:name:`` one that the server would parse as user ``user`` (RFC 7617 §2). """ - secret = super().resolve_token(entry) + secret = super().resolve_token(entry, hostname) if entry.auth != "basic": return secret username = (entry.username or "").strip() diff --git a/src/specify_cli/authentication/config.py b/src/specify_cli/authentication/config.py index 296c5c4d39..2f5d6a72d2 100644 --- a/src/specify_cli/authentication/config.py +++ b/src/specify_cli/authentication/config.py @@ -220,7 +220,7 @@ def load_auth_config( raise ValueError( f"providers[{i}]: '{field_name}' must be a non-empty string" ) - # azure-cli needs no extra fields + # azure-cli and github-cli need no extra fields entries.append( AuthConfigEntry( diff --git a/src/specify_cli/authentication/github.py b/src/specify_cli/authentication/github.py index 3797d82926..b2cc1da431 100644 --- a/src/specify_cli/authentication/github.py +++ b/src/specify_cli/authentication/github.py @@ -2,23 +2,127 @@ from __future__ import annotations +import os +import shutil +import subprocess +from typing import TYPE_CHECKING + from .base import AuthProvider +if TYPE_CHECKING: + from .config import AuthConfigEntry + + +_GITHUB_CLI_TIMEOUT_SECONDS = 10 +_GITHUB_PUBLIC_HOSTS = frozenset({ + "github.com", + "api.github.com", + "raw.githubusercontent.com", + "codeload.github.com", +}) + + +class GitHubCliAuthError(RuntimeError): + """Raised when GitHub CLI cannot provide a configured credential.""" + class GitHubAuth(AuthProvider): """GitHub authentication provider. - Supports the ``bearer`` auth scheme, used for PATs, fine-grained PATs, - OAuth tokens, and GitHub App installation tokens. + Supports ``bearer`` for PATs, OAuth tokens, and GitHub App installation + tokens, plus ``github-cli`` for credentials managed by GitHub CLI. """ key = "github" - supported_auth_schemes = ("bearer",) + supported_auth_schemes = ("bearer", "github-cli") + + def __init__(self) -> None: + self._github_cli_cache: dict[str, tuple[bool, str]] = {} def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: """Return ``Authorization: Bearer ``.""" - if auth_scheme != "bearer": + if auth_scheme not in self.supported_auth_schemes: raise ValueError( f"GitHubAuth does not support auth scheme {auth_scheme!r}" ) return {"Authorization": f"Bearer {token}"} + + def resolve_token( + self, + entry: AuthConfigEntry, + hostname: str | None = None, + ) -> str | None: + """Resolve configured tokens or acquire one from GitHub CLI.""" + if entry.auth != "github-cli": + return super().resolve_token(entry, hostname) + + request_host = (hostname or entry.hosts[0]).lower() + credential_host = ( + "github.com" if request_host in _GITHUB_PUBLIC_HOSTS else request_host + ) + cached = self._github_cli_cache.get(credential_host) + if cached is not None: + succeeded, value = cached + if succeeded: + return value + raise GitHubCliAuthError(value) + + try: + token = self._acquire_via_gh_cli(credential_host) + except GitHubCliAuthError as exc: + message = str(exc) + self._github_cli_cache[credential_host] = (False, message) + raise GitHubCliAuthError(message) from None + + self._github_cli_cache[credential_host] = (True, token) + return token + + @staticmethod + def _acquire_via_gh_cli(hostname: str) -> str: + """Run the fixed GitHub CLI token command for *hostname*.""" + resolved = shutil.which("gh") + if not resolved or not os.path.isabs(resolved): + raise GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + "GitHub CLI ('gh') was not found." + ) + + argv = [resolved, "auth", "token"] + if hostname != "github.com": + argv.extend(["--hostname", hostname]) + + try: + result = subprocess.run( # noqa: S603 + argv, + capture_output=True, + text=True, + timeout=_GITHUB_CLI_TIMEOUT_SECONDS, + check=False, + shell=False, + ) + except subprocess.TimeoutExpired: + raise GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + f"GitHub CLI timed out while requesting a token for {hostname!r}." + ) from None + except (OSError, UnicodeDecodeError): + raise GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + f"GitHub CLI could not provide a token for {hostname!r}." + ) from None + + if result.returncode != 0: + raise GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + f"GitHub CLI could not provide a token for {hostname!r}. " + f"Run 'gh auth login --hostname {hostname}'." + ) + + token = result.stdout.strip() + if not token: + raise GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + f"GitHub CLI returned an empty token for {hostname!r}. " + f"Run 'gh auth login --hostname {hostname}'." + ) + return token diff --git a/src/specify_cli/authentication/http.py b/src/specify_cli/authentication/http.py index 7dabc752aa..6e63382357 100644 --- a/src/specify_cli/authentication/http.py +++ b/src/specify_cli/authentication/http.py @@ -69,6 +69,14 @@ class RedirectPolicyError(urllib.error.URLError): """A redirect rejected because it violates the client's security policy.""" +def _hostname_for_url(url: str) -> str | None: + """Return a normalized request hostname, or None for malformed URLs.""" + try: + return (urlparse(url).hostname or "").lower() or None + except ValueError: + return None + + def _validate_strict_redirect(old_url: str, new_url: str) -> None: if not is_safe_download_redirect(old_url, new_url): raise RedirectPolicyError( @@ -140,11 +148,12 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll 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()) + hostname = _hostname_for_url(url) for entry in entries: provider = get_provider(entry.provider) if provider is None: continue - token = provider.resolve_token(entry) + token = provider.resolve_token(entry, hostname) if token: headers.update(provider.auth_headers(token, entry.auth)) break @@ -190,6 +199,7 @@ def open_url( HTTP between loopback URLs, and rejects remote-to-local redirects. """ entries = find_entries_for_url(url, _load_config()) + hostname = _hostname_for_url(url) def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request: merged = {} @@ -205,7 +215,7 @@ def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request: provider = get_provider(entry.provider) if provider is None: continue - token = provider.resolve_token(entry) + token = provider.resolve_token(entry, hostname) if not token: continue diff --git a/tests/specify_cli/authentication/test_authentication.py b/tests/specify_cli/authentication/test_authentication.py index 45c0a4b833..e9069b47b9 100644 --- a/tests/specify_cli/authentication/test_authentication.py +++ b/tests/specify_cli/authentication/test_authentication.py @@ -30,7 +30,7 @@ find_entries_for_url, load_auth_config, ) -from specify_cli.authentication.github import GitHubAuth +from specify_cli.authentication.github import GitHubAuth, GitHubCliAuthError # --------------------------------------------------------------------------- @@ -49,6 +49,17 @@ def _github_entry(token_env: str = "GH_TOKEN", token: str | None = None) -> Auth ) +def _github_cli_entry( + hosts: tuple[str, ...] = ("github.com",), +) -> AuthConfigEntry: + """Build a GitHub CLI config entry.""" + return AuthConfigEntry( + hosts=hosts, + provider="github", + auth="github-cli", + ) + + def _ado_basic_entry(token_env: str = "AZURE_DEVOPS_PAT") -> AuthConfigEntry: """Build an ADO basic-pat config entry.""" return AuthConfigEntry( @@ -293,6 +304,18 @@ def test_azure_cli_config(self, tmp_path): entries = load_auth_config(cfg) assert entries[0].auth == "azure-cli" + def test_github_cli_config(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["github.com"], + "provider": "github", + "auth": "github-cli", + }] + })) + entries = load_auth_config(cfg) + assert entries == [_github_cli_entry()] + def test_multiple_entries(self, tmp_path): cfg = tmp_path / "auth.json" cfg.write_text(json.dumps({ @@ -616,7 +639,201 @@ def test_key(self): assert GitHubAuth.key == "github" def test_supported_schemes(self): - assert GitHubAuth.supported_auth_schemes == ("bearer",) + assert GitHubAuth.supported_auth_schemes == ("bearer", "github-cli") + + def test_github_cli_headers_use_bearer(self): + assert GitHubAuth().auth_headers("cli-token", "github-cli") == { + "Authorization": "Bearer cli-token" + } + + def test_resolve_token_github_cli_success_without_shell(self): + from unittest.mock import MagicMock, patch + + provider = GitHubAuth() + result = MagicMock(returncode=0, stdout=" cli-token\n") + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ) as run: + assert provider.resolve_token( + _github_cli_entry(), + "github.com", + ) == "cli-token" + + assert run.call_args.args[0] == [executable, "auth", "token"] + assert run.call_args.kwargs == { + "capture_output": True, + "text": True, + "timeout": 10, + "check": False, + "shell": False, + } + + def test_resolve_token_github_cli_uses_enterprise_hostname(self): + from unittest.mock import MagicMock, patch + + provider = GitHubAuth() + result = MagicMock(returncode=0, stdout="enterprise-token") + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ) as run: + assert provider.resolve_token( + _github_cli_entry(("ghes.example.com",)), + "ghes.example.com", + ) == "enterprise-token" + + assert run.call_args.args[0] == [ + executable, + "auth", + "token", + "--hostname", + "ghes.example.com", + ] + + def test_resolve_token_github_cli_caches_per_credential_host(self): + from unittest.mock import MagicMock, patch + + provider = GitHubAuth() + result = MagicMock(returncode=0, stdout="cached-token") + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + entry = _github_cli_entry(( + "github.com", + "api.github.com", + "ghes.example.com", + )) + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ) as run: + assert provider.resolve_token(entry, "github.com") == "cached-token" + assert provider.resolve_token(entry, "api.github.com") == "cached-token" + assert provider.resolve_token(entry, "ghes.example.com") == "cached-token" + assert provider.resolve_token(entry, "ghes.example.com") == "cached-token" + + assert run.call_count == 2 + + def test_resolve_token_github_cli_missing_raises_clear_error(self): + from unittest.mock import patch + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=None, + ), pytest.raises(GitHubCliAuthError) as exc_info: + GitHubAuth().resolve_token(_github_cli_entry(), "github.com") + + message = str(exc_info.value) + assert "provider 'github'" in message + assert "GitHub CLI ('gh') was not found" in message + + def test_resolve_token_github_cli_unauthenticated_suggests_login(self): + from unittest.mock import MagicMock, patch + + result = MagicMock( + returncode=1, + stdout="", + stderr="not logged into any GitHub hosts", + ) + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ), pytest.raises(GitHubCliAuthError, match="gh auth login --hostname github.com"): + GitHubAuth().resolve_token(_github_cli_entry(), "github.com") + + def test_resolve_token_github_cli_failure_redacts_command_output( + self, + caplog, + ): + from unittest.mock import MagicMock, patch + + secret = "ghp_must_not_leak" + result = MagicMock(returncode=2, stdout=secret, stderr=secret) + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ), pytest.raises(GitHubCliAuthError) as exc_info: + GitHubAuth().resolve_token(_github_cli_entry(), "github.com") + + assert secret not in str(exc_info.value) + assert secret not in caplog.text + + def test_resolve_token_github_cli_empty_output_raises(self): + from unittest.mock import MagicMock, patch + + result = MagicMock(returncode=0, stdout=" \n", stderr="") + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ), pytest.raises(GitHubCliAuthError, match="returned an empty token"): + GitHubAuth().resolve_token(_github_cli_entry(), "github.com") + + def test_resolve_token_github_cli_timeout_raises_without_output(self): + import subprocess + from unittest.mock import patch + + secret = b"ghp_timeout_secret" + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + timeout = subprocess.TimeoutExpired( + cmd=[executable, "auth", "token"], + timeout=10, + output=secret, + stderr=secret, + ) + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + side_effect=timeout, + ), pytest.raises(GitHubCliAuthError) as exc_info: + GitHubAuth().resolve_token(_github_cli_entry(), "github.com") + + assert "timed out" in str(exc_info.value) + assert secret.decode() not in str(exc_info.value) + + def test_resolve_token_github_cli_caches_failure(self): + from unittest.mock import patch + + provider = GitHubAuth() + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=None, + ) as which: + for _ in range(2): + with pytest.raises(GitHubCliAuthError): + provider.resolve_token(_github_cli_entry(), "github.com") + + assert which.call_count == 1 # --------------------------------------------------------------------------- @@ -1070,6 +1287,30 @@ def test_build_request_attaches_auth_for_matching_host(self, monkeypatch): req = build_request("https://github.com/org/repo") assert req.get_header("Authorization") == "Bearer my-token" + def test_build_request_resolves_github_cli_for_request_host(self, monkeypatch): + from unittest.mock import MagicMock, patch + + from specify_cli.authentication.http import build_request + + provider = get_provider("github") + assert isinstance(provider, GitHubAuth) + monkeypatch.setattr(provider, "_github_cli_cache", {}) + self._set_config(monkeypatch, [_github_cli_entry(("ghes.example.com",))]) + result = MagicMock(returncode=0, stdout="enterprise-token") + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ) as run: + req = build_request("https://ghes.example.com/org/repo") + + assert req.get_header("Authorization") == "Bearer enterprise-token" + assert run.call_args.args[0][-2:] == ["--hostname", "ghes.example.com"] + def test_build_request_attaches_bitbucket_basic_auth(self, monkeypatch): from specify_cli.authentication.http import build_request monkeypatch.setenv("ATLASSIAN_API_TOKEN", "api-tok") From feb98210f31b51401c5fbddced6bccb42d26eebc Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 14 Oct 2026 12:22:08 -0500 Subject: [PATCH 02/17] fix(auth): address GitHub CLI host handling Pass explicit GitHub CLI hostnames, map configured enterprise service hosts to their instance login, and route sanitized credential failures through existing URL error handling. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 434e2508-0633-4d40-8e0d-5edb92cbc177 --- docs/reference/authentication.md | 22 ++-- src/specify_cli/authentication/github.py | 34 ++++-- .../authentication/test_authentication.py | 102 +++++++++++++++++- tests/test_extensions.py | 63 +++++++++++ 4 files changed, 202 insertions(+), 19 deletions(-) diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index be769af819..6d3887d687 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -92,9 +92,10 @@ Either `token` or `token_env` must be set for the `bearer`, `basic-pat`, and `ba ``` Install [GitHub CLI](https://cli.github.com/) and run `gh auth login` before -using this mode. Specify invokes the fixed command `gh auth token` directly, -without a shell, and caches the resulting token in memory for the current -process. The token is not written to disk by Specify. +using this mode. Specify invokes the fixed command +`gh auth token --hostname github.com` directly, without a shell, and caches +the resulting token in memory for the current process. The token is not +written to disk by Specify. ### GitHub Enterprise Server (GHES) @@ -123,17 +124,20 @@ and `codeload.` subdomains your catalog/extension URLs use. A `*.ghes.example.com` wildcard matches subdomains but **not** the bare host, so always include the bare host explicitly. -With `auth: "github-cli"`, Specify requests the credential for the exact -enterprise request host by running: +With `auth: "github-cli"`, Specify requests the credential for the configured +GitHub instance host: ```bash gh auth token --hostname ghes.example.com ``` -The host must already be configured in GitHub CLI. If it is not configured, -or GitHub CLI is not authenticated for it, Specify fails the request and -suggests `gh auth login --hostname ghes.example.com`. GitHub CLI command output -is not included in the error. +When a request targets an `api.`, `raw.`, or `codeload.` host, Specify uses the +bare instance host only when that exact host is also listed in the same +provider entry. For example, `raw.ghes.example.com` maps to +`ghes.example.com` in the configuration above. Otherwise, the request host is +used unchanged. The selected host must already be configured and authenticated +in GitHub CLI; failures suggest `gh auth login --hostname ` without +including GitHub CLI command output in the error. ### GitHub Enterprise Cloud with data residency (GHE.com) diff --git a/src/specify_cli/authentication/github.py b/src/specify_cli/authentication/github.py index b2cc1da431..b9e1599e5d 100644 --- a/src/specify_cli/authentication/github.py +++ b/src/specify_cli/authentication/github.py @@ -5,6 +5,7 @@ import os import shutil import subprocess +import urllib.error from typing import TYPE_CHECKING from .base import AuthProvider @@ -20,11 +21,16 @@ "raw.githubusercontent.com", "codeload.github.com", }) +_GITHUB_ENTERPRISE_SERVICE_PREFIXES = ("api.", "raw.", "codeload.") -class GitHubCliAuthError(RuntimeError): +class GitHubCliAuthError(urllib.error.URLError): """Raised when GitHub CLI cannot provide a configured credential.""" + def __str__(self) -> str: + """Return the sanitized reason without URLError's wrapper.""" + return str(self.reason) + class GitHubAuth(AuthProvider): """GitHub authentication provider. @@ -57,9 +63,7 @@ def resolve_token( return super().resolve_token(entry, hostname) request_host = (hostname or entry.hosts[0]).lower() - credential_host = ( - "github.com" if request_host in _GITHUB_PUBLIC_HOSTS else request_host - ) + credential_host = self._credential_hostname(entry, request_host) cached = self._github_cli_cache.get(credential_host) if cached is not None: succeeded, value = cached @@ -77,6 +81,24 @@ def resolve_token( self._github_cli_cache[credential_host] = (True, token) return token + @staticmethod + def _credential_hostname(entry: AuthConfigEntry, request_host: str) -> str: + """Map a request host to the GitHub CLI login host.""" + if request_host in _GITHUB_PUBLIC_HOSTS: + return "github.com" + + exact_hosts = { + configured_host.lower() + for configured_host in entry.hosts + if "*" not in configured_host + } + for prefix in _GITHUB_ENTERPRISE_SERVICE_PREFIXES: + if request_host.startswith(prefix): + instance_host = request_host.removeprefix(prefix) + if instance_host in exact_hosts: + return instance_host + return request_host + @staticmethod def _acquire_via_gh_cli(hostname: str) -> str: """Run the fixed GitHub CLI token command for *hostname*.""" @@ -87,9 +109,7 @@ def _acquire_via_gh_cli(hostname: str) -> str: "GitHub CLI ('gh') was not found." ) - argv = [resolved, "auth", "token"] - if hostname != "github.com": - argv.extend(["--hostname", hostname]) + argv = [resolved, "auth", "token", "--hostname", hostname] try: result = subprocess.run( # noqa: S603 diff --git a/tests/specify_cli/authentication/test_authentication.py b/tests/specify_cli/authentication/test_authentication.py index e9069b47b9..63f26d82e4 100644 --- a/tests/specify_cli/authentication/test_authentication.py +++ b/tests/specify_cli/authentication/test_authentication.py @@ -18,6 +18,7 @@ import io import json import os +import urllib.error import pytest @@ -646,9 +647,13 @@ def test_github_cli_headers_use_bearer(self): "Authorization": "Bearer cli-token" } - def test_resolve_token_github_cli_success_without_shell(self): + def test_resolve_token_github_cli_success_without_shell( + self, + monkeypatch, + ): from unittest.mock import MagicMock, patch + monkeypatch.setenv("GH_HOST", "enterprise.example.com") provider = GitHubAuth() result = MagicMock(returncode=0, stdout=" cli-token\n") executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") @@ -665,7 +670,13 @@ def test_resolve_token_github_cli_success_without_shell(self): "github.com", ) == "cli-token" - assert run.call_args.args[0] == [executable, "auth", "token"] + assert run.call_args.args[0] == [ + executable, + "auth", + "token", + "--hostname", + "github.com", + ] assert run.call_args.kwargs == { "capture_output": True, "text": True, @@ -701,6 +712,66 @@ def test_resolve_token_github_cli_uses_enterprise_hostname(self): "ghes.example.com", ] + @pytest.mark.parametrize( + "request_host", + [ + "api.ghes.example.com", + "raw.ghes.example.com", + "codeload.ghes.example.com", + ], + ) + def test_resolve_token_github_cli_maps_enterprise_service_host( + self, + request_host, + ): + from unittest.mock import MagicMock, patch + + provider = GitHubAuth() + result = MagicMock(returncode=0, stdout="enterprise-token") + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + entry = _github_cli_entry(( + "ghes.example.com", + "api.ghes.example.com", + "raw.ghes.example.com", + "codeload.ghes.example.com", + )) + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ) as run: + assert provider.resolve_token(entry, request_host) == "enterprise-token" + + assert run.call_args.args[0][-2:] == ["--hostname", "ghes.example.com"] + + def test_resolve_token_github_cli_does_not_infer_unconfigured_instance_host(self): + from unittest.mock import MagicMock, patch + + provider = GitHubAuth() + result = MagicMock(returncode=0, stdout="enterprise-token") + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + entry = _github_cli_entry(("raw.ghes.example.com",)) + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ) as run: + assert provider.resolve_token( + entry, + "raw.ghes.example.com", + ) == "enterprise-token" + + assert run.call_args.args[0][-2:] == [ + "--hostname", + "raw.ghes.example.com", + ] + def test_resolve_token_github_cli_caches_per_credential_host(self): from unittest.mock import MagicMock, patch @@ -711,6 +782,7 @@ def test_resolve_token_github_cli_caches_per_credential_host(self): "github.com", "api.github.com", "ghes.example.com", + "raw.ghes.example.com", )) with patch( @@ -723,7 +795,7 @@ def test_resolve_token_github_cli_caches_per_credential_host(self): assert provider.resolve_token(entry, "github.com") == "cached-token" assert provider.resolve_token(entry, "api.github.com") == "cached-token" assert provider.resolve_token(entry, "ghes.example.com") == "cached-token" - assert provider.resolve_token(entry, "ghes.example.com") == "cached-token" + assert provider.resolve_token(entry, "raw.ghes.example.com") == "cached-token" assert run.call_count == 2 @@ -737,6 +809,8 @@ def test_resolve_token_github_cli_missing_raises_clear_error(self): GitHubAuth().resolve_token(_github_cli_entry(), "github.com") message = str(exc_info.value) + assert isinstance(exc_info.value, urllib.error.URLError) + assert not message.startswith(" Date: Wed, 14 Oct 2026 12:45:39 -0500 Subject: [PATCH 03/17] fix(auth): preserve GitHub CLI diagnostics Keep sanitized GitHub CLI credential failures distinct from offline errors in release checks and upgrade output, prove no HTTP fallback occurs, and document terminal failure caching. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 434e2508-0633-4d40-8e0d-5edb92cbc177 --- docs/reference/authentication.md | 8 ++++++++ src/specify_cli/_version.py | 7 +++++++ .../authentication/test_authentication.py | 15 ++++++++++++--- tests/specify_cli/test_version_verification.py | 18 ++++++++++++++++++ 4 files changed, 45 insertions(+), 3 deletions(-) diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index 6d3887d687..33fd7871db 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -97,6 +97,14 @@ using this mode. Specify invokes the fixed command the resulting token in memory for the current process. The token is not written to disk by Specify. +A configured `github-cli` entry is authoritative for its matching hosts. If +GitHub CLI cannot provide a token, Specify fails that credential lookup before +making an HTTP request; it does not try later matching entries or an +unauthenticated fallback for that request. Successful tokens and sanitized +lookup failures are both cached per selected credential host for the current +process. After correcting a GitHub CLI login, start a new Specify invocation +to retry the lookup. + ### GitHub Enterprise Server (GHES) To use a private catalog or extension hosted on a GitHub Enterprise Server diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 93b4c6a618..afdf71b8de 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -37,6 +37,9 @@ "rate limited (configure ~/.specify/auth.json with a GitHub token)" ) _RESOLUTION_FAILURE_HTTP_PREFIX = "HTTP " +_RESOLUTION_FAILURE_GITHUB_AUTH_PREFIX = ( + "GitHub authentication failed for provider 'github': " +) _FAILURE_INSTALLER_MISSING = "installer-missing" _FAILURE_INSTALLER_INVALID = "installer-invalid" _FAILURE_TARGET_TAG_UNPARSEABLE = "target-tag-unparseable" @@ -112,6 +115,7 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]: On anything else — including a malformed response body — the exception propagates; there is no catch-all (research D-006). """ + from .authentication.github import GitHubCliAuthError from .authentication.http import open_url try: @@ -139,6 +143,8 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]: if e.code in (403, 429): return None, _RESOLUTION_FAILURE_RATE_LIMITED return None, f"{_RESOLUTION_FAILURE_HTTP_PREFIX}{e.code}" + except GitHubCliAuthError as e: + return None, str(e) except (urllib.error.URLError, OSError): return None, _RESOLUTION_FAILURE_OFFLINE @@ -1022,6 +1028,7 @@ def _emit_failure( if ( category in _RESOLUTION_FAILURE_CATEGORIES or category.startswith(_RESOLUTION_FAILURE_HTTP_PREFIX) + or category.startswith(_RESOLUTION_FAILURE_GITHUB_AUTH_PREFIX) ): console.print(f"Upgrade aborted: {category}", soft_wrap=True) return diff --git a/tests/specify_cli/authentication/test_authentication.py b/tests/specify_cli/authentication/test_authentication.py index 63f26d82e4..4a59b5f0e5 100644 --- a/tests/specify_cli/authentication/test_authentication.py +++ b/tests/specify_cli/authentication/test_authentication.py @@ -1984,7 +1984,10 @@ def test_accept_header_present(self, monkeypatch): _fetch_latest_release_tag() assert captured["request"].get_header("Accept") == "application/vnd.github+json" - def test_github_cli_failure_maps_to_offline(self, monkeypatch): + def test_github_cli_failure_preserves_message_without_http( + self, + monkeypatch, + ): from unittest.mock import patch from specify_cli._version import _fetch_latest_release_tag @@ -2000,11 +2003,17 @@ def test_github_cli_failure_maps_to_offline(self, monkeypatch): with patch( "specify_cli.authentication.github.shutil.which", return_value=None, - ): + ), patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener: tag, reason = _fetch_latest_release_tag() assert tag is None - assert reason == "offline or timeout" + assert reason == ( + "GitHub authentication failed for provider 'github': " + "GitHub CLI ('gh') was not found." + ) + build_opener.assert_not_called() # --------------------------------------------------------------------------- diff --git a/tests/specify_cli/test_version_verification.py b/tests/specify_cli/test_version_verification.py index f3311bb23f..062fd82201 100644 --- a/tests/specify_cli/test_version_verification.py +++ b/tests/specify_cli/test_version_verification.py @@ -285,6 +285,24 @@ def test_offline_exits_1_with_phase1_string(self, uv_tool_argv0, clean_environ): assert result.exit_code == 1 assert "Upgrade aborted: offline or timeout" in strip_ansi(result.output) + def test_github_cli_auth_failure_preserves_actionable_message( + self, + uv_tool_argv0, + clean_environ, + ): + failure = ( + "GitHub authentication failed for provider 'github': " + "GitHub CLI ('gh') was not found." + ) + with patch( + "specify_cli._version._fetch_latest_release_tag", + return_value=(None, failure), + ): + result = runner.invoke(app, ["self", "upgrade"]) + + assert result.exit_code == 1 + assert f"Upgrade aborted: {failure}" in strip_ansi(result.output) + def test_rate_limited_exits_1(self, uv_tool_argv0, clean_environ): err = urllib.error.HTTPError( url="https://api.github.com", From e07aa6e68d8e744a8583a969063c804e3234202e Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:28:35 -0500 Subject: [PATCH 04/17] fix(auth): scope credentials across redirects Prevent GitHub CLI tokens from following redirects to a different GitHub credential host while preserving authorization across public and configured enterprise aliases. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 434e2508-0633-4d40-8e0d-5edb92cbc177 --- docs/reference/authentication.md | 4 +- src/specify_cli/authentication/base.py | 14 +++ src/specify_cli/authentication/github.py | 16 +++ src/specify_cli/authentication/http.py | 28 ++++- .../authentication/test_authentication.py | 117 ++++++++++++++++++ 5 files changed, 176 insertions(+), 3 deletions(-) diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index 33fd7871db..045db6e012 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -319,7 +319,9 @@ You can configure multiple entries for different hosts or organizations: is attempted as a final fallback. 5. On redirects, the `Authorization` header is stripped if the redirect target leaves the entry's declared hosts — preventing credential - leakage to CDNs or third-party services. + leakage to CDNs or third-party services. For `github-cli`, the header is + also stripped when the target maps to a different GitHub CLI credential + host, even if both hosts appear in the same provider entry. ## Template diff --git a/src/specify_cli/authentication/base.py b/src/specify_cli/authentication/base.py index a34d266100..e83fdfce83 100644 --- a/src/specify_cli/authentication/base.py +++ b/src/specify_cli/authentication/base.py @@ -62,3 +62,17 @@ def resolve_token( if val: return val return None + + def can_reuse_token_for_host( + self, + entry: AuthConfigEntry, + source_hostname: str | None, + target_hostname: str, + ) -> bool: + """Return whether a resolved token may follow a redirect. + + Static credentials are scoped by the entry's declared hosts, so the + default allows reuse within those hosts. Providers with host-selected + credentials can narrow that scope. + """ + return True diff --git a/src/specify_cli/authentication/github.py b/src/specify_cli/authentication/github.py index b9e1599e5d..40f60df00d 100644 --- a/src/specify_cli/authentication/github.py +++ b/src/specify_cli/authentication/github.py @@ -99,6 +99,22 @@ def _credential_hostname(entry: AuthConfigEntry, request_host: str) -> str: return instance_host return request_host + def can_reuse_token_for_host( + self, + entry: AuthConfigEntry, + source_hostname: str | None, + target_hostname: str, + ) -> bool: + """Allow redirects only within one GitHub CLI credential scope.""" + if entry.auth != "github-cli": + return True + if source_hostname is None: + return False + return self._credential_hostname( + entry, + source_hostname, + ) == self._credential_hostname(entry, target_hostname) + @staticmethod def _acquire_via_gh_cli(hostname: str) -> str: """Run the fixed GitHub CLI token command for *hostname*.""" diff --git a/src/specify_cli/authentication/http.py b/src/specify_cli/authentication/http.py index 6e63382357..84f1700b60 100644 --- a/src/specify_cli/authentication/http.py +++ b/src/specify_cli/authentication/http.py @@ -63,6 +63,7 @@ def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool: RedirectValidator = Callable[[str, str], None] +AuthHostnameValidator = Callable[[str], bool] class RedirectPolicyError(urllib.error.URLError): @@ -99,10 +100,12 @@ def __init__( self, hosts: tuple[str, ...], redirect_validator: RedirectValidator | None = None, + auth_hostname_validator: AuthHostnameValidator | None = None, ) -> None: super().__init__() self._hosts = hosts self._redirect_validator = redirect_validator + self._auth_hostname_validator = auth_hostname_validator def redirect_request(self, req, fp, code, msg, headers, newurl): try: @@ -127,7 +130,15 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): old_scheme = urlparse(req.full_url).scheme hostname = (new_parsed.hostname or "").lower() is_https_downgrade = old_scheme == "https" and new_parsed.scheme != "https" - if _hostname_in_hosts(hostname, self._hosts) and not is_https_downgrade: + is_auth_hostname_allowed = ( + self._auth_hostname_validator is None + or self._auth_hostname_validator(hostname) + ) + if ( + _hostname_in_hosts(hostname, self._hosts) + and not is_https_downgrade + and is_auth_hostname_allowed + ): if original_auth: new_req.add_unredirected_header("Authorization", original_auth) else: @@ -220,7 +231,20 @@ def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request: continue req = _make_req(provider.auth_headers(token, entry.auth)) - opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts, redirect_validator)) + def auth_hostname_validator(target_hostname: str) -> bool: + return provider.can_reuse_token_for_host( + entry, + hostname, + target_hostname, + ) + + opener = urllib.request.build_opener( + _StripAuthOnRedirect( + entry.hosts, + redirect_validator, + auth_hostname_validator, + ) + ) try: return opener.open(req, timeout=timeout) except urllib.error.HTTPError as exc: diff --git a/tests/specify_cli/authentication/test_authentication.py b/tests/specify_cli/authentication/test_authentication.py index 4a59b5f0e5..775d4e0c9e 100644 --- a/tests/specify_cli/authentication/test_authentication.py +++ b/tests/specify_cli/authentication/test_authentication.py @@ -772,6 +772,54 @@ def test_resolve_token_github_cli_does_not_infer_unconfigured_instance_host(self "raw.ghes.example.com", ] + @pytest.mark.parametrize( + ("source", "target"), + [ + ("github.com", "api.github.com"), + ("github.com", "codeload.github.com"), + ("ghes.example.com", "api.ghes.example.com"), + ("ghes.example.com", "raw.ghes.example.com"), + ("ghes.example.com", "codeload.ghes.example.com"), + ], + ) + def test_github_cli_reuses_token_within_credential_host( + self, + source, + target, + ): + provider = GitHubAuth() + entry = _github_cli_entry(( + "github.com", + "api.github.com", + "codeload.github.com", + "ghes.example.com", + "api.ghes.example.com", + "raw.ghes.example.com", + "codeload.ghes.example.com", + )) + + assert provider.can_reuse_token_for_host(entry, source, target) + + def test_github_cli_does_not_reuse_token_across_instances(self): + provider = GitHubAuth() + entry = _github_cli_entry(( + "github.com", + "api.github.com", + "ghes.example.com", + "raw.ghes.example.com", + )) + + assert not provider.can_reuse_token_for_host( + entry, + "github.com", + "ghes.example.com", + ) + assert not provider.can_reuse_token_for_host( + entry, + "raw.ghes.example.com", + "api.github.com", + ) + def test_resolve_token_github_cli_caches_per_credential_host(self): from unittest.mock import MagicMock, patch @@ -1650,6 +1698,75 @@ def test_redirect_outside_hosts_strips_auth(self): assert new_req.headers.get("Authorization") is None assert new_req.unredirected_hdrs.get("Authorization") is None + def test_redirect_across_credential_scopes_strips_auth(self): + from specify_cli.authentication.http import _StripAuthOnRedirect + from urllib.request import Request + import io + + provider = GitHubAuth() + entry = _github_cli_entry(("github.com", "ghes.example.com")) + handler = _StripAuthOnRedirect( + entry.hosts, + auth_hostname_validator=lambda target: provider.can_reuse_token_for_host( + entry, + "github.com", + target, + ), + ) + req = Request( + "https://github.com/org/repo", + headers={"Authorization": "Bearer public-token"}, + ) + + new_req = handler.redirect_request( + req, + io.BytesIO(b""), + 302, + "Found", + {}, + "https://ghes.example.com/org/repo", + ) + + assert new_req is not None + assert new_req.get_header("Authorization") is None + assert new_req.unredirected_hdrs.get("Authorization") is None + + def test_redirect_within_credential_scope_preserves_auth(self): + from specify_cli.authentication.http import _StripAuthOnRedirect + from urllib.request import Request + import io + + provider = GitHubAuth() + entry = _github_cli_entry(("ghes.example.com", "raw.ghes.example.com")) + handler = _StripAuthOnRedirect( + entry.hosts, + auth_hostname_validator=lambda target: provider.can_reuse_token_for_host( + entry, + "ghes.example.com", + target, + ), + ) + req = Request( + "https://ghes.example.com/org/repo", + headers={"Authorization": "Bearer enterprise-token"}, + ) + + new_req = handler.redirect_request( + req, + io.BytesIO(b""), + 302, + "Found", + {}, + "https://raw.ghes.example.com/org/repo/file", + ) + + assert new_req is not None + auth = ( + new_req.get_header("Authorization") + or new_req.unredirected_hdrs.get("Authorization") + ) + assert auth == "Bearer enterprise-token" + @pytest.mark.parametrize( ("hosts", "target", "expected_auth"), [ From 5fafafdd05618b12dd0192534659a62862e78181 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 14 Oct 2026 13:54:25 -0500 Subject: [PATCH 05/17] fix(auth): preserve bundle CLI diagnostics Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 434e2508-0633-4d40-8e0d-5edb92cbc177 --- src/specify_cli/bundles/adapters.py | 3 + src/specify_cli/bundles/component_catalog.py | 3 + .../authentication/test_authentication.py | 81 +++++++++++++++++++ tests/specify_cli/bundles/test_adapters.py | 35 ++++++++ tests/specify_cli/bundles/test_references.py | 30 +++++++ 5 files changed, 152 insertions(+) diff --git a/src/specify_cli/bundles/adapters.py b/src/specify_cli/bundles/adapters.py index 46433cb005..5dd40460ea 100644 --- a/src/specify_cli/bundles/adapters.py +++ b/src/specify_cli/bundles/adapters.py @@ -22,6 +22,7 @@ from .._assets import _locate_core_pack, _repo_root from .._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited +from ..authentication.github import GitHubCliAuthError from . import BundlerError from .catalogs import CatalogSource from .manifest import ComponentRef @@ -276,6 +277,8 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: raise BundlerError( f"Failed to fetch catalog from {url}: HTTP {exc.code} {exc.reason}" ) from exc + except GitHubCliAuthError as exc: + raise BundlerError(f"Failed to fetch catalog from {url}: {exc}") from exc except urllib.error.URLError as exc: # urllib wraps a TLS handshake failure as URLError(reason=); # a certificate-verification failure is a security failure, not a diff --git a/src/specify_cli/bundles/component_catalog.py b/src/specify_cli/bundles/component_catalog.py index d4b7a107c7..931463de00 100644 --- a/src/specify_cli/bundles/component_catalog.py +++ b/src/specify_cli/bundles/component_catalog.py @@ -6,6 +6,7 @@ from http.client import HTTPException from urllib.error import HTTPError, URLError +from ..authentication.github import GitHubCliAuthError from ..authentication.http import RedirectPolicyError from . import BundlerError from .manifest import ComponentRef @@ -19,6 +20,8 @@ def _is_unavailable(exc: BaseException) -> bool: seen: set[int] = set() while exc is not None and id(exc) not in seen: seen.add(id(exc)) + if isinstance(exc, GitHubCliAuthError): + return False if isinstance(exc, RedirectPolicyError): return False if isinstance(exc, HTTPError): diff --git a/tests/specify_cli/authentication/test_authentication.py b/tests/specify_cli/authentication/test_authentication.py index 775d4e0c9e..28b8d41c3f 100644 --- a/tests/specify_cli/authentication/test_authentication.py +++ b/tests/specify_cli/authentication/test_authentication.py @@ -862,6 +862,22 @@ def test_resolve_token_github_cli_missing_raises_clear_error(self): assert "provider 'github'" in message assert "GitHub CLI ('gh') was not found" in message + @pytest.mark.parametrize("executable", [r".\gh.exe", "gh.exe", "./gh"]) + def test_resolve_token_github_cli_rejects_relative_executable( + self, executable + ): + from unittest.mock import patch + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + ) as run, pytest.raises(GitHubCliAuthError, match="was not found"): + GitHubAuth().resolve_token(_github_cli_entry(), "github.com") + + run.assert_not_called() + def test_resolve_token_github_cli_unauthenticated_suggests_login(self): from unittest.mock import MagicMock, patch @@ -1482,6 +1498,71 @@ def fake_open(req, timeout=None): open_url("https://github.com/org/repo/catalog.json") assert captured["req"].get_header("Authorization") == "Bearer my-token" + def test_open_url_wires_github_cli_redirect_credential_scope(self, monkeypatch): + from unittest.mock import MagicMock, patch + + from specify_cli.authentication.http import ( + _StripAuthOnRedirect, + open_url, + ) + + provider = get_provider("github") + assert isinstance(provider, GitHubAuth) + monkeypatch.setattr(provider, "_github_cli_cache", {}) + self._set_config( + monkeypatch, + [ + _github_cli_entry( + ("github.com", "codeload.github.com", "ghes.example.com") + ) + ], + ) + result = MagicMock(returncode=0, stdout="github-token") + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + response = MagicMock() + mock_opener = MagicMock() + mock_opener.open.return_value = response + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ), patch( + "specify_cli.authentication.http.urllib.request.build_opener", + return_value=mock_opener, + ) as build_opener: + assert open_url("https://github.com/org/repo/archive.zip") is response + + request = mock_opener.open.call_args.args[0] + handler = next( + argument + for argument in build_opener.call_args.args + if isinstance(argument, _StripAuthOnRedirect) + ) + same_instance = handler.redirect_request( + request, + io.BytesIO(b""), + 302, + "Found", + {}, + "https://codeload.github.com/org/repo/archive.zip", + ) + cross_instance = handler.redirect_request( + request, + io.BytesIO(b""), + 302, + "Found", + {}, + "https://ghes.example.com/org/repo/archive.zip", + ) + + assert same_instance is not None + assert same_instance.get_header("Authorization") == "Bearer github-token" + assert cross_instance is not None + assert cross_instance.get_header("Authorization") is None + def test_open_url_no_auth_for_non_matching_host(self, monkeypatch): from unittest.mock import MagicMock, patch from specify_cli.authentication.http import open_url diff --git a/tests/specify_cli/bundles/test_adapters.py b/tests/specify_cli/bundles/test_adapters.py index 58e05d9706..f4b739a7b7 100644 --- a/tests/specify_cli/bundles/test_adapters.py +++ b/tests/specify_cli/bundles/test_adapters.py @@ -13,6 +13,7 @@ import pytest +from specify_cli.authentication.github import GitHubCliAuthError from specify_cli.authentication.http import _StripAuthOnRedirect from specify_cli.bundler import BundlerError from specify_cli.bundles.catalogs import CatalogSource, InstallPolicy @@ -111,6 +112,40 @@ def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): adapters._http_get_json("team", "https://example.com/c.json") +def test_http_get_json_preserves_github_cli_auth_error(monkeypatch): + def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): + raise GitHubCliAuthError( + "GitHub CLI authentication failed for github.com. " + "Run 'gh auth login --hostname github.com'." + ) + + monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) + + with pytest.raises(BundlerError, match="gh auth login") as excinfo: + adapters._http_get_json("team", "https://example.com/c.json") + assert not isinstance(excinfo.value, adapters._CatalogUnavailable) + + +def test_builtin_catalog_does_not_fall_back_for_github_cli_auth_error(monkeypatch): + def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): + raise GitHubCliAuthError( + "GitHub CLI authentication failed for github.com. " + "Run 'gh auth login --hostname github.com'." + ) + + monkeypatch.setattr("specify_cli.authentication.http.open_url", fake_open_url) + monkeypatch.setattr( + adapters, + "_load_packaged_catalog", + lambda filename: pytest.fail("authentication errors must not use a snapshot"), + ) + + fetcher = adapters.make_catalog_fetcher(allow_network=True) + with pytest.raises(BundlerError, match="gh auth login") as excinfo: + fetcher(_source("builtin://community")) + assert not isinstance(excinfo.value, adapters._CatalogUnavailable) + + def test_http_get_json_marks_server_error_unavailable(monkeypatch): def fake_open_url(url, timeout=10, extra_headers=None, redirect_validator=None): raise urllib.error.HTTPError(url, 503, "Service Unavailable", {}, None) diff --git a/tests/specify_cli/bundles/test_references.py b/tests/specify_cli/bundles/test_references.py index 261e1b2661..b034593e9c 100644 --- a/tests/specify_cli/bundles/test_references.py +++ b/tests/specify_cli/bundles/test_references.py @@ -492,6 +492,36 @@ def unavailable(self, source, force_refresh=False): assert "unreachable" in warnings[0] +def test_online_validation_preserves_wrapped_github_cli_auth_error( + tmp_path, monkeypatch, +): + from specify_cli.authentication.github import GitHubCliAuthError + from specify_cli.extensions import ExtensionCatalog, ExtensionError + + _mock_catalog(monkeypatch, ExtensionCatalog, "extensions", "requested", {}) + + def auth_failure(self, source, force_refresh=False): + try: + raise GitHubCliAuthError( + "GitHub CLI authentication failed for github.com. " + "Run 'gh auth login --hostname github.com'." + ) + except GitHubCliAuthError as exc: + raise ExtensionError( + "Failed to fetch catalog: " + "Run 'gh auth login --hostname github.com'." + ) from exc + + monkeypatch.setattr(ExtensionCatalog, "_fetch_single_catalog", auth_failure) + warnings = [] + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + + problem = check(_ref("extensions", "requested")) + + assert problem is not None and "gh auth login" in problem + assert warnings == [] + + @pytest.mark.parametrize("kind", ["extensions", "presets", "workflows", "steps"]) @pytest.mark.parametrize( ("unreachable", "has_match"), From 702806c81a7fae43da4753a685495b7d467e6185 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:28:43 -0500 Subject: [PATCH 06/17] fix(auth): validate GitHub CLI hostnames Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 434e2508-0633-4d40-8e0d-5edb92cbc177 --- docs/reference/authentication.md | 4 +++ src/specify_cli/authentication/github.py | 20 ++++++++++++ .../authentication/test_authentication.py | 31 +++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index 045db6e012..7a54a9b27c 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -139,6 +139,10 @@ GitHub instance host: gh auth token --hostname ghes.example.com ``` +Before invoking GitHub CLI, Specify requires the selected credential host to be +an ASCII DNS hostname made of letters, digits, hyphens, and dots. Invalid hosts +are rejected without invoking `gh` or including the rejected value in the error. + When a request targets an `api.`, `raw.`, or `codeload.` host, Specify uses the bare instance host only when that exact host is also listed in the same provider entry. For example, `raw.ghes.example.com` maps to diff --git a/src/specify_cli/authentication/github.py b/src/specify_cli/authentication/github.py index 40f60df00d..f79255af59 100644 --- a/src/specify_cli/authentication/github.py +++ b/src/specify_cli/authentication/github.py @@ -24,6 +24,20 @@ _GITHUB_ENTERPRISE_SERVICE_PREFIXES = ("api.", "raw.", "codeload.") +def _is_safe_cli_hostname(hostname: str) -> bool: + """Return whether *hostname* is a shell-safe DNS hostname.""" + if not hostname or len(hostname) > 253 or not hostname.isascii(): + return False + labels = hostname.split(".") + return all( + 0 < len(label) <= 63 + and label[0].isalnum() + and label[-1].isalnum() + and all(char.isalnum() or char == "-" for char in label) + for label in labels + ) + + class GitHubCliAuthError(urllib.error.URLError): """Raised when GitHub CLI cannot provide a configured credential.""" @@ -118,6 +132,12 @@ def can_reuse_token_for_host( @staticmethod def _acquire_via_gh_cli(hostname: str) -> str: """Run the fixed GitHub CLI token command for *hostname*.""" + if not _is_safe_cli_hostname(hostname): + raise GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + "the request hostname is not valid for GitHub CLI authentication." + ) + resolved = shutil.which("gh") if not resolved or not os.path.isabs(resolved): raise GitHubCliAuthError( diff --git a/tests/specify_cli/authentication/test_authentication.py b/tests/specify_cli/authentication/test_authentication.py index 28b8d41c3f..d0cb4f60f2 100644 --- a/tests/specify_cli/authentication/test_authentication.py +++ b/tests/specify_cli/authentication/test_authentication.py @@ -1581,6 +1581,37 @@ def fake_open(req, timeout=None): open_url("https://example.com/file.json") assert captured["req"].get_header("Authorization") is None + def test_open_url_rejects_shell_unsafe_github_cli_hostname(self, monkeypatch): + from unittest.mock import patch + + from specify_cli.authentication.http import open_url + + attacker_host = "a&whoami&b.ghes.example.com" + provider = get_provider("github") + assert isinstance(provider, GitHubAuth) + monkeypatch.setattr(provider, "_github_cli_cache", {}) + self._set_config( + monkeypatch, + [_github_cli_entry(("*.ghes.example.com",))], + ) + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value="/opt/github-cli/gh.cmd", + ), patch( + "specify_cli.authentication.github.subprocess.run", + ) as run, patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener, pytest.raises( + GitHubCliAuthError, + match="hostname is not valid", + ) as exc_info: + open_url(f"https://{attacker_host}/archive.zip") + + assert attacker_host not in str(exc_info.value) + run.assert_not_called() + build_opener.assert_not_called() + def test_open_url_no_auth_when_no_config(self, monkeypatch): from unittest.mock import MagicMock, patch from specify_cli.authentication.http import open_url From fe472fe172c6b27c53c964223324d6f3db09bdbe Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Wed, 14 Oct 2026 15:14:15 -0500 Subject: [PATCH 07/17] refactor(auth): narrow GitHub CLI provider scope Remove consumer-specific error handling, redirect policy changes, and shared provider API changes. Keep GitHub CLI host selection within the GitHub provider by treating each provider entry as one credential scope. Assisted-by: GitHub Copilot (model: GPT-5.6 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Copilot-Session: 434e2508-0633-4d40-8e0d-5edb92cbc177 --- docs/reference/authentication.md | 57 +- src/specify_cli/_version.py | 7 - .../authentication/azure_devops.py | 8 +- src/specify_cli/authentication/base.py | 25 +- src/specify_cli/authentication/bitbucket.py | 8 +- src/specify_cli/authentication/config.py | 2 +- src/specify_cli/authentication/github.py | 93 ++- src/specify_cli/authentication/http.py | 42 +- src/specify_cli/bundles/adapters.py | 3 - src/specify_cli/bundles/component_catalog.py | 3 - .../authentication/test_authentication.py | 605 ++++-------------- tests/specify_cli/bundles/test_adapters.py | 35 - tests/specify_cli/bundles/test_references.py | 30 - .../specify_cli/test_version_verification.py | 18 - tests/test_extensions.py | 63 -- 15 files changed, 193 insertions(+), 806 deletions(-) diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index 7a54a9b27c..1b20f34305 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -59,7 +59,9 @@ Either `token` or `token_env` must be set for the `bearer`, `basic-pat`, and `ba | Scheme | Header | Use for | |---|---|---| | `bearer` | `Authorization: Bearer ` | PATs, fine-grained PATs, OAuth tokens, GitHub App tokens | -| `github-cli` | `Authorization: Bearer ` | Token acquired on demand from `gh auth token` | + +The `github` provider supports `bearer` tokens and `github-cli` credentials +acquired on demand from GitHub CLI. **Example — PAT via environment variable:** @@ -72,7 +74,7 @@ Either `token` or `token_env` must be set for the `bearer`, `basic-pat`, and `ba } ``` -**Example — GitHub CLI (recommended for interactive use):** +**Example — GitHub CLI:** ```json { @@ -92,18 +94,9 @@ Either `token` or `token_env` must be set for the `bearer`, `basic-pat`, and `ba ``` Install [GitHub CLI](https://cli.github.com/) and run `gh auth login` before -using this mode. Specify invokes the fixed command -`gh auth token --hostname github.com` directly, without a shell, and caches -the resulting token in memory for the current process. The token is not -written to disk by Specify. - -A configured `github-cli` entry is authoritative for its matching hosts. If -GitHub CLI cannot provide a token, Specify fails that credential lookup before -making an HTTP request; it does not try later matching entries or an -unauthenticated fallback for that request. Successful tokens and sanitized -lookup failures are both cached per selected credential host for the current -process. After correcting a GitHub CLI login, start a new Specify invocation -to retry the lookup. +using this mode. Specify runs the fixed command +`gh auth token --hostname github.com` without a shell, caches the result in +memory for the current process, and never writes the token to disk. ### GitHub Enterprise Server (GHES) @@ -132,24 +125,26 @@ and `codeload.` subdomains your catalog/extension URLs use. A `*.ghes.example.com` wildcard matches subdomains but **not** the bare host, so always include the bare host explicitly. -With `auth: "github-cli"`, Specify requests the credential for the configured -GitHub instance host: +With `auth: "github-cli"`, one provider entry represents one GitHub instance. +List exactly one bare enterprise host plus any service aliases that share its +credential: -```bash -gh auth token --hostname ghes.example.com +```json +{ + "hosts": [ + "ghes.example.com", + "raw.ghes.example.com", + "codeload.ghes.example.com" + ], + "provider": "github", + "auth": "github-cli" +} ``` -Before invoking GitHub CLI, Specify requires the selected credential host to be -an ASCII DNS hostname made of letters, digits, hyphens, and dots. Invalid hosts -are rejected without invoking `gh` or including the rejected value in the error. - -When a request targets an `api.`, `raw.`, or `codeload.` host, Specify uses the -bare instance host only when that exact host is also listed in the same -provider entry. For example, `raw.ghes.example.com` maps to -`ghes.example.com` in the configuration above. Otherwise, the request host is -used unchanged. The selected host must already be configured and authenticated -in GitHub CLI; failures suggest `gh auth login --hostname ` without -including GitHub CLI command output in the error. +Specify runs `gh auth token --hostname ghes.example.com`. Use separate provider +entries for separate GitHub instances. The selected host must already be +authenticated in GitHub CLI; failures suggest `gh auth login --hostname ` +without including GitHub CLI command output. ### GitHub Enterprise Cloud with data residency (GHE.com) @@ -323,9 +318,7 @@ You can configure multiple entries for different hosts or organizations: is attempted as a final fallback. 5. On redirects, the `Authorization` header is stripped if the redirect target leaves the entry's declared hosts — preventing credential - leakage to CDNs or third-party services. For `github-cli`, the header is - also stripped when the target maps to a different GitHub CLI credential - host, even if both hosts appear in the same provider entry. + leakage to CDNs or third-party services. ## Template diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index afdf71b8de..93b4c6a618 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -37,9 +37,6 @@ "rate limited (configure ~/.specify/auth.json with a GitHub token)" ) _RESOLUTION_FAILURE_HTTP_PREFIX = "HTTP " -_RESOLUTION_FAILURE_GITHUB_AUTH_PREFIX = ( - "GitHub authentication failed for provider 'github': " -) _FAILURE_INSTALLER_MISSING = "installer-missing" _FAILURE_INSTALLER_INVALID = "installer-invalid" _FAILURE_TARGET_TAG_UNPARSEABLE = "target-tag-unparseable" @@ -115,7 +112,6 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]: On anything else — including a malformed response body — the exception propagates; there is no catch-all (research D-006). """ - from .authentication.github import GitHubCliAuthError from .authentication.http import open_url try: @@ -143,8 +139,6 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]: if e.code in (403, 429): return None, _RESOLUTION_FAILURE_RATE_LIMITED return None, f"{_RESOLUTION_FAILURE_HTTP_PREFIX}{e.code}" - except GitHubCliAuthError as e: - return None, str(e) except (urllib.error.URLError, OSError): return None, _RESOLUTION_FAILURE_OFFLINE @@ -1028,7 +1022,6 @@ def _emit_failure( if ( category in _RESOLUTION_FAILURE_CATEGORIES or category.startswith(_RESOLUTION_FAILURE_HTTP_PREFIX) - or category.startswith(_RESOLUTION_FAILURE_GITHUB_AUTH_PREFIX) ): console.print(f"Upgrade aborted: {category}", soft_wrap=True) return diff --git a/src/specify_cli/authentication/azure_devops.py b/src/specify_cli/authentication/azure_devops.py index 904a1310c2..91578bd418 100644 --- a/src/specify_cli/authentication/azure_devops.py +++ b/src/specify_cli/authentication/azure_devops.py @@ -58,17 +58,13 @@ def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: f"AzureDevOpsAuth does not support auth scheme {auth_scheme!r}" ) - def resolve_token( - self, - entry: AuthConfigEntry, - hostname: str | None = None, - ) -> str | None: + 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, hostname) + return super().resolve_token(entry) # -- Token acquisition ------------------------------------------------ diff --git a/src/specify_cli/authentication/base.py b/src/specify_cli/authentication/base.py index e83fdfce83..d6e0f4b118 100644 --- a/src/specify_cli/authentication/base.py +++ b/src/specify_cli/authentication/base.py @@ -20,7 +20,7 @@ class AuthProvider(ABC): And implement: * ``auth_headers(token, auth_scheme)`` — build headers from a resolved token - * ``resolve_token(entry, hostname)`` — obtain the token for a config entry + * ``resolve_token(entry)`` — obtain the token for a config entry """ key: str = "" @@ -36,20 +36,13 @@ def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: Must return a dict with at least an ``Authorization`` key. """ - def resolve_token( - self, - entry: AuthConfigEntry, - hostname: str | None = None, - ) -> str | None: + def resolve_token(self, entry: AuthConfigEntry) -> str | None: """Resolve the token for *entry*. Default implementation reads from ``entry.token`` directly or from the environment variable named by ``entry.token_env``. Override for schemes that acquire tokens dynamically (e.g. ``azure-cli``, ``azure-ad``). - - ``hostname`` identifies the request target for providers whose - credential helper selects credentials by host. """ import os @@ -62,17 +55,3 @@ def resolve_token( if val: return val return None - - def can_reuse_token_for_host( - self, - entry: AuthConfigEntry, - source_hostname: str | None, - target_hostname: str, - ) -> bool: - """Return whether a resolved token may follow a redirect. - - Static credentials are scoped by the entry's declared hosts, so the - default allows reuse within those hosts. Providers with host-selected - credentials can narrow that scope. - """ - return True diff --git a/src/specify_cli/authentication/bitbucket.py b/src/specify_cli/authentication/bitbucket.py index c561535a6a..24474e6b5b 100644 --- a/src/specify_cli/authentication/bitbucket.py +++ b/src/specify_cli/authentication/bitbucket.py @@ -59,11 +59,7 @@ def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: f"BitbucketAuth does not support auth scheme {auth_scheme!r}" ) - def resolve_token( - self, - entry: AuthConfigEntry, - hostname: str | None = None, - ) -> str | None: + def resolve_token(self, entry: AuthConfigEntry) -> str | None: """Resolve the credential, combining ``username`` for ``basic``. Returns ``None`` when the secret is missing, or — for ``basic`` — @@ -73,7 +69,7 @@ def resolve_token( ``:`` credential or a ``user:name:`` one that the server would parse as user ``user`` (RFC 7617 §2). """ - secret = super().resolve_token(entry, hostname) + secret = super().resolve_token(entry) if entry.auth != "basic": return secret username = (entry.username or "").strip() diff --git a/src/specify_cli/authentication/config.py b/src/specify_cli/authentication/config.py index 2f5d6a72d2..296c5c4d39 100644 --- a/src/specify_cli/authentication/config.py +++ b/src/specify_cli/authentication/config.py @@ -220,7 +220,7 @@ def load_auth_config( raise ValueError( f"providers[{i}]: '{field_name}' must be a non-empty string" ) - # azure-cli and github-cli need no extra fields + # azure-cli needs no extra fields entries.append( AuthConfigEntry( diff --git a/src/specify_cli/authentication/github.py b/src/specify_cli/authentication/github.py index f79255af59..6f13f349c2 100644 --- a/src/specify_cli/authentication/github.py +++ b/src/specify_cli/authentication/github.py @@ -5,7 +5,6 @@ import os import shutil import subprocess -import urllib.error from typing import TYPE_CHECKING from .base import AuthProvider @@ -21,7 +20,7 @@ "raw.githubusercontent.com", "codeload.github.com", }) -_GITHUB_ENTERPRISE_SERVICE_PREFIXES = ("api.", "raw.", "codeload.") +_GITHUB_SERVICE_PREFIXES = ("api.", "raw.", "codeload.") def _is_safe_cli_hostname(hostname: str) -> bool: @@ -38,20 +37,12 @@ def _is_safe_cli_hostname(hostname: str) -> bool: ) -class GitHubCliAuthError(urllib.error.URLError): +class GitHubCliAuthError(RuntimeError): """Raised when GitHub CLI cannot provide a configured credential.""" - def __str__(self) -> str: - """Return the sanitized reason without URLError's wrapper.""" - return str(self.reason) - class GitHubAuth(AuthProvider): - """GitHub authentication provider. - - Supports ``bearer`` for PATs, OAuth tokens, and GitHub App installation - tokens, plus ``github-cli`` for credentials managed by GitHub CLI. - """ + """GitHub authentication provider.""" key = "github" supported_auth_schemes = ("bearer", "github-cli") @@ -60,24 +51,19 @@ def __init__(self) -> None: self._github_cli_cache: dict[str, tuple[bool, str]] = {} def auth_headers(self, token: str, auth_scheme: str) -> dict[str, str]: - """Return ``Authorization: Bearer ``.""" + """Return a bearer authorization header.""" if auth_scheme not in self.supported_auth_schemes: raise ValueError( f"GitHubAuth does not support auth scheme {auth_scheme!r}" ) - return {"Authorization": f"Bearer {token}"} - - def resolve_token( - self, - entry: AuthConfigEntry, - hostname: str | None = None, - ) -> str | None: - """Resolve configured tokens or acquire one from GitHub CLI.""" + return {"Authorization": "Bearer " + token} + + def resolve_token(self, entry: AuthConfigEntry) -> str | None: + """Resolve a configured token or acquire one from GitHub CLI.""" if entry.auth != "github-cli": - return super().resolve_token(entry, hostname) + return super().resolve_token(entry) - request_host = (hostname or entry.hosts[0]).lower() - credential_host = self._credential_hostname(entry, request_host) + credential_host = self._credential_hostname(entry) cached = self._github_cli_cache.get(credential_host) if cached is not None: succeeded, value = cached @@ -96,38 +82,33 @@ def resolve_token( return token @staticmethod - def _credential_hostname(entry: AuthConfigEntry, request_host: str) -> str: - """Map a request host to the GitHub CLI login host.""" - if request_host in _GITHUB_PUBLIC_HOSTS: + def _credential_hostname(entry: AuthConfigEntry) -> str: + """Select the one GitHub CLI credential host represented by *entry*.""" + exact_hosts = { + host.lower() + for host in entry.hosts + if "*" not in host + } + if exact_hosts & _GITHUB_PUBLIC_HOSTS: + if any(host not in _GITHUB_PUBLIC_HOSTS for host in exact_hosts): + raise GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + "a github-cli entry must describe one GitHub instance. " + "Use separate provider entries for github.com and GitHub Enterprise." + ) return "github.com" - exact_hosts = { - configured_host.lower() - for configured_host in entry.hosts - if "*" not in configured_host + bare_hosts = { + host + for host in exact_hosts + if not host.startswith(_GITHUB_SERVICE_PREFIXES) } - for prefix in _GITHUB_ENTERPRISE_SERVICE_PREFIXES: - if request_host.startswith(prefix): - instance_host = request_host.removeprefix(prefix) - if instance_host in exact_hosts: - return instance_host - return request_host - - def can_reuse_token_for_host( - self, - entry: AuthConfigEntry, - source_hostname: str | None, - target_hostname: str, - ) -> bool: - """Allow redirects only within one GitHub CLI credential scope.""" - if entry.auth != "github-cli": - return True - if source_hostname is None: - return False - return self._credential_hostname( - entry, - source_hostname, - ) == self._credential_hostname(entry, target_hostname) + if len(bare_hosts) != 1: + raise GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + "a github-cli entry must list exactly one bare GitHub host." + ) + return next(iter(bare_hosts)) @staticmethod def _acquire_via_gh_cli(hostname: str) -> str: @@ -135,7 +116,7 @@ def _acquire_via_gh_cli(hostname: str) -> str: if not _is_safe_cli_hostname(hostname): raise GitHubCliAuthError( "GitHub authentication failed for provider 'github': " - "the request hostname is not valid for GitHub CLI authentication." + "the configured hostname is not valid for GitHub CLI authentication." ) resolved = shutil.which("gh") @@ -145,11 +126,9 @@ def _acquire_via_gh_cli(hostname: str) -> str: "GitHub CLI ('gh') was not found." ) - argv = [resolved, "auth", "token", "--hostname", hostname] - try: result = subprocess.run( # noqa: S603 - argv, + [resolved, "auth", "token", "--hostname", hostname], capture_output=True, text=True, timeout=_GITHUB_CLI_TIMEOUT_SECONDS, diff --git a/src/specify_cli/authentication/http.py b/src/specify_cli/authentication/http.py index 84f1700b60..7dabc752aa 100644 --- a/src/specify_cli/authentication/http.py +++ b/src/specify_cli/authentication/http.py @@ -63,21 +63,12 @@ def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool: RedirectValidator = Callable[[str, str], None] -AuthHostnameValidator = Callable[[str], bool] class RedirectPolicyError(urllib.error.URLError): """A redirect rejected because it violates the client's security policy.""" -def _hostname_for_url(url: str) -> str | None: - """Return a normalized request hostname, or None for malformed URLs.""" - try: - return (urlparse(url).hostname or "").lower() or None - except ValueError: - return None - - def _validate_strict_redirect(old_url: str, new_url: str) -> None: if not is_safe_download_redirect(old_url, new_url): raise RedirectPolicyError( @@ -100,12 +91,10 @@ def __init__( self, hosts: tuple[str, ...], redirect_validator: RedirectValidator | None = None, - auth_hostname_validator: AuthHostnameValidator | None = None, ) -> None: super().__init__() self._hosts = hosts self._redirect_validator = redirect_validator - self._auth_hostname_validator = auth_hostname_validator def redirect_request(self, req, fp, code, msg, headers, newurl): try: @@ -130,15 +119,7 @@ def redirect_request(self, req, fp, code, msg, headers, newurl): old_scheme = urlparse(req.full_url).scheme hostname = (new_parsed.hostname or "").lower() is_https_downgrade = old_scheme == "https" and new_parsed.scheme != "https" - is_auth_hostname_allowed = ( - self._auth_hostname_validator is None - or self._auth_hostname_validator(hostname) - ) - if ( - _hostname_in_hosts(hostname, self._hosts) - and not is_https_downgrade - and is_auth_hostname_allowed - ): + if _hostname_in_hosts(hostname, self._hosts) and not is_https_downgrade: if original_auth: new_req.add_unredirected_header("Authorization", original_auth) else: @@ -159,12 +140,11 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll 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()) - hostname = _hostname_for_url(url) for entry in entries: provider = get_provider(entry.provider) if provider is None: continue - token = provider.resolve_token(entry, hostname) + token = provider.resolve_token(entry) if token: headers.update(provider.auth_headers(token, entry.auth)) break @@ -210,7 +190,6 @@ def open_url( HTTP between loopback URLs, and rejects remote-to-local redirects. """ entries = find_entries_for_url(url, _load_config()) - hostname = _hostname_for_url(url) def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request: merged = {} @@ -226,25 +205,12 @@ def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request: provider = get_provider(entry.provider) if provider is None: continue - token = provider.resolve_token(entry, hostname) + token = provider.resolve_token(entry) if not token: continue req = _make_req(provider.auth_headers(token, entry.auth)) - def auth_hostname_validator(target_hostname: str) -> bool: - return provider.can_reuse_token_for_host( - entry, - hostname, - target_hostname, - ) - - opener = urllib.request.build_opener( - _StripAuthOnRedirect( - entry.hosts, - redirect_validator, - auth_hostname_validator, - ) - ) + opener = urllib.request.build_opener(_StripAuthOnRedirect(entry.hosts, redirect_validator)) try: return opener.open(req, timeout=timeout) except urllib.error.HTTPError as exc: diff --git a/src/specify_cli/bundles/adapters.py b/src/specify_cli/bundles/adapters.py index 5dd40460ea..46433cb005 100644 --- a/src/specify_cli/bundles/adapters.py +++ b/src/specify_cli/bundles/adapters.py @@ -22,7 +22,6 @@ from .._assets import _locate_core_pack, _repo_root from .._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited -from ..authentication.github import GitHubCliAuthError from . import BundlerError from .catalogs import CatalogSource from .manifest import ComponentRef @@ -277,8 +276,6 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: raise BundlerError( f"Failed to fetch catalog from {url}: HTTP {exc.code} {exc.reason}" ) from exc - except GitHubCliAuthError as exc: - raise BundlerError(f"Failed to fetch catalog from {url}: {exc}") from exc except urllib.error.URLError as exc: # urllib wraps a TLS handshake failure as URLError(reason=); # a certificate-verification failure is a security failure, not a diff --git a/src/specify_cli/bundles/component_catalog.py b/src/specify_cli/bundles/component_catalog.py index 931463de00..d4b7a107c7 100644 --- a/src/specify_cli/bundles/component_catalog.py +++ b/src/specify_cli/bundles/component_catalog.py @@ -6,7 +6,6 @@ from http.client import HTTPException from urllib.error import HTTPError, URLError -from ..authentication.github import GitHubCliAuthError from ..authentication.http import RedirectPolicyError from . import BundlerError from .manifest import ComponentRef @@ -20,8 +19,6 @@ def _is_unavailable(exc: BaseException) -> bool: seen: set[int] = set() while exc is not None and id(exc) not in seen: seen.add(id(exc)) - if isinstance(exc, GitHubCliAuthError): - return False if isinstance(exc, RedirectPolicyError): return False if isinstance(exc, HTTPError): diff --git a/tests/specify_cli/authentication/test_authentication.py b/tests/specify_cli/authentication/test_authentication.py index d0cb4f60f2..c46d4ac5c9 100644 --- a/tests/specify_cli/authentication/test_authentication.py +++ b/tests/specify_cli/authentication/test_authentication.py @@ -18,7 +18,6 @@ import io import json import os -import urllib.error import pytest @@ -51,7 +50,12 @@ def _github_entry(token_env: str = "GH_TOKEN", token: str | None = None) -> Auth def _github_cli_entry( - hosts: tuple[str, ...] = ("github.com",), + hosts: tuple[str, ...] = ( + "github.com", + "api.github.com", + "raw.githubusercontent.com", + "codeload.github.com", + ), ) -> AuthConfigEntry: """Build a GitHub CLI config entry.""" return AuthConfigEntry( @@ -106,6 +110,23 @@ def test_valid_github_config(self, tmp_path): assert entries[0].auth == "bearer" assert entries[0].token_env == "GH_TOKEN" + def test_valid_github_cli_config(self, tmp_path): + cfg = tmp_path / "auth.json" + cfg.write_text(json.dumps({ + "providers": [{ + "hosts": ["github.com", "api.github.com"], + "provider": "github", + "auth": "github-cli", + }] + })) + + entries = load_auth_config(cfg) + + assert len(entries) == 1 + assert entries[0].auth == "github-cli" + assert entries[0].token is None + assert entries[0].token_env is None + def test_padded_token_env_is_normalized_and_resolves(self, tmp_path, monkeypatch): # token_env is validated on its stripped form but was stored raw, so a # padded env-var name passed validation yet broke the verbatim @@ -305,18 +326,6 @@ def test_azure_cli_config(self, tmp_path): entries = load_auth_config(cfg) assert entries[0].auth == "azure-cli" - def test_github_cli_config(self, tmp_path): - cfg = tmp_path / "auth.json" - cfg.write_text(json.dumps({ - "providers": [{ - "hosts": ["github.com"], - "provider": "github", - "auth": "github-cli", - }] - })) - entries = load_auth_config(cfg) - assert entries == [_github_cli_entry()] - def test_multiple_entries(self, tmp_path): cfg = tmp_path / "auth.json" cfg.write_text(json.dumps({ @@ -613,48 +622,14 @@ class TestGitHubAuth: def test_bearer_headers(self): assert GitHubAuth().auth_headers("my-token", "bearer") == {"Authorization": "Bearer my-token"} - def test_unsupported_scheme_raises(self): - with pytest.raises(ValueError, match="basic-pat"): - GitHubAuth().auth_headers("tok", "basic-pat") - - def test_resolve_token_from_env(self, monkeypatch): - monkeypatch.setenv("GH_TOKEN", "env-token") - assert GitHubAuth().resolve_token(_github_entry()) == "env-token" - - def test_resolve_token_inline(self): - assert GitHubAuth().resolve_token(_github_entry(token="inline-tok")) == "inline-tok" - - def test_resolve_token_strips_whitespace(self, monkeypatch): - monkeypatch.setenv("GH_TOKEN", " my-token ") - assert GitHubAuth().resolve_token(_github_entry()) == "my-token" - - def test_resolve_token_empty_env_returns_none(self, monkeypatch): - monkeypatch.setenv("GH_TOKEN", " ") - assert GitHubAuth().resolve_token(_github_entry()) is None - - def test_resolve_token_missing_env_returns_none(self, monkeypatch): - monkeypatch.delenv("GH_TOKEN", raising=False) - assert GitHubAuth().resolve_token(_github_entry()) is None - - def test_key(self): - assert GitHubAuth.key == "github" - - def test_supported_schemes(self): - assert GitHubAuth.supported_auth_schemes == ("bearer", "github-cli") - - def test_github_cli_headers_use_bearer(self): - assert GitHubAuth().auth_headers("cli-token", "github-cli") == { - "Authorization": "Bearer cli-token" + def test_github_cli_headers(self): + assert GitHubAuth().auth_headers("my-token", "github-cli") == { + "Authorization": "Bearer my-token" } - def test_resolve_token_github_cli_success_without_shell( - self, - monkeypatch, - ): + def test_github_cli_resolves_token_with_fixed_command(self): from unittest.mock import MagicMock, patch - monkeypatch.setenv("GH_HOST", "enterprise.example.com") - provider = GitHubAuth() result = MagicMock(returncode=0, stdout=" cli-token\n") executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") @@ -665,95 +640,28 @@ def test_resolve_token_github_cli_success_without_shell( "specify_cli.authentication.github.subprocess.run", return_value=result, ) as run: - assert provider.resolve_token( - _github_cli_entry(), - "github.com", - ) == "cli-token" - - assert run.call_args.args[0] == [ - executable, - "auth", - "token", - "--hostname", - "github.com", - ] - assert run.call_args.kwargs == { - "capture_output": True, - "text": True, - "timeout": 10, - "check": False, - "shell": False, - } - - def test_resolve_token_github_cli_uses_enterprise_hostname(self): - from unittest.mock import MagicMock, patch - - provider = GitHubAuth() - result = MagicMock(returncode=0, stdout="enterprise-token") - executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + token = GitHubAuth().resolve_token(_github_cli_entry()) - with patch( - "specify_cli.authentication.github.shutil.which", - return_value=executable, - ), patch( - "specify_cli.authentication.github.subprocess.run", - return_value=result, - ) as run: - assert provider.resolve_token( - _github_cli_entry(("ghes.example.com",)), - "ghes.example.com", - ) == "enterprise-token" - - assert run.call_args.args[0] == [ - executable, - "auth", - "token", - "--hostname", - "ghes.example.com", - ] + assert token == "cli-token" + run.assert_called_once_with( + [executable, "auth", "token", "--hostname", "github.com"], + capture_output=True, + text=True, + timeout=10, + check=False, + shell=False, + ) - @pytest.mark.parametrize( - "request_host", - [ - "api.ghes.example.com", - "raw.ghes.example.com", - "codeload.ghes.example.com", - ], - ) - def test_resolve_token_github_cli_maps_enterprise_service_host( - self, - request_host, - ): + def test_github_cli_uses_bare_enterprise_host(self): from unittest.mock import MagicMock, patch - provider = GitHubAuth() - result = MagicMock(returncode=0, stdout="enterprise-token") - executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") entry = _github_cli_entry(( "ghes.example.com", - "api.ghes.example.com", "raw.ghes.example.com", "codeload.ghes.example.com", )) - - with patch( - "specify_cli.authentication.github.shutil.which", - return_value=executable, - ), patch( - "specify_cli.authentication.github.subprocess.run", - return_value=result, - ) as run: - assert provider.resolve_token(entry, request_host) == "enterprise-token" - - assert run.call_args.args[0][-2:] == ["--hostname", "ghes.example.com"] - - def test_resolve_token_github_cli_does_not_infer_unconfigured_instance_host(self): - from unittest.mock import MagicMock, patch - - provider = GitHubAuth() result = MagicMock(returncode=0, stdout="enterprise-token") executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") - entry = _github_cli_entry(("raw.ghes.example.com",)) with patch( "specify_cli.authentication.github.shutil.which", @@ -762,76 +670,22 @@ def test_resolve_token_github_cli_does_not_infer_unconfigured_instance_host(self "specify_cli.authentication.github.subprocess.run", return_value=result, ) as run: - assert provider.resolve_token( - entry, - "raw.ghes.example.com", - ) == "enterprise-token" + assert GitHubAuth().resolve_token(entry) == "enterprise-token" - assert run.call_args.args[0][-2:] == [ - "--hostname", - "raw.ghes.example.com", - ] - - @pytest.mark.parametrize( - ("source", "target"), - [ - ("github.com", "api.github.com"), - ("github.com", "codeload.github.com"), - ("ghes.example.com", "api.ghes.example.com"), - ("ghes.example.com", "raw.ghes.example.com"), - ("ghes.example.com", "codeload.ghes.example.com"), - ], - ) - def test_github_cli_reuses_token_within_credential_host( - self, - source, - target, - ): - provider = GitHubAuth() - entry = _github_cli_entry(( - "github.com", - "api.github.com", - "codeload.github.com", - "ghes.example.com", - "api.ghes.example.com", - "raw.ghes.example.com", - "codeload.ghes.example.com", - )) + assert run.call_args.args[0][-2:] == ["--hostname", "ghes.example.com"] - assert provider.can_reuse_token_for_host(entry, source, target) + def test_github_cli_requires_one_instance_per_entry(self): + entry = _github_cli_entry(("github.com", "ghes.example.com")) - def test_github_cli_does_not_reuse_token_across_instances(self): - provider = GitHubAuth() - entry = _github_cli_entry(( - "github.com", - "api.github.com", - "ghes.example.com", - "raw.ghes.example.com", - )) + with pytest.raises(GitHubCliAuthError, match="separate provider entries"): + GitHubAuth().resolve_token(entry) - assert not provider.can_reuse_token_for_host( - entry, - "github.com", - "ghes.example.com", - ) - assert not provider.can_reuse_token_for_host( - entry, - "raw.ghes.example.com", - "api.github.com", - ) - - def test_resolve_token_github_cli_caches_per_credential_host(self): + def test_github_cli_caches_token_per_process(self): from unittest.mock import MagicMock, patch provider = GitHubAuth() result = MagicMock(returncode=0, stdout="cached-token") executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") - entry = _github_cli_entry(( - "github.com", - "api.github.com", - "ghes.example.com", - "raw.ghes.example.com", - )) with patch( "specify_cli.authentication.github.shutil.which", @@ -840,32 +694,13 @@ def test_resolve_token_github_cli_caches_per_credential_host(self): "specify_cli.authentication.github.subprocess.run", return_value=result, ) as run: - assert provider.resolve_token(entry, "github.com") == "cached-token" - assert provider.resolve_token(entry, "api.github.com") == "cached-token" - assert provider.resolve_token(entry, "ghes.example.com") == "cached-token" - assert provider.resolve_token(entry, "raw.ghes.example.com") == "cached-token" + assert provider.resolve_token(_github_cli_entry()) == "cached-token" + assert provider.resolve_token(_github_cli_entry()) == "cached-token" - assert run.call_count == 2 + run.assert_called_once() - def test_resolve_token_github_cli_missing_raises_clear_error(self): - from unittest.mock import patch - - with patch( - "specify_cli.authentication.github.shutil.which", - return_value=None, - ), pytest.raises(GitHubCliAuthError) as exc_info: - GitHubAuth().resolve_token(_github_cli_entry(), "github.com") - - message = str(exc_info.value) - assert isinstance(exc_info.value, urllib.error.URLError) - assert not message.startswith(" Date: Thu, 22 Oct 2026 17:18:12 -0500 Subject: [PATCH 08/17] fix(auth): select first configured GitHub CLI hostname Use hosts[0] as the credential hostname instead of inferring an instance from service prefixes. Document ordering and cover first-host selection, wildcard rejection, and cache isolation. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/authentication.md | 21 ++++++-- src/specify_cli/authentication/github.py | 36 +++---------- .../authentication/test_authentication.py | 52 +++++++++++++++++-- 3 files changed, 71 insertions(+), 38 deletions(-) diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index 1b20f34305..6aeac45bb8 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -98,6 +98,16 @@ using this mode. Specify runs the fixed command `gh auth token --hostname github.com` without a shell, caches the result in memory for the current process, and never writes the token to disk. +For `github-cli` only, the **first hostname in `hosts`** is passed to +`gh auth token --hostname`. Put `github.com` or your enterprise instance's +GitHub CLI login hostname first. It must be an exact hostname, not a wildcard. +All listed hosts remain request-matching destinations authorized to receive +that credential; later entries may use supported wildcards. Specify does not +infer the credential hostname from service prefixes or the requested URL. +The in-memory cache is keyed by the first hostname. Use separate provider +entries for hosts requiring different credentials. Host order has no effect +on existing authentication modes. + ### GitHub Enterprise Server (GHES) To use a private catalog or extension hosted on a GitHub Enterprise Server @@ -125,9 +135,9 @@ and `codeload.` subdomains your catalog/extension URLs use. A `*.ghes.example.com` wildcard matches subdomains but **not** the bare host, so always include the bare host explicitly. -With `auth: "github-cli"`, one provider entry represents one GitHub instance. -List exactly one bare enterprise host plus any service aliases that share its -credential: +With `auth: "github-cli"`, list your enterprise instance's GitHub CLI login +hostname first, followed by any request hosts or wildcard patterns that should +receive the same credential: ```json { @@ -141,8 +151,9 @@ credential: } ``` -Specify runs `gh auth token --hostname ghes.example.com`. Use separate provider -entries for separate GitHub instances. The selected host must already be +Specify runs `gh auth token --hostname ghes.example.com`, using the first +hostname exactly as configured. Use separate provider entries for separate +GitHub instances. The selected host must already be authenticated in GitHub CLI; failures suggest `gh auth login --hostname ` without including GitHub CLI command output. diff --git a/src/specify_cli/authentication/github.py b/src/specify_cli/authentication/github.py index 6f13f349c2..755af798e3 100644 --- a/src/specify_cli/authentication/github.py +++ b/src/specify_cli/authentication/github.py @@ -14,13 +14,6 @@ _GITHUB_CLI_TIMEOUT_SECONDS = 10 -_GITHUB_PUBLIC_HOSTS = frozenset({ - "github.com", - "api.github.com", - "raw.githubusercontent.com", - "codeload.github.com", -}) -_GITHUB_SERVICE_PREFIXES = ("api.", "raw.", "codeload.") def _is_safe_cli_hostname(hostname: str) -> bool: @@ -83,32 +76,15 @@ def resolve_token(self, entry: AuthConfigEntry) -> str | None: @staticmethod def _credential_hostname(entry: AuthConfigEntry) -> str: - """Select the one GitHub CLI credential host represented by *entry*.""" - exact_hosts = { - host.lower() - for host in entry.hosts - if "*" not in host - } - if exact_hosts & _GITHUB_PUBLIC_HOSTS: - if any(host not in _GITHUB_PUBLIC_HOSTS for host in exact_hosts): - raise GitHubCliAuthError( - "GitHub authentication failed for provider 'github': " - "a github-cli entry must describe one GitHub instance. " - "Use separate provider entries for github.com and GitHub Enterprise." - ) - return "github.com" - - bare_hosts = { - host - for host in exact_hosts - if not host.startswith(_GITHUB_SERVICE_PREFIXES) - } - if len(bare_hosts) != 1: + """Use the first configured host for GitHub CLI credential acquisition.""" + hostname = entry.hosts[0] + if "*" in hostname: raise GitHubCliAuthError( "GitHub authentication failed for provider 'github': " - "a github-cli entry must list exactly one bare GitHub host." + "the first hostname in a github-cli entry must be an exact " + "hostname, not a wildcard." ) - return next(iter(bare_hosts)) + return hostname @staticmethod def _acquire_via_gh_cli(hostname: str) -> str: diff --git a/tests/specify_cli/authentication/test_authentication.py b/tests/specify_cli/authentication/test_authentication.py index c46d4ac5c9..8001e0e3c1 100644 --- a/tests/specify_cli/authentication/test_authentication.py +++ b/tests/specify_cli/authentication/test_authentication.py @@ -674,12 +674,58 @@ def test_github_cli_uses_bare_enterprise_host(self): assert run.call_args.args[0][-2:] == ["--hostname", "ghes.example.com"] - def test_github_cli_requires_one_instance_per_entry(self): - entry = _github_cli_entry(("github.com", "ghes.example.com")) + @pytest.mark.parametrize("hosts", [ + ("api.company.example",), + ("raw.company.example", "api.raw.company.example"), + ("api.github.com", "github.com"), + ("ghes.example.com", "*.ghes.example.com"), + ("first.example.com", "second.example.com"), + ]) + def test_github_cli_uses_first_hostname(self, hosts): + from unittest.mock import patch + + with patch.object( + GitHubAuth, "_acquire_via_gh_cli", return_value="cli-token", + ) as acquire: + assert GitHubAuth().resolve_token(_github_cli_entry(hosts)) == "cli-token" + + acquire.assert_called_once_with(hosts[0]) + + def test_github_cli_rejects_wildcard_first_hostname(self): + from unittest.mock import patch - with pytest.raises(GitHubCliAuthError, match="separate provider entries"): + entry = _github_cli_entry(("*.ghes.example.com", "ghes.example.com")) + with patch( + "specify_cli.authentication.github.shutil.which", + ) as which, patch( + "specify_cli.authentication.github.subprocess.run", + ) as run, pytest.raises(GitHubCliAuthError, match="first hostname"): GitHubAuth().resolve_token(entry) + which.assert_not_called() + run.assert_not_called() + + def test_github_cli_caches_by_first_hostname(self): + from unittest.mock import patch + + provider = GitHubAuth() + with patch.object( + GitHubAuth, "_acquire_via_gh_cli", side_effect=["first-token", "second-token"], + ) as acquire: + assert provider.resolve_token(_github_cli_entry(( + "first.example.com", "api.first.example.com", + ))) == "first-token" + assert provider.resolve_token(_github_cli_entry(( + "first.example.com", "*.first.example.com", + ))) == "first-token" + assert provider.resolve_token(_github_cli_entry(( + "second.example.com", "first.example.com", + ))) == "second-token" + + assert [call.args for call in acquire.call_args_list] == [ + ("first.example.com",), ("second.example.com",), + ] + def test_github_cli_caches_token_per_process(self): from unittest.mock import MagicMock, patch From 27126e62b0ca08fe65abf64747a0a48814f53464 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:23:48 -0500 Subject: [PATCH 09/17] fix(ci): update vulnerable OpenTelemetry audit pin Upgrade only opentelemetry-api from 1.45.0 to 1.45.1 and regenerate its hashes to resolve CVE-2026-114603 in the committed audit snapshot. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .github/security-audit-requirements.txt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/security-audit-requirements.txt b/.github/security-audit-requirements.txt index d542924487..2f4a8c1fe8 100644 --- a/.github/security-audit-requirements.txt +++ b/.github/security-audit-requirements.txt @@ -374,9 +374,9 @@ mdurl==0.1.2 \ --hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \ --hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba # via markdown-it-py -opentelemetry-api==1.45.0 \ - --hash=sha256:711ede81773c8025c2c03dac0450bc89f3d30aea6eabcc815c570d4e35a963f7 \ - --hash=sha256:80e068aba7cd56c8b58512d6a36f8d25cb1dfaa0c0a4cc1c938ccf9f362d9cb3 +opentelemetry-api==1.45.1 \ + --hash=sha256:aa38ed19bcc084ba42782a73255b3582283eced7ad6dddbd6695189e69adfb75 \ + --hash=sha256:b31553efa588ae44bc306f863c785c5333a9ecc091248c6ee68b4b6c87fdedfb # via mcp packaging==26.3 \ --hash=sha256:94edc256424af38762eb31306eed28beb9f0efc50a8837492c9d6fd6004aed79 \ From 71d47547113c0c17555ce822e8a472c8de1bdcc8 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Thu, 22 Oct 2026 17:46:14 -0500 Subject: [PATCH 10/17] test(auth): cover registered GitHub CLI request paths Exercise build_request and open_url with the registered GitHub provider and an isolated credential cache. Verify bearer headers and fail-fast acquisition errors, and cover sanitized cached PermissionError and UnicodeDecodeError failures. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../authentication/test_authentication.py | 95 ++++++++++++++++++- 1 file changed, 94 insertions(+), 1 deletion(-) diff --git a/tests/specify_cli/authentication/test_authentication.py b/tests/specify_cli/authentication/test_authentication.py index 8001e0e3c1..4b9f269e68 100644 --- a/tests/specify_cli/authentication/test_authentication.py +++ b/tests/specify_cli/authentication/test_authentication.py @@ -809,12 +809,18 @@ def test_github_cli_timeout_raises(self): ), pytest.raises(GitHubCliAuthError, match="timed out"): GitHubAuth().resolve_token(_github_cli_entry()) - def test_github_cli_caches_sanitized_failure(self): + @pytest.mark.parametrize("error", [ + None, + PermissionError("secret"), + UnicodeDecodeError("utf-8", b"secret\xff", 6, 7, "secret"), + ], ids=["nonzero-exit", "permission-error", "decode-error"]) + def test_github_cli_caches_sanitized_failure(self, error): from unittest.mock import MagicMock, patch provider = GitHubAuth() result = MagicMock(returncode=1, stdout="secret", stderr="secret") executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + messages = [] with patch( "specify_cli.authentication.github.shutil.which", @@ -822,12 +828,17 @@ def test_github_cli_caches_sanitized_failure(self): ), patch( "specify_cli.authentication.github.subprocess.run", return_value=result, + side_effect=error, ) as run: for _ in range(2): with pytest.raises(GitHubCliAuthError) as exc_info: provider.resolve_token(_github_cli_entry()) assert "secret" not in str(exc_info.value) + messages.append(str(exc_info.value)) + assert messages[0] == messages[1] + assert "provider 'github'" in messages[0] + assert "could not provide a token" in messages[0] run.assert_called_once() def test_github_cli_rejects_shell_unsafe_host_without_echoing_it(self): @@ -1492,6 +1503,88 @@ def test_timeout_propagates(self, monkeypatch): open_url("https://example.com/file") +# --------------------------------------------------------------------------- +# GitHub CLI — registered provider HTTP wiring +# --------------------------------------------------------------------------- + + +class TestGitHubCliAuthenticatedHttp: + @pytest.fixture(autouse=True) + def isolate_config_and_cache(self, monkeypatch): + from specify_cli.authentication import http + + provider = get_provider("github") + assert isinstance(provider, GitHubAuth) + monkeypatch.setattr(provider, "_github_cli_cache", {}) + monkeypatch.setattr(http, "_config_override", [_github_cli_entry()]) + + @pytest.mark.parametrize("helper", ["build_request", "open_url"]) + def test_resolved_token_becomes_bearer_header(self, helper): + from unittest.mock import MagicMock, patch + from specify_cli.authentication import http + + result = MagicMock(returncode=0, stdout=" cli-token\n") + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ) as run, patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener: + if helper == "build_request": + request = http.build_request("https://api.github.com/repos/org/repo") + build_opener.assert_not_called() + else: + response = http.open_url("https://api.github.com/repos/org/repo") + opener = build_opener.return_value + assert response is opener.open.return_value + opener.open.assert_called_once() + request = opener.open.call_args.args[0] + + assert request.get_header("Authorization") == "Bearer cli-token" + run.assert_called_once_with( + [executable, "auth", "token", "--hostname", "github.com"], + capture_output=True, + text=True, + timeout=10, + check=False, + shell=False, + ) + + @pytest.mark.parametrize("helper", ["build_request", "open_url"]) + def test_acquisition_failure_stops_credential_and_anonymous_fallback( + self, monkeypatch, helper, + ): + from unittest.mock import patch + from specify_cli.authentication import http + + entry = _github_cli_entry() + monkeypatch.setattr(http, "_config_override", [ + entry, _github_entry(token="backup-token"), + ]) + provider = get_provider("github") + assert isinstance(provider, GitHubAuth) + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=None, + ) as which, patch( + "specify_cli.authentication.github.subprocess.run", + ) as run, patch.object( + provider, "resolve_token", wraps=provider.resolve_token, + ) as resolve, patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener, pytest.raises(GitHubCliAuthError, match="was not found"): + getattr(http, helper)("https://api.github.com/repos/org/repo") + + resolve.assert_called_once_with(entry) + which.assert_called_once_with("gh") + run.assert_not_called() + build_opener.assert_not_called() + + # --------------------------------------------------------------------------- # _load_config caching # --------------------------------------------------------------------------- From 8c9a5b71135ef097c9c20fcf24f0b07a3388a565 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:25:01 -0500 Subject: [PATCH 11/17] fix(auth): reject multiline GitHub CLI credentials Reject embedded CR and LF after trimming CLI output, before returning or caching a token. Cache only the sanitized failure diagnostic and cover CR, LF, and CRLF regressions. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/authentication.md | 2 ++ src/specify_cli/authentication/github.py | 5 ++++ .../authentication/test_authentication.py | 28 +++++++++++++++++++ 3 files changed, 35 insertions(+) diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index 6aeac45bb8..e260738eda 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -97,6 +97,8 @@ Install [GitHub CLI](https://cli.github.com/) and run `gh auth login` before using this mode. Specify runs the fixed command `gh auth token --hostname github.com` without a shell, caches the result in memory for the current process, and never writes the token to disk. +Surrounding whitespace is trimmed; tokens containing embedded carriage returns +or newlines are rejected without including the token in the error. For `github-cli` only, the **first hostname in `hosts`** is passed to `gh auth token --hostname`. Put `github.com` or your enterprise instance's diff --git a/src/specify_cli/authentication/github.py b/src/specify_cli/authentication/github.py index 755af798e3..cc9685eec3 100644 --- a/src/specify_cli/authentication/github.py +++ b/src/specify_cli/authentication/github.py @@ -136,4 +136,9 @@ def _acquire_via_gh_cli(hostname: str) -> str: f"GitHub CLI returned an empty token for {hostname!r}. " f"Run 'gh auth login --hostname {hostname}'." ) + if "\r" in token or "\n" in token: + raise GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + f"GitHub CLI returned a malformed token for {hostname!r}." + ) return token diff --git a/tests/specify_cli/authentication/test_authentication.py b/tests/specify_cli/authentication/test_authentication.py index 4b9f269e68..7aaaf8737a 100644 --- a/tests/specify_cli/authentication/test_authentication.py +++ b/tests/specify_cli/authentication/test_authentication.py @@ -794,6 +794,34 @@ def test_github_cli_empty_output_raises(self): ), pytest.raises(GitHubCliAuthError, match="empty token"): GitHubAuth().resolve_token(_github_cli_entry()) + @pytest.mark.parametrize("separator", ["\r", "\n", "\r\n"]) + def test_github_cli_caches_sanitized_malformed_token_failure(self, separator): + from unittest.mock import MagicMock, patch + + secret = "synthetic_credential" + result = MagicMock(returncode=0, stdout=f" {secret}{separator}extra\n") + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + provider = GitHubAuth() + messages = [] + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=result, + ) as run: + for _ in range(2): + with pytest.raises(GitHubCliAuthError, match="malformed token") as exc_info: + provider.resolve_token(_github_cli_entry()) + messages.append(str(exc_info.value)) + assert secret not in messages[-1] + assert "extra" not in messages[-1] + + assert messages[0] == messages[1] + assert provider._github_cli_cache == {"github.com": (False, messages[0])} + run.assert_called_once() + def test_github_cli_timeout_raises(self): import subprocess from unittest.mock import patch From 135a1a6d47f2a0e6f14b8e9d607fdbcb37d0a8bd Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 23 Oct 2026 08:53:30 -0500 Subject: [PATCH 12/17] fix(auth): preserve caller handling of GitHub CLI failures Classify GitHub CLI acquisition failures as URL errors so existing catalog handlers can warn and continue, while each affected HTTP request still fails without credential or anonymous fallback. Cache the sanitized reason without nesting URL-error wrappers. Add catalog-search regression, release-check, and redirect-wiring coverage; retain the approved first-host credential contract. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/authentication.md | 4 + src/specify_cli/authentication/github.py | 5 +- .../authentication/test_authentication.py | 89 ++++++++++++++++++- 3 files changed, 95 insertions(+), 3 deletions(-) diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index e260738eda..fea6b79628 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -99,6 +99,10 @@ using this mode. Specify runs the fixed command memory for the current process, and never writes the token to disk. Surrounding whitespace is trimmed; tokens containing embedded carriage returns or newlines are rejected without including the token in the error. +Credential acquisition failures fail the affected request without trying another +credential or an unauthenticated request. They use the existing URL-error +category, so callers retain their normal error handling (for example, warning +and skipping an unavailable catalog). For `github-cli` only, the **first hostname in `hosts`** is passed to `gh auth token --hostname`. Put `github.com` or your enterprise instance's diff --git a/src/specify_cli/authentication/github.py b/src/specify_cli/authentication/github.py index cc9685eec3..3a95a4149e 100644 --- a/src/specify_cli/authentication/github.py +++ b/src/specify_cli/authentication/github.py @@ -6,6 +6,7 @@ import shutil import subprocess from typing import TYPE_CHECKING +from urllib.error import URLError from .base import AuthProvider @@ -30,7 +31,7 @@ def _is_safe_cli_hostname(hostname: str) -> bool: ) -class GitHubCliAuthError(RuntimeError): +class GitHubCliAuthError(URLError): """Raised when GitHub CLI cannot provide a configured credential.""" @@ -67,7 +68,7 @@ def resolve_token(self, entry: AuthConfigEntry) -> str | None: try: token = self._acquire_via_gh_cli(credential_host) except GitHubCliAuthError as exc: - message = str(exc) + message = str(exc.reason) self._github_cli_cache[credential_host] = (False, message) raise GitHubCliAuthError(message) from None diff --git a/tests/specify_cli/authentication/test_authentication.py b/tests/specify_cli/authentication/test_authentication.py index 7aaaf8737a..95b663288e 100644 --- a/tests/specify_cli/authentication/test_authentication.py +++ b/tests/specify_cli/authentication/test_authentication.py @@ -819,7 +819,9 @@ def test_github_cli_caches_sanitized_malformed_token_failure(self, separator): assert "extra" not in messages[-1] assert messages[0] == messages[1] - assert provider._github_cli_cache == {"github.com": (False, messages[0])} + assert provider._github_cli_cache == { + "github.com": (False, exc_info.value.reason), + } run.assert_called_once() def test_github_cli_timeout_raises(self): @@ -1612,6 +1614,91 @@ def test_acquisition_failure_stops_credential_and_anonymous_fallback( run.assert_not_called() build_opener.assert_not_called() + @pytest.mark.parametrize("target,expected_auth", [ + ("https://codeload.github.com/org/repo/zip", "Bearer cli-token"), + ("https://unlisted.example.com/archive.zip", None), + ]) + def test_open_url_wires_declared_redirect_hosts(self, target, expected_auth): + from unittest.mock import MagicMock, patch + from specify_cli.authentication import http + + executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=executable, + ), patch( + "specify_cli.authentication.github.subprocess.run", + return_value=MagicMock(returncode=0, stdout="cli-token"), + ), patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener: + http.open_url("https://github.com/org/repo/archive.zip") + + handler = build_opener.call_args.args[0] + request = build_opener.return_value.open.call_args.args[0] + redirected = handler.redirect_request( + request, io.BytesIO(b""), 302, "Found", {}, target, + ) + assert redirected is not None + assert redirected.get_header("Authorization") == expected_auth + + def test_failed_cli_catalog_does_not_abort_other_catalogs( + self, tmp_path, monkeypatch, capsys, + ): + from unittest.mock import MagicMock, patch + from specify_cli.integrations import IntegrationCatalog, IntegrationCatalogEntry + + catalog = IntegrationCatalog(tmp_path) + entries = [ + IntegrationCatalogEntry( + url="https://github.com/org/private/catalog.json", + name="private", priority=1, install_allowed=True, + ), + IntegrationCatalogEntry( + url="https://catalog.example.com/catalog.json", + name="public", priority=2, install_allowed=True, + ), + ] + monkeypatch.setattr(catalog, "get_active_catalogs", lambda: entries) + response = MagicMock() + response.__enter__.return_value = response + response.geturl.return_value = entries[1].url + response.read.side_effect = io.BytesIO(json.dumps({ + "schema_version": "1.0", + "integrations": {"example": {"name": "Example"}}, + }).encode()).read + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=None, + ), patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener: + build_opener.return_value.open.return_value = response + results = catalog.search() + + assert [item["id"] for item in results] == ["example"] + warning = capsys.readouterr().err + assert "Could not fetch catalog 'private'" in warning + assert "GitHub CLI ('gh') was not found" in warning + build_opener.return_value.open.assert_called_once() + + def test_release_check_handles_cli_failure_as_url_error(self): + from unittest.mock import patch + from specify_cli._version import ( + _fetch_latest_release_tag, + _RESOLUTION_FAILURE_OFFLINE, + ) + + with patch( + "specify_cli.authentication.github.shutil.which", + return_value=None, + ), patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener: + assert _fetch_latest_release_tag() == (None, _RESOLUTION_FAILURE_OFFLINE) + + build_opener.assert_not_called() + # --------------------------------------------------------------------------- # _load_config caching From ec829bb553dd5fa840c20498f0bbafdfba83abf7 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 23 Oct 2026 09:35:04 -0500 Subject: [PATCH 13/17] docs(auth): list GitHub CLI in auth scheme table Add the GitHub CLI credential mode alongside bearer and remove the redundant prose below the table. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/authentication.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index fea6b79628..419eb20fb3 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -59,9 +59,7 @@ Either `token` or `token_env` must be set for the `bearer`, `basic-pat`, and `ba | Scheme | Header | Use for | |---|---|---| | `bearer` | `Authorization: Bearer ` | PATs, fine-grained PATs, OAuth tokens, GitHub App tokens | - -The `github` provider supports `bearer` tokens and `github-cli` credentials -acquired on demand from GitHub CLI. +| `github-cli` | `Authorization: Bearer ` | Credentials acquired on demand via `gh auth token` | **Example — PAT via environment variable:** From 1dfc5244742ea0ec081e8e862cf3293a6ce826c7 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 23 Oct 2026 10:17:47 -0500 Subject: [PATCH 14/17] docs(auth): clarify acquisition failure versus HTTP fallback Qualify the authentication process summary and HTTP helper docstrings: GitHub CLI acquisition errors stop the request immediately, whereas HTTP 401/403 after successful acquisition still permits matching-entry and anonymous fallback. No runtime behavior changes. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/authentication.md | 11 +++++++---- src/specify_cli/authentication/http.py | 11 ++++++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index 419eb20fb3..fe2e5fd194 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -327,10 +327,13 @@ You can configure multiple entries for different hosts or organizations: 1. For each outbound HTTP request, the URL hostname is matched against the `hosts` patterns in `auth.json`. 2. If a match is found, the corresponding provider resolves the token - and attaches the appropriate `Authorization` header. -3. If the request receives a 401 or 403, the next matching entry is tried. -4. After all matching entries are exhausted, an unauthenticated request - is attempted as a final fallback. + and attaches the appropriate `Authorization` header. A `github-cli` + acquisition failure ends the request immediately, without trying later + matching entries or making an unauthenticated request. +3. After successful token acquisition, if the request receives a 401 or 403, + the next matching entry is tried. +4. After all matching entries are exhausted without an acquisition failure, + an unauthenticated request is attempted as a final fallback. 5. On redirects, the `Authorization` header is stripped if the redirect target leaves the entry's declared hosts — preventing credential leakage to CDNs or third-party services. diff --git a/src/specify_cli/authentication/http.py b/src/specify_cli/authentication/http.py index 7dabc752aa..795b491e1e 100644 --- a/src/specify_cli/authentication/http.py +++ b/src/specify_cli/authentication/http.py @@ -133,6 +133,8 @@ def build_request(url: str, extra_headers: dict[str, str] | None = None) -> urll 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. + A ``github-cli`` acquisition failure raises immediately, without trying + later matching entries or returning an unauthenticated request. """ headers: dict[str, str] = {} if extra_headers: @@ -174,9 +176,12 @@ def open_url( """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. + 2. For each entry, resolve the token and try the request. A ``github-cli`` + acquisition failure raises immediately, with no credential or + unauthenticated fallback. + 3. On HTTP 401/403 after successful acquisition, try the next matching entry. + 4. After all entries exhausted without an acquisition failure (or none + matched), try unauthenticated. 5. Non-auth errors (404, 500, network) raise immediately. *extra_headers* (e.g. ``Accept``) are merged into every attempt. From 12fe353959be1c5f0cf60cd90da9b3f2eef891c0 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 23 Oct 2026 11:35:05 -0500 Subject: [PATCH 15/17] fix(auth): reject non-ASCII GitHub CLI credentials Reject decoded non-ASCII CLI output through the sanitized malformed-token path before returning or caching credentials. Extend failure-cache regression coverage to Latin-1 and non-Latin-1 Unicode and document the restriction. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/authentication.md | 5 +++-- src/specify_cli/authentication/github.py | 2 +- tests/specify_cli/authentication/test_authentication.py | 6 +++--- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index fe2e5fd194..2d01c610dd 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -95,8 +95,9 @@ Install [GitHub CLI](https://cli.github.com/) and run `gh auth login` before using this mode. Specify runs the fixed command `gh auth token --hostname github.com` without a shell, caches the result in memory for the current process, and never writes the token to disk. -Surrounding whitespace is trimmed; tokens containing embedded carriage returns -or newlines are rejected without including the token in the error. +Surrounding whitespace is trimmed; tokens containing non-ASCII characters, +embedded carriage returns, or newlines are rejected without including the +token in the error. Credential acquisition failures fail the affected request without trying another credential or an unauthenticated request. They use the existing URL-error category, so callers retain their normal error handling (for example, warning diff --git a/src/specify_cli/authentication/github.py b/src/specify_cli/authentication/github.py index 3a95a4149e..655063e33c 100644 --- a/src/specify_cli/authentication/github.py +++ b/src/specify_cli/authentication/github.py @@ -137,7 +137,7 @@ def _acquire_via_gh_cli(hostname: str) -> str: f"GitHub CLI returned an empty token for {hostname!r}. " f"Run 'gh auth login --hostname {hostname}'." ) - if "\r" in token or "\n" in token: + if not token.isascii() or "\r" in token or "\n" in token: raise GitHubCliAuthError( "GitHub authentication failed for provider 'github': " f"GitHub CLI returned a malformed token for {hostname!r}." diff --git a/tests/specify_cli/authentication/test_authentication.py b/tests/specify_cli/authentication/test_authentication.py index 95b663288e..14b377ef13 100644 --- a/tests/specify_cli/authentication/test_authentication.py +++ b/tests/specify_cli/authentication/test_authentication.py @@ -794,12 +794,12 @@ def test_github_cli_empty_output_raises(self): ), pytest.raises(GitHubCliAuthError, match="empty token"): GitHubAuth().resolve_token(_github_cli_entry()) - @pytest.mark.parametrize("separator", ["\r", "\n", "\r\n"]) - def test_github_cli_caches_sanitized_malformed_token_failure(self, separator): + @pytest.mark.parametrize("invalid_text", ["\r", "\n", "\r\n", "\u00e9", "\u2603"]) + def test_github_cli_caches_sanitized_malformed_token_failure(self, invalid_text): from unittest.mock import MagicMock, patch secret = "synthetic_credential" - result = MagicMock(returncode=0, stdout=f" {secret}{separator}extra\n") + result = MagicMock(returncode=0, stdout=f" {secret}{invalid_text}extra\n") executable = os.path.join(os.path.abspath(os.sep), "opt", "gh", "gh") provider = GitHubAuth() messages = [] From ecce5d6a43de2769044fb8026e99d0fa77a7a733 Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:16:26 -0500 Subject: [PATCH 16/17] fix(auth): preserve credential failures in release and bundle consumers Keep sanitized GitHub CLI diagnostics in release checks and upgrade failure rendering. Treat authentication failures as definitive in bundle catalog fetching and reference validation, rather than availability fallbacks. Add regressions that failed before the fix and document the caller behavior; preserve the first-host contract and authentication base classes. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- docs/reference/authentication.md | 4 + src/specify_cli/_version.py | 11 ++- src/specify_cli/bundles/adapters.py | 7 +- src/specify_cli/bundles/component_catalog.py | 3 +- .../authentication/test_authentication.py | 99 +++++++++++++++++-- 5 files changed, 112 insertions(+), 12 deletions(-) diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index 2d01c610dd..f7f2e02749 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -102,6 +102,10 @@ Credential acquisition failures fail the affected request without trying another credential or an unauthenticated request. They use the existing URL-error category, so callers retain their normal error handling (for example, warning and skipping an unavailable catalog). +Release checks and upgrades preserve the authentication diagnostic rather than +reporting a network outage. Online bundle catalog fetching and reference +validation treat authentication failures as errors, not as availability +fallbacks to packaged snapshots or unchecked references. For `github-cli` only, the **first hostname in `hosts`** is passed to `gh auth token --hostname`. Put `github.com` or your enterprise instance's diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py index 93b4c6a618..c8fcad7c7e 100644 --- a/src/specify_cli/_version.py +++ b/src/specify_cli/_version.py @@ -37,6 +37,7 @@ "rate limited (configure ~/.specify/auth.json with a GitHub token)" ) _RESOLUTION_FAILURE_HTTP_PREFIX = "HTTP " +_RESOLUTION_FAILURE_AUTH_PREFIX = "authentication error: " _FAILURE_INSTALLER_MISSING = "installer-missing" _FAILURE_INSTALLER_INVALID = "installer-invalid" _FAILURE_TARGET_TAG_UNPARSEABLE = "target-tag-unparseable" @@ -108,11 +109,12 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]: """Return (tag, failure_category). Exactly one outbound call, 5 s timeout. On success: (tag_name, None). - On a documented network/HTTP failure (added in T029/T030): (None, category). + On a documented authentication/network/HTTP failure: (None, category). On anything else — including a malformed response body — the exception propagates; there is no catch-all (research D-006). """ from .authentication.http import open_url + from .authentication.github import GitHubCliAuthError try: with open_url( @@ -131,6 +133,8 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]: if not isinstance(tag, str) or not tag: raise ValueError("GitHub API response missing valid tag_name") return tag, None + except GitHubCliAuthError as exc: + return None, f"{_RESOLUTION_FAILURE_AUTH_PREFIX}{exc.reason}" except urllib.error.HTTPError as e: # Order matters: HTTPError is a subclass of URLError. # 403 (primary rate limit / abuse detection) and 429 (Too Many Requests / @@ -1021,7 +1025,10 @@ def _emit_failure( """Render user-facing output for resolver, installer, or verification failures.""" if ( category in _RESOLUTION_FAILURE_CATEGORIES - or category.startswith(_RESOLUTION_FAILURE_HTTP_PREFIX) + or category.startswith(( + _RESOLUTION_FAILURE_HTTP_PREFIX, + _RESOLUTION_FAILURE_AUTH_PREFIX, + )) ): console.print(f"Upgrade aborted: {category}", soft_wrap=True) return diff --git a/src/specify_cli/bundles/adapters.py b/src/specify_cli/bundles/adapters.py index 46433cb005..fb4c6571c4 100644 --- a/src/specify_cli/bundles/adapters.py +++ b/src/specify_cli/bundles/adapters.py @@ -228,6 +228,7 @@ def _http_get_json(source_id: str, url: str) -> dict: HTTPS/host guarantee from ``_validate_remote_url`` is preserved end to end rather than only on the initial URL. """ + from ..authentication.github import GitHubCliAuthError from ..authentication.http import RedirectPolicyError, open_url def _validate_redirect(_old_url: str, new_url: str) -> None: @@ -258,9 +259,9 @@ def _validate_redirect(_old_url: str, new_url: str) -> None: raise BundlerError( f"Failed to fetch catalog from {url}: {exc}" ) from exc - except RedirectPolicyError as exc: - # Unsafe/malformed redirects are security failures and must surface as - # a hard error instead of being downgraded to catalog unavailability. + except (GitHubCliAuthError, RedirectPolicyError) as exc: + # Authentication and redirect-policy failures are definitive, not + # transient catalog unavailability. raise BundlerError( f"Failed to fetch catalog from {url}: {exc}" ) from exc diff --git a/src/specify_cli/bundles/component_catalog.py b/src/specify_cli/bundles/component_catalog.py index d4b7a107c7..aef547e6ac 100644 --- a/src/specify_cli/bundles/component_catalog.py +++ b/src/specify_cli/bundles/component_catalog.py @@ -6,6 +6,7 @@ from http.client import HTTPException from urllib.error import HTTPError, URLError +from ..authentication.github import GitHubCliAuthError from ..authentication.http import RedirectPolicyError from . import BundlerError from .manifest import ComponentRef @@ -19,7 +20,7 @@ def _is_unavailable(exc: BaseException) -> bool: seen: set[int] = set() while exc is not None and id(exc) not in seen: seen.add(id(exc)) - if isinstance(exc, RedirectPolicyError): + if isinstance(exc, (GitHubCliAuthError, RedirectPolicyError)): return False if isinstance(exc, HTTPError): return exc.code in (408, 429) or 500 <= exc.code <= 599 diff --git a/tests/specify_cli/authentication/test_authentication.py b/tests/specify_cli/authentication/test_authentication.py index 14b377ef13..7428779378 100644 --- a/tests/specify_cli/authentication/test_authentication.py +++ b/tests/specify_cli/authentication/test_authentication.py @@ -1682,12 +1682,9 @@ def test_failed_cli_catalog_does_not_abort_other_catalogs( assert "GitHub CLI ('gh') was not found" in warning build_opener.return_value.open.assert_called_once() - def test_release_check_handles_cli_failure_as_url_error(self): + def test_release_check_preserves_cli_failure_reason(self): from unittest.mock import patch - from specify_cli._version import ( - _fetch_latest_release_tag, - _RESOLUTION_FAILURE_OFFLINE, - ) + from specify_cli._version import _fetch_latest_release_tag with patch( "specify_cli.authentication.github.shutil.which", @@ -1695,8 +1692,98 @@ def test_release_check_handles_cli_failure_as_url_error(self): ), patch( "specify_cli.authentication.http.urllib.request.build_opener", ) as build_opener: - assert _fetch_latest_release_tag() == (None, _RESOLUTION_FAILURE_OFFLINE) + tag, reason = _fetch_latest_release_tag() + + assert tag is None + assert reason is not None + assert "GitHub CLI ('gh') was not found" in reason + assert "offline or timeout" not in reason + + build_opener.assert_not_called() + + @pytest.mark.parametrize("detail", [ + "GitHub CLI ('gh') was not found.", + "Run 'gh auth login --hostname github.com'.", + ]) + @pytest.mark.parametrize("command,exit_code", [("check", 0), ("upgrade", 1)]) + def test_release_commands_preserve_cli_failure(self, detail, command, exit_code): + from unittest.mock import patch + from typer.testing import CliRunner + from specify_cli import app, _version + + error = GitHubCliAuthError( + "GitHub authentication failed for provider 'github': " + detail + ) + with patch.object( + GitHubAuth, "_acquire_via_gh_cli", side_effect=error, + ), patch.object( + _version, "_detect_install_method", return_value=_version._InstallMethod.UV_TOOL, + ), patch.object( + _version, "_get_installed_version", return_value="1.0.0", + ), patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener, patch.object(_version.subprocess, "run") as run: + result = CliRunner().invoke(app, ["self", command]) + + assert result.exit_code == exit_code, result.output + assert detail in result.output + assert "offline or timeout" not in result.output + build_opener.assert_not_called() + run.assert_not_called() + + @pytest.mark.parametrize("url", [ + "builtin://default", + "https://github.com/org/repo/catalog.json", + ]) + def test_bundle_catalog_auth_failure_does_not_use_snapshot(self, url): + from unittest.mock import patch + from specify_cli.bundles import BundlerError, adapters + from specify_cli.bundles.catalogs import CatalogSource, InstallPolicy + + source = CatalogSource( + id="test", url=url, priority=1, + install_policy=InstallPolicy.INSTALL_ALLOWED, + ) + with patch( + "specify_cli.authentication.github.shutil.which", return_value=None, + ), patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener, patch.object( + adapters, "_load_packaged_catalog", + ) as snapshot, pytest.raises(BundlerError, match="was not found") as exc_info: + adapters.make_catalog_fetcher(allow_network=True)(source) + + assert not isinstance(exc_info.value, adapters._CatalogUnavailable) + snapshot.assert_not_called() + build_opener.assert_not_called() + def test_bundle_reference_auth_failure_is_not_left_unchecked(self, tmp_path): + from types import SimpleNamespace + from unittest.mock import patch + from specify_cli.bundles.manifest import ComponentRef + from specify_cli.bundles.references import make_reference_checker + from specify_cli.extensions import ExtensionCatalog + + source = SimpleNamespace( + name="private", install_allowed=True, + url="https://github.com/org/repo/catalog.json", + ) + warnings = [] + with patch.object( + ExtensionCatalog, "get_active_catalogs", return_value=[source], + ), patch( + "specify_cli.authentication.github.shutil.which", return_value=None, + ), patch( + "specify_cli.authentication.http.urllib.request.build_opener", + ) as build_opener: + check = make_reference_checker(tmp_path, allow_network=True, warnings=warnings) + problem = check(ComponentRef( + kind="extensions", id="private-extension", version="1.0.0", source="private", + )) + + assert problem is not None + assert "GitHub CLI ('gh') was not found" in problem + assert warnings == [] build_opener.assert_not_called() From 6d005329b156e3dd41aea53cb42ba3a39ccc9cdb Mon Sep 17 00:00:00 2001 From: Manfred Riem <15701806+mnriem@users.noreply.github.com> Date: Fri, 23 Oct 2026 12:43:51 -0500 Subject: [PATCH 17/17] test(auth): normalize wrapped release diagnostics Normalize captured whitespace before checking authentication error details so Rich line wrapping does not make release-command tests depend on terminal width. Assisted-by: GitHub Copilot (model: GPT-6.1 Sol, autonomous) Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- tests/specify_cli/authentication/test_authentication.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/specify_cli/authentication/test_authentication.py b/tests/specify_cli/authentication/test_authentication.py index 7428779378..31c94f2ad1 100644 --- a/tests/specify_cli/authentication/test_authentication.py +++ b/tests/specify_cli/authentication/test_authentication.py @@ -1726,8 +1726,9 @@ def test_release_commands_preserve_cli_failure(self, detail, command, exit_code) result = CliRunner().invoke(app, ["self", command]) assert result.exit_code == exit_code, result.output - assert detail in result.output - assert "offline or timeout" not in result.output + output = " ".join(result.output.split()) + assert detail in output + assert "offline or timeout" not in output build_opener.assert_not_called() run.assert_not_called()