diff --git a/docs/src/content/docs/getting-started/authentication.md b/docs/src/content/docs/getting-started/authentication.md index 8768531e..74c3fdfa 100644 --- a/docs/src/content/docs/getting-started/authentication.md +++ b/docs/src/content/docs/getting-started/authentication.md @@ -10,13 +10,14 @@ APM works without tokens for public packages on github.com. Authentication is ne APM resolves tokens per `(host, org)` pair. For each dependency, it walks a resolution chain until it finds a token: -1. **Per-org env var** — `GITHUB_APM_PAT_{ORG}` (GitHub-like hosts — not ADO) -2. **Global env vars** — `GITHUB_APM_PAT` → `GITHUB_TOKEN` → `GH_TOKEN` (any host) -3. **Git credential helper** — `git credential fill` (any host except ADO) +1. **Per-org env var** -- `GITHUB_APM_PAT_{ORG}` (GitHub-like hosts -- not ADO) +2. **Global env vars** -- `GITHUB_APM_PAT` -> `GITHUB_TOKEN` -> `GH_TOKEN` (any host) +3. **GitHub CLI active account** -- `gh auth token --hostname ` (GitHub-like hosts) +4. **Git credential helper** -- `git credential fill` (any host except ADO) -If the global token doesn't work for the target host, APM automatically retries with git credential helpers. If nothing matches, APM attempts unauthenticated access (works for public repos on github.com). +If the global token doesn't work for the target host, APM next tries the active `gh` CLI account before falling back to git credential helpers. If nothing matches, APM attempts unauthenticated access (works for public repos on github.com). -Results are cached per-process — the same `(host, org)` pair is resolved once. +Results are cached per-process -- the same `(host, org)` pair is resolved once. All token-bearing requests use HTTPS. Tokens are never sent over unencrypted connections. @@ -28,7 +29,8 @@ All token-bearing requests use HTTPS. Tokens are never sent over unencrypted con | 2 | `GITHUB_APM_PAT` | Any host | Falls back to git credential helpers if rejected | | 3 | `GITHUB_TOKEN` | Any host | Shared with GitHub Actions | | 4 | `GH_TOKEN` | Any host | Set by `gh auth login` | -| 5 | `git credential fill` | Per-host | System credential manager, `gh auth`, OS keychain | +| 5 | `gh auth token --hostname ` | GitHub-like hosts | Active `gh auth login` account | +| 6 | `git credential fill` | Per-host | System credential manager, `gh auth`, OS keychain | For Azure DevOps, the only token source is `ADO_APM_PAT`. diff --git a/packages/apm-guide/.apm/skills/apm-usage/authentication.md b/packages/apm-guide/.apm/skills/apm-usage/authentication.md index 1652a3ee..487a1623 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/authentication.md +++ b/packages/apm-guide/.apm/skills/apm-usage/authentication.md @@ -10,9 +10,12 @@ APM checks these sources in order, using the first valid token found: | 2 | `GITHUB_APM_PAT` | Global | Falls back to git credential if rejected | | 3 | `GITHUB_TOKEN` | Global | Shared with GitHub Actions | | 4 | `GH_TOKEN` | Global | Set by `gh auth login` | -| 5 | `git credential fill` | Per-host | System credential manager | +| 5 | `gh auth token --hostname ` | GitHub-like hosts | Active `gh auth login` account | +| 6 | `git credential fill` | Per-host | System credential manager | | -- | None | -- | Unauthenticated (public GitHub repos only) | +APM checks the active `gh` CLI account before invoking OS credential helpers. This reduces ambiguous multi-account prompts on hosts like github.com. + ## Per-org setup Use per-org tokens when accessing packages across multiple organizations: diff --git a/src/apm_cli/core/auth.py b/src/apm_cli/core/auth.py index 1ab109a0..cee43630 100644 --- a/src/apm_cli/core/auth.py +++ b/src/apm_cli/core/auth.py @@ -178,8 +178,12 @@ def detect_token_type(token: str) -> str: # -- core resolution ---------------------------------------------------- - def resolve(self, host: str, org: Optional[str] = None) -> AuthContext: - """Resolve auth for *(host, org)*. Cached & thread-safe.""" + def resolve( + self, + host: str, + org: Optional[str] = None, + ) -> AuthContext: + """Resolve auth for *(host, org)*. Cached & thread-safe.""" key = (host.lower() if host else host, org.lower() if org else org) with self._lock: cached = self._cache.get(key) @@ -245,7 +249,8 @@ def try_with_fallback( When the resolved token comes from a global env var and fails (e.g. a github.com PAT tried on ``*.ghe.com``), the method - retries with ``git credential fill`` before giving up. + retries with ``gh auth token`` and then ``git credential fill`` + before giving up. """ auth_ctx = self.resolve(host, org) host_info = auth_ctx.host_info @@ -261,7 +266,11 @@ def _try_credential_fallback(exc: Exception) -> T: raise exc if host_info.kind == "ado": raise exc - _log(f"Token from {auth_ctx.source} failed, trying git credential fill for {host}") + _log(f"Token from {auth_ctx.source} failed, trying fallback credentials for {host}") + if host_info.kind in ("github", "ghe_cloud", "ghes"): + gh_token = self._token_manager.resolve_credential_from_gh_cli(host) + if gh_token: + return operation(gh_token, self._build_git_env(gh_token)) cred = self._token_manager.resolve_credential_from_git(host) if cred: return operation(cred, self._build_git_env(cred)) @@ -362,9 +371,7 @@ def build_error_context( # -- internals ---------------------------------------------------------- - def _resolve_token( - self, host_info: HostInfo, org: Optional[str] - ) -> tuple[Optional[str], str]: + def _resolve_token(self, host_info: HostInfo, org: Optional[str]) -> tuple[Optional[str], str]: """Walk the token resolution chain. Returns (token, source). Resolution order: @@ -372,7 +379,8 @@ def _resolve_token( 2. Global env vars ``GITHUB_APM_PAT`` → ``GITHUB_TOKEN`` → ``GH_TOKEN`` (any host — if the token is wrong for the target host, ``try_with_fallback`` retries with git credentials) - 3. Git credential helper (any host except ADO) + 3. gh CLI active account (GitHub-like hosts only) + 4. Git credential helper (any host except ADO) All token-bearing requests use HTTPS, which is the transport security boundary. Host-gating global env vars is unnecessary @@ -392,7 +400,13 @@ def _resolve_token( source = self._identify_env_source(purpose) return token, source - # 3. Git credential helper (not for ADO — uses its own PAT) + # 3. gh CLI active account (GitHub-like hosts only) + if host_info.kind in ("github", "ghe_cloud", "ghes"): + gh_token = self._token_manager.resolve_credential_from_gh_cli(host_info.host) + if gh_token: + return gh_token, "gh-auth-token" + + # 4. Git credential helper (not for ADO -- uses its own PAT) if host_info.kind not in ("ado",): credential = self._token_manager.resolve_credential_from_git(host_info.host) if credential: diff --git a/src/apm_cli/core/token_manager.py b/src/apm_cli/core/token_manager.py index 93afdd60..151d9765 100644 --- a/src/apm_cli/core/token_manager.py +++ b/src/apm_cli/core/token_manager.py @@ -11,7 +11,7 @@ - GITHUB_TOKEN: User-scoped PAT for GitHub Models API access Platform Token Selection: -- GitHub: GITHUB_APM_PAT -> GITHUB_TOKEN -> GH_TOKEN -> git credential helpers +- GitHub: GITHUB_APM_PAT -> GITHUB_TOKEN -> GH_TOKEN -> gh auth token -> git credential helpers - Azure DevOps: ADO_APM_PAT Runtime Requirements: @@ -23,6 +23,13 @@ import sys from typing import Dict, Optional, Tuple +from apm_cli.utils.github_host import ( + default_host, + is_azure_devops_hostname, + is_github_hostname, + is_valid_fqdn, +) + class GitHubTokenManager: """Manages GitHub token environment setup for different AI runtimes.""" @@ -70,6 +77,24 @@ def _is_valid_credential_token(token: str) -> bool: return False return True + @staticmethod + def _supports_gh_cli_host(host: Optional[str]) -> bool: + """Return True when *host* should use gh CLI fallback.""" + if not host: + return False + if is_github_hostname(host): + return True + + configured_host = default_host().lower() + host_lower = host.lower() + if host_lower != configured_host: + return False + if configured_host == "github.com" or configured_host.endswith(".ghe.com"): + return False + if is_azure_devops_hostname(configured_host): + return False + return is_valid_fqdn(configured_host) + # `git credential fill` may invoke OS credential helpers that show # interactive dialogs (e.g. Windows Credential Manager account picker). # The 60s default prevents false negatives on slow helpers. @@ -101,14 +126,16 @@ def resolve_credential_from_git(host: str) -> Optional[str]: Args: host: The git host to resolve credentials for (e.g., "github.com") - + Returns: The password/token from the credential store, or None if unavailable """ try: + request = f'protocol=https\nhost={host}\n\n' + result = subprocess.run( - ['git', 'credential', 'fill'], - input=f"protocol=https\nhost={host}\n\n", + ['git', '-c', 'credential.useHttpPath=true', 'credential', 'fill'], + input=request, capture_output=True, text=True, encoding="utf-8", @@ -128,6 +155,32 @@ def resolve_credential_from_git(host: str) -> Optional[str]: return None except (subprocess.TimeoutExpired, FileNotFoundError, OSError): return None + + @staticmethod + def resolve_credential_from_gh_cli(host: str) -> Optional[str]: + """Resolve a token from the active gh CLI account for the host. + + Uses `gh auth token --hostname ` as a non-interactive fallback + before invoking OS credential helpers that may display UI. + """ + try: + result = subprocess.run( + ['gh', 'auth', 'token', '--hostname', host], + capture_output=True, + text=True, + encoding='utf-8', + timeout=GitHubTokenManager._get_credential_timeout(), + env={**os.environ, 'GH_PROMPT_DISABLED': '1'}, + ) + if result.returncode != 0: + return None + + token = result.stdout.strip() + if token and GitHubTokenManager._is_valid_credential_token(token): + return token + return None + except (subprocess.TimeoutExpired, FileNotFoundError, OSError): + return None def setup_environment(self, env: Optional[Dict[str, str]] = None) -> Dict[str, str]: """Set up complete token environment for all runtimes. @@ -173,12 +226,18 @@ def get_token_for_purpose(self, purpose: str, env: Optional[Dict[str, str]] = No return token return None - def get_token_with_credential_fallback(self, purpose: str, host: str, env: Optional[Dict[str, str]] = None) -> Optional[str]: + def get_token_with_credential_fallback( + self, + purpose: str, + host: str, + env: Optional[Dict[str, str]] = None, + ) -> Optional[str]: """Get token for a purpose, falling back to git credential helpers. Tries environment variables first (via get_token_for_purpose), then - queries the git credential store as a last resort. Results are cached - per host to avoid repeated subprocess calls. + checks the active gh CLI account, then queries the git credential + store as a last resort. Results are cached per host to avoid repeated + subprocess calls. Args: purpose: Token purpose ('modules', etc.) @@ -192,11 +251,19 @@ def get_token_with_credential_fallback(self, purpose: str, host: str, env: Optio if token: return token - if host in self._credential_cache: - return self._credential_cache[host] + cache_key = host.lower() if host else host + if cache_key in self._credential_cache: + return self._credential_cache[cache_key] + + gh_token = None + if self._supports_gh_cli_host(host): + gh_token = self.resolve_credential_from_gh_cli(host) + if gh_token: + self._credential_cache[cache_key] = gh_token + return gh_token credential = self.resolve_credential_from_git(host) - self._credential_cache[host] = credential + self._credential_cache[cache_key] = credential return credential def validate_tokens(self, env: Optional[Dict[str, str]] = None) -> Tuple[bool, str]: diff --git a/tests/test_token_manager.py b/tests/test_token_manager.py index 1ddb02cb..edab1649 100644 --- a/tests/test_token_manager.py +++ b/tests/test_token_manager.py @@ -2,6 +2,7 @@ import os import subprocess +import sys from unittest.mock import patch, MagicMock import pytest @@ -122,6 +123,7 @@ def test_correct_input_sent(self): GitHubTokenManager.resolve_credential_from_git('github.com') call_kwargs = mock_run.call_args assert call_kwargs.kwargs['input'] == "protocol=https\nhost=github.com\n\n" + assert call_kwargs.args[0] == ['git', '-c', 'credential.useHttpPath=true', 'credential', 'fill'] def test_git_terminal_prompt_disabled(self): """GIT_TERMINAL_PROMPT=0 is set in the subprocess env.""" @@ -137,7 +139,8 @@ def test_git_askpass_set_to_empty(self): with patch('subprocess.run', return_value=mock_result) as mock_run: GitHubTokenManager.resolve_credential_from_git('github.com') call_env = mock_run.call_args.kwargs['env'] - assert call_env['GIT_ASKPASS'] == '' + expected = 'echo' if sys.platform == 'win32' else '' + assert call_env['GIT_ASKPASS'] == expected def test_rejects_password_prompt_as_token(self): """Rejects 'Password for ...' prompt text echoed back by GIT_ASKPASS.""" @@ -205,6 +208,32 @@ def test_accepts_valid_gho_token(self): assert token == 'gho_abc123def456' +class TestResolveCredentialFromGhCli: + """Test resolve_credential_from_gh_cli static method.""" + + def test_success_returns_token(self): + mock_result = MagicMock(returncode=0, stdout="gho_cli_token\n") + with patch('subprocess.run', return_value=mock_result) as mock_run: + token = GitHubTokenManager.resolve_credential_from_gh_cli('github.com') + assert token == 'gho_cli_token' + assert mock_run.call_args.args[0] == ['gh', 'auth', 'token', '--hostname', 'github.com'] + assert mock_run.call_args.kwargs['env']['GH_PROMPT_DISABLED'] == '1' + + def test_nonzero_exit_returns_none(self): + mock_result = MagicMock(returncode=1, stdout="", stderr="not logged in") + with patch('subprocess.run', return_value=mock_result): + assert GitHubTokenManager.resolve_credential_from_gh_cli('github.com') is None + + def test_invalid_output_returns_none(self): + mock_result = MagicMock(returncode=0, stdout="Username for 'https://github.com':\n") + with patch('subprocess.run', return_value=mock_result): + assert GitHubTokenManager.resolve_credential_from_gh_cli('github.com') is None + + def test_timeout_returns_none(self): + with patch('subprocess.run', side_effect=subprocess.TimeoutExpired(cmd='gh', timeout=5)): + assert GitHubTokenManager.resolve_credential_from_gh_cli('github.com') is None + + class TestCredentialTimeout: """Tests for configurable git credential fill timeout.""" @@ -282,58 +311,84 @@ def test_returns_env_token_without_credential_fill(self): """Returns env var token and never calls credential fill.""" with patch.dict(os.environ, {'GITHUB_APM_PAT': 'env-token'}, clear=True): manager = GitHubTokenManager() - with patch.object(GitHubTokenManager, 'resolve_credential_from_git') as mock_cred: + with patch.object(GitHubTokenManager, 'resolve_credential_from_gh_cli') as mock_gh, \ + patch.object(GitHubTokenManager, 'resolve_credential_from_git') as mock_cred: token = manager.get_token_with_credential_fallback('modules', 'github.com') assert token == 'env-token' + mock_gh.assert_not_called() + mock_cred.assert_not_called() + + def test_falls_back_to_gh_cli_before_credential_fill(self): + """Uses gh CLI before git credential helpers when no env token exists.""" + with patch.dict(os.environ, {}, clear=True): + manager = GitHubTokenManager() + with patch.object(GitHubTokenManager, 'resolve_credential_from_gh_cli', return_value='gh-token') as mock_gh, \ + patch.object(GitHubTokenManager, 'resolve_credential_from_git') as mock_cred: + token = manager.get_token_with_credential_fallback('modules', 'github.com') + assert token == 'gh-token' + mock_gh.assert_called_once_with('github.com') mock_cred.assert_not_called() def test_falls_back_to_credential_fill(self): - """Falls back to resolve_credential_from_git when no env token.""" + """Falls back to resolve_credential_from_git when gh CLI has no token.""" with patch.dict(os.environ, {}, clear=True): manager = GitHubTokenManager() - with patch.object( - GitHubTokenManager, 'resolve_credential_from_git', return_value='cred-token' - ) as mock_cred: + with patch.object(GitHubTokenManager, 'resolve_credential_from_gh_cli', return_value=None) as mock_gh, \ + patch.object(GitHubTokenManager, 'resolve_credential_from_git', return_value='cred-token') as mock_cred: token = manager.get_token_with_credential_fallback('modules', 'github.com') assert token == 'cred-token' + mock_gh.assert_called_once_with('github.com') mock_cred.assert_called_once_with('github.com') def test_caches_credential_result(self): """Second call uses cache, subprocess not invoked again.""" with patch.dict(os.environ, {}, clear=True): manager = GitHubTokenManager() - with patch.object( - GitHubTokenManager, 'resolve_credential_from_git', return_value='cached-tok' - ) as mock_cred: + with patch.object(GitHubTokenManager, 'resolve_credential_from_gh_cli', return_value=None) as mock_gh, \ + patch.object(GitHubTokenManager, 'resolve_credential_from_git', return_value='cached-tok') as mock_cred: first = manager.get_token_with_credential_fallback('modules', 'github.com') second = manager.get_token_with_credential_fallback('modules', 'github.com') assert first == second == 'cached-tok' + mock_gh.assert_called_once_with('github.com') mock_cred.assert_called_once() def test_caches_none_results(self): """None results are cached to avoid retrying failed lookups.""" with patch.dict(os.environ, {}, clear=True): manager = GitHubTokenManager() - with patch.object( - GitHubTokenManager, 'resolve_credential_from_git', return_value=None - ) as mock_cred: + with patch.object(GitHubTokenManager, 'resolve_credential_from_gh_cli', return_value=None) as mock_gh, \ + patch.object(GitHubTokenManager, 'resolve_credential_from_git', return_value=None) as mock_cred: first = manager.get_token_with_credential_fallback('modules', 'github.com') second = manager.get_token_with_credential_fallback('modules', 'github.com') assert first is None assert second is None + mock_gh.assert_called_once_with('github.com') mock_cred.assert_called_once() def test_different_hosts_separate_cache(self): """Different hosts get independent cache entries.""" with patch.dict(os.environ, {}, clear=True): manager = GitHubTokenManager() - with patch.object( - GitHubTokenManager, - 'resolve_credential_from_git', - side_effect=lambda h: f'tok-{h}', - ) as mock_cred: + with patch.object(GitHubTokenManager, 'resolve_credential_from_gh_cli', return_value=None) as mock_gh, \ + patch.object( + GitHubTokenManager, + 'resolve_credential_from_git', + side_effect=lambda h, path=None, username=None: f'tok-{h}', + ) as mock_cred: tok1 = manager.get_token_with_credential_fallback('modules', 'github.com') tok2 = manager.get_token_with_credential_fallback('modules', 'gitlab.com') assert tok1 == 'tok-github.com' assert tok2 == 'tok-gitlab.com' + mock_gh.assert_called_once_with('github.com') assert mock_cred.call_count == 2 + + def test_non_github_host_skips_gh_cli(self): + """Generic hosts should not invoke gh CLI fallback.""" + with patch.dict(os.environ, {}, clear=True): + manager = GitHubTokenManager() + with patch.object(GitHubTokenManager, 'resolve_credential_from_gh_cli') as mock_gh, \ + patch.object(GitHubTokenManager, 'resolve_credential_from_git', return_value='cred-token') as mock_cred: + token = manager.get_token_with_credential_fallback('modules', 'gitlab.com') + assert token == 'cred-token' + mock_gh.assert_not_called() + mock_cred.assert_called_once_with('gitlab.com') diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index f4a9390e..7ff5f2bc 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -11,6 +11,13 @@ from apm_cli.core.token_manager import GitHubTokenManager +@pytest.fixture(autouse=True) +def disable_gh_cli_fallback(): + """Keep auth tests deterministic regardless of local gh login state.""" + with patch.object(GitHubTokenManager, "resolve_credential_from_gh_cli", return_value=None): + yield + + # --------------------------------------------------------------------------- # TestClassifyHost # ---------------------------------------------------------------------------