From 19ac1e20a3bae65fe68fefc4ea65eb482e381063 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marc-Andr=C3=A9=20Moreau?= Date: Wed, 8 Apr 2026 15:39:12 -0400 Subject: [PATCH] fix(auth): try gh auth token before git credential fill Add a GitHub CLI fallback for GitHub-like hosts before invoking git credential fill. Update the auth resolution docs and focused auth tests to match the narrower fallback chain. --- .../docs/getting-started/authentication.md | 33 +++++---- .../.apm/skills/apm-usage/authentication.md | 5 +- src/apm_cli/core/auth.py | 31 +++++--- src/apm_cli/core/token_manager.py | 67 +++++++++++++++-- tests/test_token_manager.py | 71 ++++++++++++++++++- tests/unit/test_auth.py | 28 ++++++++ 6 files changed, 206 insertions(+), 29 deletions(-) diff --git a/docs/src/content/docs/getting-started/authentication.md b/docs/src/content/docs/getting-started/authentication.md index 52fed8138..414924d12 100644 --- a/docs/src/content/docs/getting-started/authentication.md +++ b/docs/src/content/docs/getting-started/authentication.md @@ -12,9 +12,10 @@ APM resolves tokens per `(host, org)` pair. For each dependency, it walks a reso 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) +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. @@ -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, APM resolves credentials in this order: `ADO_APM_PAT` env var, then a Microsoft Entra ID (AAD) bearer token from the Azure CLI (`az`). See [Azure DevOps](#azure-devops) below. @@ -297,21 +299,24 @@ flowchart TD B -->|GITHUB_APM_PAT_ORG| C[Use per-org token] B -->|Not set| D{Global env var?} D -->|GITHUB_APM_PAT / GITHUB_TOKEN / GH_TOKEN| E[Use global token] - D -->|Not set| F{Git credential fill?} - F -->|Found| G[Use credential] - F -->|Not found| H[No token] + D -->|Not set| F{gh auth token?} + F -->|Found| G[Use gh token] + F -->|Not found| H{Git credential fill?} + H -->|Found| J[Use credential] + H -->|Not found| K[No token] E --> I{try_with_fallback} C --> I G --> I - H --> I - - I -->|Token works| J[Success] - I -->|Token fails| K{Credential-fill fallback} - K -->|Found credential| J - K -->|No credential| L{Host has public repos?} - L -->|Yes| M[Try unauthenticated] - L -->|No| N[Auth error with actionable message] + J --> I + K --> I + + I -->|Token works| L[Success] + I -->|Token fails| M{Fallback credentials} + M -->|gh or git credential found| L + M -->|No credential| N{Host has public repos?} + N -->|Yes| O[Try unauthenticated] + N -->|No| P[Auth error with actionable message] ``` ### Git credential helper not found diff --git a/packages/apm-guide/.apm/skills/apm-usage/authentication.md b/packages/apm-guide/.apm/skills/apm-usage/authentication.md index 27a7d3f05..9e9758b2e 100644 --- a/packages/apm-guide/.apm/skills/apm-usage/authentication.md +++ b/packages/apm-guide/.apm/skills/apm-usage/authentication.md @@ -10,7 +10,10 @@ 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 | + +APM checks the active `gh` CLI account before invoking OS credential helpers. This reduces ambiguous multi-account prompts on hosts like github.com. | -- | None | -- | Unauthenticated (public GitHub repos only) | ## Per-org setup diff --git a/src/apm_cli/core/auth.py b/src/apm_cli/core/auth.py index b8d9d31d7..faac309db 100644 --- a/src/apm_cli/core/auth.py +++ b/src/apm_cli/core/auth.py @@ -325,7 +325,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, port=port) host_info = auth_ctx.host_info @@ -342,15 +343,22 @@ def _try_credential_fallback(exc: Exception) -> T: # ADO uses ADO_APM_PAT + AAD bearer fallback; credential fill is out of scope. if host_info.kind == "ado": raise exc - _log( - f"Token from {auth_ctx.source} failed, trying git credential fill " - f"for {host_info.display_name}" - ) + _log(f"Token from {auth_ctx.source} failed, trying fallback credentials for {host_info.display_name}") + 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 operation( + gh_token, + self._build_git_env(gh_token, scheme="basic", host_kind=host_info.kind), + ) cred = self._token_manager.resolve_credential_from_git( host_info.host, port=host_info.port ) if cred: - return operation(cred, self._build_git_env(cred)) + return operation( + cred, + self._build_git_env(cred, scheme="basic", host_kind=host_info.kind), + ) raise exc # ADO bearer fallback machinery (PAT was tried first; bearer is the safety net) @@ -600,7 +608,8 @@ def _resolve_token(self, host_info: HostInfo, org: str | None) -> tuple[str | No 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) Resolution order (ADO): 1. ``ADO_APM_PAT`` env var -> scheme ``"basic"`` @@ -647,7 +656,13 @@ def _resolve_token(self, host_info: HostInfo, org: str | None) -> tuple[str | No source = self._identify_env_source(purpose) return token, source, "basic" - # 3. Git credential helper (not for ADO) + # 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", "basic" + + # 4. Git credential helper (not for ADO) if host_info.kind not in ("ado",): credential = self._token_manager.resolve_credential_from_git( host_info.host, port=host_info.port diff --git a/src/apm_cli/core/token_manager.py b/src/apm_cli/core/token_manager.py index 2d3616842..df68bb58f 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 # noqa: F401, UP035 +from apm_cli.utils.github_host import ( + default_host, + is_azure_devops_hostname, + is_github_hostname, + is_valid_fqdn, +) + def _format_credential_host(host: str, port: int | None) -> str: """Embed a custom port into the git credential ``host`` field. @@ -93,6 +100,24 @@ def _is_valid_credential_token(token: str) -> bool: return False return True + @staticmethod + def _supports_gh_cli_host(host: str | None) -> 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. @@ -159,6 +184,32 @@ def resolve_credential_from_git(host: str, port: int | None = None) -> str | Non except (subprocess.TimeoutExpired, FileNotFoundError, OSError): return None + @staticmethod + def resolve_credential_from_gh_cli(host: str) -> str | None: + """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: dict[str, str] | None = None) -> dict[str, str]: """Set up complete token environment for all runtimes. @@ -214,9 +265,10 @@ def get_token_with_credential_fallback( """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, port)`` to avoid repeated subprocess calls while keeping - same-host-different-port credentials separate. + checks the active gh CLI account, then queries the git credential + store as a last resort. Results are cached per ``(host, port)`` to + avoid repeated subprocess calls while keeping same-host-different-port + credentials separate. Args: purpose: Token purpose ('modules', etc.) @@ -237,6 +289,13 @@ def get_token_with_credential_fallback( 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, port=port) self._credential_cache[cache_key] = credential return credential diff --git a/tests/test_token_manager.py b/tests/test_token_manager.py index fc1899619..ec1ee12e8 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 MagicMock, patch import pytest # noqa: F401 @@ -151,7 +152,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.""" @@ -219,6 +221,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.""" @@ -298,9 +326,24 @@ 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): @@ -308,10 +351,13 @@ def test_falls_back_to_credential_fill(self): with patch.dict(os.environ, {}, clear=True): manager = GitHubTokenManager() 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", port=None) def test_caches_credential_result(self): @@ -319,11 +365,14 @@ def test_caches_credential_result(self): with patch.dict(os.environ, {}, clear=True): manager = GitHubTokenManager() 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): @@ -331,12 +380,15 @@ def test_caches_none_results(self): with patch.dict(os.environ, {}, clear=True): manager = GitHubTokenManager() 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): @@ -344,6 +396,8 @@ def test_different_hosts_separate_cache(self): with patch.dict(os.environ, {}, clear=True): manager = GitHubTokenManager() 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, port=None: f"tok-{h}", @@ -352,8 +406,21 @@ def test_different_hosts_separate_cache(self): 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", port=None) + def test_same_host_different_ports_separate_cache(self): """Same host on different ports must not cross-contaminate credentials.""" with patch.dict(os.environ, {}, clear=True): diff --git a/tests/unit/test_auth.py b/tests/unit/test_auth.py index a6f752370..f1482414e 100644 --- a/tests/unit/test_auth.py +++ b/tests/unit/test_auth.py @@ -10,6 +10,7 @@ from apm_cli.core import azure_cli as _azure_cli_mod from apm_cli.core.auth import AuthContext, AuthResolver, HostInfo # noqa: F401 from apm_cli.core.token_manager import GitHubTokenManager +from apm_cli.models.dependency.reference import DependencyReference @pytest.fixture(autouse=True) @@ -21,6 +22,13 @@ def _reset_bearer_singleton(): _azure_cli_mod._provider_singleton = None +@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 # --------------------------------------------------------------------------- @@ -217,6 +225,26 @@ def test_credential_fallback(self): assert ctx.token == "cred-token" assert ctx.source == "git-credential-fill" + def test_resolve_for_dep_uses_standard_credential_fallback(self): + """Dependency-aware resolution still uses the standard host-based fallback chain.""" + dep_ref = DependencyReference.parse("Devolutions/RDM/.claude/skills/add-culture-rdm") + with patch.dict(os.environ, {}, clear=True): + 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: + resolver = AuthResolver() + ctx = resolver.resolve_for_dep(dep_ref) + assert ctx.token == "cred-token" + assert ctx.source == "git-credential-fill" + mock_gh.assert_called_once_with("github.com") + mock_cred.assert_called_once_with("github.com", port=None) + def test_global_var_resolves_for_non_default_host(self): """GITHUB_APM_PAT resolves for *.ghe.com (any host, not just default).""" with patch.dict(os.environ, {"GITHUB_APM_PAT": "global-token"}, clear=True):