diff --git a/docs/reference/authentication.md b/docs/reference/authentication.md index 9e0ff21449..1b20f34305 100644 --- a/docs/reference/authentication.md +++ b/docs/reference/authentication.md @@ -60,6 +60,9 @@ Either `token` or `token_env` must be set for the `bearer`, `basic-pat`, and `ba |---|---|---| | `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. + **Example — PAT via environment variable:** ```json @@ -71,6 +74,30 @@ Either `token` or `token_env` must be set for the `bearer`, `basic-pat`, and `ba } ``` +**Example — GitHub CLI:** + +```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 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) To use a private catalog or extension hosted on a GitHub Enterprise Server @@ -98,6 +125,27 @@ 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: + +```json +{ + "hosts": [ + "ghes.example.com", + "raw.ghes.example.com", + "codeload.ghes.example.com" + ], + "provider": "github", + "auth": "github-cli" +} +``` + +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) GHE.com tenants use separate web and REST API hostnames. List both hosts so diff --git a/src/specify_cli/authentication/github.py b/src/specify_cli/authentication/github.py index 3797d82926..6f13f349c2 100644 --- a/src/specify_cli/authentication/github.py +++ b/src/specify_cli/authentication/github.py @@ -2,23 +2,162 @@ 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 -class GitHubAuth(AuthProvider): - """GitHub authentication provider. - Supports the ``bearer`` auth scheme, used for PATs, fine-grained PATs, - OAuth tokens, and GitHub App installation tokens. - """ +_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: + """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(RuntimeError): + """Raised when GitHub CLI cannot provide a configured credential.""" + + +class GitHubAuth(AuthProvider): + """GitHub authentication provider.""" 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": + """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}"} + 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) + + credential_host = self._credential_hostname(entry) + 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 _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: + 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: + """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 configured hostname is not valid for GitHub CLI authentication." + ) + + 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." + ) + + try: + result = subprocess.run( # noqa: S603 + [resolved, "auth", "token", "--hostname", hostname], + 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/tests/specify_cli/authentication/test_authentication.py b/tests/specify_cli/authentication/test_authentication.py index 45c0a4b833..c46d4ac5c9 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,22 @@ def _github_entry(token_env: str = "GH_TOKEN", token: str | None = None) -> Auth ) +def _github_cli_entry( + hosts: tuple[str, ...] = ( + "github.com", + "api.github.com", + "raw.githubusercontent.com", + "codeload.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( @@ -94,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 @@ -589,6 +622,185 @@ class TestGitHubAuth: def test_bearer_headers(self): assert GitHubAuth().auth_headers("my-token", "bearer") == {"Authorization": "Bearer my-token"} + def test_github_cli_headers(self): + assert GitHubAuth().auth_headers("my-token", "github-cli") == { + "Authorization": "Bearer my-token" + } + + def test_github_cli_resolves_token_with_fixed_command(self): + from unittest.mock import MagicMock, patch + + 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: + token = GitHubAuth().resolve_token(_github_cli_entry()) + + 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, + ) + + def test_github_cli_uses_bare_enterprise_host(self): + from unittest.mock import MagicMock, patch + + entry = _github_cli_entry(( + "ghes.example.com", + "raw.ghes.example.com", + "codeload.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: + assert GitHubAuth().resolve_token(entry) == "enterprise-token" + + 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")) + + with pytest.raises(GitHubCliAuthError, match="separate provider entries"): + GitHubAuth().resolve_token(entry) + + 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") + + 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()) == "cached-token" + assert provider.resolve_token(_github_cli_entry()) == "cached-token" + + run.assert_called_once() + + @pytest.mark.parametrize("executable", [None, r".\gh.exe", "gh.exe", "./gh"]) + def test_github_cli_rejects_missing_or_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()) + + run.assert_not_called() + + def test_github_cli_nonzero_suggests_login_without_command_output(self): + from unittest.mock import MagicMock, patch + + secret = "ghp_must_not_leak" + result = MagicMock(returncode=1, 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()) + + message = str(exc_info.value) + assert "gh auth login --hostname github.com" in message + assert secret not in message + + def test_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="empty token"): + GitHubAuth().resolve_token(_github_cli_entry()) + + def test_github_cli_timeout_raises(self): + import subprocess + from unittest.mock import patch + + 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", + side_effect=subprocess.TimeoutExpired(["gh"], 10), + ), pytest.raises(GitHubCliAuthError, match="timed out"): + GitHubAuth().resolve_token(_github_cli_entry()) + + def test_github_cli_caches_sanitized_failure(self): + 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") + + 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) as exc_info: + provider.resolve_token(_github_cli_entry()) + assert "secret" not in str(exc_info.value) + + run.assert_called_once() + + def test_github_cli_rejects_shell_unsafe_host_without_echoing_it(self): + from unittest.mock import patch + + unsafe_host = "a&whoami&b.ghes.example.com" + entry = _github_cli_entry((unsafe_host,)) + + with patch( + "specify_cli.authentication.github.shutil.which", + ) as which, patch( + "specify_cli.authentication.github.subprocess.run", + ) as run, pytest.raises(GitHubCliAuthError) as exc_info: + GitHubAuth().resolve_token(entry) + + assert unsafe_host not in str(exc_info.value) + which.assert_not_called() + run.assert_not_called() + def test_unsupported_scheme_raises(self): with pytest.raises(ValueError, match="basic-pat"): GitHubAuth().auth_headers("tok", "basic-pat") @@ -616,7 +828,7 @@ 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") # ---------------------------------------------------------------------------