Skip to content

Commit 19ac1e2

Browse files
committed
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.
1 parent d5ef108 commit 19ac1e2

6 files changed

Lines changed: 206 additions & 29 deletions

File tree

docs/src/content/docs/getting-started/authentication.md

Lines changed: 19 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,10 @@ APM resolves tokens per `(host, org)` pair. For each dependency, it walks a reso
1212

1313
1. **Per-org env var**`GITHUB_APM_PAT_{ORG}` (GitHub-like hosts — not ADO)
1414
2. **Global env vars**`GITHUB_APM_PAT``GITHUB_TOKEN``GH_TOKEN` (any host)
15-
3. **Git credential helper**`git credential fill` (any host except ADO)
15+
3. **GitHub CLI active account**`gh auth token --hostname <host>` (GitHub-like hosts)
16+
4. **Git credential helper**`git credential fill` (any host except ADO)
1617

17-
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).
18+
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).
1819

1920
Results are cached per-process — the same `(host, org)` pair is resolved once.
2021

@@ -28,7 +29,8 @@ All token-bearing requests use HTTPS. Tokens are never sent over unencrypted con
2829
| 2 | `GITHUB_APM_PAT` | Any host | Falls back to git credential helpers if rejected |
2930
| 3 | `GITHUB_TOKEN` | Any host | Shared with GitHub Actions |
3031
| 4 | `GH_TOKEN` | Any host | Set by `gh auth login` |
31-
| 5 | `git credential fill` | Per-host | System credential manager, `gh auth`, OS keychain |
32+
| 5 | `gh auth token --hostname <host>` | GitHub-like hosts | Active `gh auth login` account |
33+
| 6 | `git credential fill` | Per-host | System credential manager, `gh auth`, OS keychain |
3234

3335
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.
3436

@@ -297,21 +299,24 @@ flowchart TD
297299
B -->|GITHUB_APM_PAT_ORG| C[Use per-org token]
298300
B -->|Not set| D{Global env var?}
299301
D -->|GITHUB_APM_PAT / GITHUB_TOKEN / GH_TOKEN| E[Use global token]
300-
D -->|Not set| F{Git credential fill?}
301-
F -->|Found| G[Use credential]
302-
F -->|Not found| H[No token]
302+
D -->|Not set| F{gh auth token?}
303+
F -->|Found| G[Use gh token]
304+
F -->|Not found| H{Git credential fill?}
305+
H -->|Found| J[Use credential]
306+
H -->|Not found| K[No token]
303307
304308
E --> I{try_with_fallback}
305309
C --> I
306310
G --> I
307-
H --> I
308-
309-
I -->|Token works| J[Success]
310-
I -->|Token fails| K{Credential-fill fallback}
311-
K -->|Found credential| J
312-
K -->|No credential| L{Host has public repos?}
313-
L -->|Yes| M[Try unauthenticated]
314-
L -->|No| N[Auth error with actionable message]
311+
J --> I
312+
K --> I
313+
314+
I -->|Token works| L[Success]
315+
I -->|Token fails| M{Fallback credentials}
316+
M -->|gh or git credential found| L
317+
M -->|No credential| N{Host has public repos?}
318+
N -->|Yes| O[Try unauthenticated]
319+
N -->|No| P[Auth error with actionable message]
315320
```
316321

317322
### Git credential helper not found

packages/apm-guide/.apm/skills/apm-usage/authentication.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,10 @@ APM checks these sources in order, using the first valid token found:
1010
| 2 | `GITHUB_APM_PAT` | Global | Falls back to git credential if rejected |
1111
| 3 | `GITHUB_TOKEN` | Global | Shared with GitHub Actions |
1212
| 4 | `GH_TOKEN` | Global | Set by `gh auth login` |
13-
| 5 | `git credential fill` | Per-host | System credential manager |
13+
| 5 | `gh auth token --hostname <host>` | GitHub-like hosts | Active `gh auth login` account |
14+
| 6 | `git credential fill` | Per-host | System credential manager |
15+
16+
APM checks the active `gh` CLI account before invoking OS credential helpers. This reduces ambiguous multi-account prompts on hosts like github.com.
1417
| -- | None | -- | Unauthenticated (public GitHub repos only) |
1518

1619
## Per-org setup

src/apm_cli/core/auth.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -325,7 +325,8 @@ def try_with_fallback(
325325
326326
When the resolved token comes from a global env var and fails
327327
(e.g. a github.com PAT tried on ``*.ghe.com``), the method
328-
retries with ``git credential fill`` before giving up.
328+
retries with ``gh auth token`` and then ``git credential fill``
329+
before giving up.
329330
"""
330331
auth_ctx = self.resolve(host, org, port=port)
331332
host_info = auth_ctx.host_info
@@ -342,15 +343,22 @@ def _try_credential_fallback(exc: Exception) -> T:
342343
# ADO uses ADO_APM_PAT + AAD bearer fallback; credential fill is out of scope.
343344
if host_info.kind == "ado":
344345
raise exc
345-
_log(
346-
f"Token from {auth_ctx.source} failed, trying git credential fill "
347-
f"for {host_info.display_name}"
348-
)
346+
_log(f"Token from {auth_ctx.source} failed, trying fallback credentials for {host_info.display_name}")
347+
if host_info.kind in ("github", "ghe_cloud", "ghes"):
348+
gh_token = self._token_manager.resolve_credential_from_gh_cli(host_info.host)
349+
if gh_token:
350+
return operation(
351+
gh_token,
352+
self._build_git_env(gh_token, scheme="basic", host_kind=host_info.kind),
353+
)
349354
cred = self._token_manager.resolve_credential_from_git(
350355
host_info.host, port=host_info.port
351356
)
352357
if cred:
353-
return operation(cred, self._build_git_env(cred))
358+
return operation(
359+
cred,
360+
self._build_git_env(cred, scheme="basic", host_kind=host_info.kind),
361+
)
354362
raise exc
355363

356364
# 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
600608
2. Global env vars ``GITHUB_APM_PAT`` -> ``GITHUB_TOKEN`` -> ``GH_TOKEN``
601609
(any host -- if the token is wrong for the target host,
602610
``try_with_fallback`` retries with git credentials)
603-
3. Git credential helper (any host except ADO)
611+
3. gh CLI active account (GitHub-like hosts only)
612+
4. Git credential helper (any host except ADO)
604613
605614
Resolution order (ADO):
606615
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
647656
source = self._identify_env_source(purpose)
648657
return token, source, "basic"
649658

650-
# 3. Git credential helper (not for ADO)
659+
# 3. gh CLI active account (GitHub-like hosts only)
660+
if host_info.kind in ("github", "ghe_cloud", "ghes"):
661+
gh_token = self._token_manager.resolve_credential_from_gh_cli(host_info.host)
662+
if gh_token:
663+
return gh_token, "gh-auth-token", "basic"
664+
665+
# 4. Git credential helper (not for ADO)
651666
if host_info.kind not in ("ado",):
652667
credential = self._token_manager.resolve_credential_from_git(
653668
host_info.host, port=host_info.port

src/apm_cli/core/token_manager.py

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@
1111
- GITHUB_TOKEN: User-scoped PAT for GitHub Models API access
1212
1313
Platform Token Selection:
14-
- GitHub: GITHUB_APM_PAT -> GITHUB_TOKEN -> GH_TOKEN -> git credential helpers
14+
- GitHub: GITHUB_APM_PAT -> GITHUB_TOKEN -> GH_TOKEN -> gh auth token -> git credential helpers
1515
- Azure DevOps: ADO_APM_PAT
1616
1717
Runtime Requirements:
@@ -23,6 +23,13 @@
2323
import sys
2424
from typing import Dict, Optional, Tuple # noqa: F401, UP035
2525

26+
from apm_cli.utils.github_host import (
27+
default_host,
28+
is_azure_devops_hostname,
29+
is_github_hostname,
30+
is_valid_fqdn,
31+
)
32+
2633

2734
def _format_credential_host(host: str, port: int | None) -> str:
2835
"""Embed a custom port into the git credential ``host`` field.
@@ -93,6 +100,24 @@ def _is_valid_credential_token(token: str) -> bool:
93100
return False
94101
return True
95102

103+
@staticmethod
104+
def _supports_gh_cli_host(host: str | None) -> bool:
105+
"""Return True when *host* should use gh CLI fallback."""
106+
if not host:
107+
return False
108+
if is_github_hostname(host):
109+
return True
110+
111+
configured_host = default_host().lower()
112+
host_lower = host.lower()
113+
if host_lower != configured_host:
114+
return False
115+
if configured_host == "github.com" or configured_host.endswith(".ghe.com"):
116+
return False
117+
if is_azure_devops_hostname(configured_host):
118+
return False
119+
return is_valid_fqdn(configured_host)
120+
96121
# `git credential fill` may invoke OS credential helpers that show
97122
# interactive dialogs (e.g. Windows Credential Manager account picker).
98123
# 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
159184
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
160185
return None
161186

187+
@staticmethod
188+
def resolve_credential_from_gh_cli(host: str) -> str | None:
189+
"""Resolve a token from the active gh CLI account for the host.
190+
191+
Uses `gh auth token --hostname <host>` as a non-interactive fallback
192+
before invoking OS credential helpers that may display UI.
193+
"""
194+
try:
195+
result = subprocess.run(
196+
["gh", "auth", "token", "--hostname", host],
197+
capture_output=True,
198+
text=True,
199+
encoding="utf-8",
200+
timeout=GitHubTokenManager._get_credential_timeout(),
201+
env={**os.environ, "GH_PROMPT_DISABLED": "1"},
202+
)
203+
if result.returncode != 0:
204+
return None
205+
206+
token = result.stdout.strip()
207+
if token and GitHubTokenManager._is_valid_credential_token(token):
208+
return token
209+
return None
210+
except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
211+
return None
212+
162213
def setup_environment(self, env: dict[str, str] | None = None) -> dict[str, str]:
163214
"""Set up complete token environment for all runtimes.
164215
@@ -214,9 +265,10 @@ def get_token_with_credential_fallback(
214265
"""Get token for a purpose, falling back to git credential helpers.
215266
216267
Tries environment variables first (via get_token_for_purpose), then
217-
queries the git credential store as a last resort. Results are cached
218-
per ``(host, port)`` to avoid repeated subprocess calls while keeping
219-
same-host-different-port credentials separate.
268+
checks the active gh CLI account, then queries the git credential
269+
store as a last resort. Results are cached per ``(host, port)`` to
270+
avoid repeated subprocess calls while keeping same-host-different-port
271+
credentials separate.
220272
221273
Args:
222274
purpose: Token purpose ('modules', etc.)
@@ -237,6 +289,13 @@ def get_token_with_credential_fallback(
237289
if cache_key in self._credential_cache:
238290
return self._credential_cache[cache_key]
239291

292+
gh_token = None
293+
if self._supports_gh_cli_host(host):
294+
gh_token = self.resolve_credential_from_gh_cli(host)
295+
if gh_token:
296+
self._credential_cache[cache_key] = gh_token
297+
return gh_token
298+
240299
credential = self.resolve_credential_from_git(host, port=port)
241300
self._credential_cache[cache_key] = credential
242301
return credential

tests/test_token_manager.py

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import os
44
import subprocess
5+
import sys
56
from unittest.mock import MagicMock, patch
67

78
import pytest # noqa: F401
@@ -151,7 +152,8 @@ def test_git_askpass_set_to_empty(self):
151152
with patch("subprocess.run", return_value=mock_result) as mock_run:
152153
GitHubTokenManager.resolve_credential_from_git("github.com")
153154
call_env = mock_run.call_args.kwargs["env"]
154-
assert call_env["GIT_ASKPASS"] == ""
155+
expected = "echo" if sys.platform == "win32" else ""
156+
assert call_env["GIT_ASKPASS"] == expected
155157

156158
def test_rejects_password_prompt_as_token(self):
157159
"""Rejects 'Password for ...' prompt text echoed back by GIT_ASKPASS."""
@@ -219,6 +221,32 @@ def test_accepts_valid_gho_token(self):
219221
assert token == "gho_abc123def456"
220222

221223

224+
class TestResolveCredentialFromGhCli:
225+
"""Test resolve_credential_from_gh_cli static method."""
226+
227+
def test_success_returns_token(self):
228+
mock_result = MagicMock(returncode=0, stdout="gho_cli_token\n")
229+
with patch("subprocess.run", return_value=mock_result) as mock_run:
230+
token = GitHubTokenManager.resolve_credential_from_gh_cli("github.com")
231+
assert token == "gho_cli_token"
232+
assert mock_run.call_args.args[0] == ["gh", "auth", "token", "--hostname", "github.com"]
233+
assert mock_run.call_args.kwargs["env"]["GH_PROMPT_DISABLED"] == "1"
234+
235+
def test_nonzero_exit_returns_none(self):
236+
mock_result = MagicMock(returncode=1, stdout="", stderr="not logged in")
237+
with patch("subprocess.run", return_value=mock_result):
238+
assert GitHubTokenManager.resolve_credential_from_gh_cli("github.com") is None
239+
240+
def test_invalid_output_returns_none(self):
241+
mock_result = MagicMock(returncode=0, stdout="Username for 'https://github.com':\n")
242+
with patch("subprocess.run", return_value=mock_result):
243+
assert GitHubTokenManager.resolve_credential_from_gh_cli("github.com") is None
244+
245+
def test_timeout_returns_none(self):
246+
with patch("subprocess.run", side_effect=subprocess.TimeoutExpired(cmd="gh", timeout=5)):
247+
assert GitHubTokenManager.resolve_credential_from_gh_cli("github.com") is None
248+
249+
222250
class TestCredentialTimeout:
223251
"""Tests for configurable git credential fill timeout."""
224252

@@ -298,52 +326,78 @@ def test_returns_env_token_without_credential_fill(self):
298326
"""Returns env var token and never calls credential fill."""
299327
with patch.dict(os.environ, {"GITHUB_APM_PAT": "env-token"}, clear=True):
300328
manager = GitHubTokenManager()
301-
with patch.object(GitHubTokenManager, "resolve_credential_from_git") as mock_cred:
329+
with patch.object(GitHubTokenManager, "resolve_credential_from_gh_cli") as mock_gh, patch.object(
330+
GitHubTokenManager, "resolve_credential_from_git"
331+
) as mock_cred:
302332
token = manager.get_token_with_credential_fallback("modules", "github.com")
303333
assert token == "env-token"
334+
mock_gh.assert_not_called()
335+
mock_cred.assert_not_called()
336+
337+
def test_falls_back_to_gh_cli_before_credential_fill(self):
338+
"""Uses gh CLI before git credential helpers when no env token exists."""
339+
with patch.dict(os.environ, {}, clear=True):
340+
manager = GitHubTokenManager()
341+
with patch.object(
342+
GitHubTokenManager, "resolve_credential_from_gh_cli", return_value="gh-token"
343+
) as mock_gh, patch.object(GitHubTokenManager, "resolve_credential_from_git") as mock_cred:
344+
token = manager.get_token_with_credential_fallback("modules", "github.com")
345+
assert token == "gh-token"
346+
mock_gh.assert_called_once_with("github.com")
304347
mock_cred.assert_not_called()
305348

306349
def test_falls_back_to_credential_fill(self):
307350
"""Falls back to resolve_credential_from_git when no env token."""
308351
with patch.dict(os.environ, {}, clear=True):
309352
manager = GitHubTokenManager()
310353
with patch.object(
354+
GitHubTokenManager, "resolve_credential_from_gh_cli", return_value=None
355+
) as mock_gh, patch.object(
311356
GitHubTokenManager, "resolve_credential_from_git", return_value="cred-token"
312357
) as mock_cred:
313358
token = manager.get_token_with_credential_fallback("modules", "github.com")
314359
assert token == "cred-token"
360+
mock_gh.assert_called_once_with("github.com")
315361
mock_cred.assert_called_once_with("github.com", port=None)
316362

317363
def test_caches_credential_result(self):
318364
"""Second call uses cache, subprocess not invoked again."""
319365
with patch.dict(os.environ, {}, clear=True):
320366
manager = GitHubTokenManager()
321367
with patch.object(
368+
GitHubTokenManager, "resolve_credential_from_gh_cli", return_value=None
369+
) as mock_gh, patch.object(
322370
GitHubTokenManager, "resolve_credential_from_git", return_value="cached-tok"
323371
) as mock_cred:
324372
first = manager.get_token_with_credential_fallback("modules", "github.com")
325373
second = manager.get_token_with_credential_fallback("modules", "github.com")
326374
assert first == second == "cached-tok"
375+
mock_gh.assert_called_once_with("github.com")
327376
mock_cred.assert_called_once()
328377

329378
def test_caches_none_results(self):
330379
"""None results are cached to avoid retrying failed lookups."""
331380
with patch.dict(os.environ, {}, clear=True):
332381
manager = GitHubTokenManager()
333382
with patch.object(
383+
GitHubTokenManager, "resolve_credential_from_gh_cli", return_value=None
384+
) as mock_gh, patch.object(
334385
GitHubTokenManager, "resolve_credential_from_git", return_value=None
335386
) as mock_cred:
336387
first = manager.get_token_with_credential_fallback("modules", "github.com")
337388
second = manager.get_token_with_credential_fallback("modules", "github.com")
338389
assert first is None
339390
assert second is None
391+
mock_gh.assert_called_once_with("github.com")
340392
mock_cred.assert_called_once()
341393

342394
def test_different_hosts_separate_cache(self):
343395
"""Different hosts get independent cache entries."""
344396
with patch.dict(os.environ, {}, clear=True):
345397
manager = GitHubTokenManager()
346398
with patch.object(
399+
GitHubTokenManager, "resolve_credential_from_gh_cli", return_value=None
400+
) as mock_gh, patch.object(
347401
GitHubTokenManager,
348402
"resolve_credential_from_git",
349403
side_effect=lambda h, port=None: f"tok-{h}",
@@ -352,8 +406,21 @@ def test_different_hosts_separate_cache(self):
352406
tok2 = manager.get_token_with_credential_fallback("modules", "gitlab.com")
353407
assert tok1 == "tok-github.com"
354408
assert tok2 == "tok-gitlab.com"
409+
mock_gh.assert_called_once_with("github.com")
355410
assert mock_cred.call_count == 2
356411

412+
def test_non_github_host_skips_gh_cli(self):
413+
"""Generic hosts should not invoke gh CLI fallback."""
414+
with patch.dict(os.environ, {}, clear=True):
415+
manager = GitHubTokenManager()
416+
with patch.object(GitHubTokenManager, "resolve_credential_from_gh_cli") as mock_gh, patch.object(
417+
GitHubTokenManager, "resolve_credential_from_git", return_value="cred-token"
418+
) as mock_cred:
419+
token = manager.get_token_with_credential_fallback("modules", "gitlab.com")
420+
assert token == "cred-token"
421+
mock_gh.assert_not_called()
422+
mock_cred.assert_called_once_with("gitlab.com", port=None)
423+
357424
def test_same_host_different_ports_separate_cache(self):
358425
"""Same host on different ports must not cross-contaminate credentials."""
359426
with patch.dict(os.environ, {}, clear=True):

0 commit comments

Comments
 (0)