Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 19 additions & 14 deletions docs/src/content/docs/getting-started/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>` (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.

Expand All @@ -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 <host>` | 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.

Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>` | 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
Expand Down
31 changes: 23 additions & 8 deletions src/apm_cli/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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:
Comment thread
awakecoding marked this conversation as resolved.
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)
Expand Down Expand Up @@ -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"``
Expand Down Expand Up @@ -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
Expand Down
67 changes: 63 additions & 4 deletions src/apm_cli/core/token_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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 <host>` 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.

Expand Down Expand Up @@ -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.)
Expand All @@ -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
Expand Down
71 changes: 69 additions & 2 deletions tests/test_token_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import os
import subprocess
import sys
from unittest.mock import MagicMock, patch

import pytest # noqa: F401
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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."""

Expand Down Expand Up @@ -298,52 +326,78 @@ 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."""
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):
"""Second call uses cache, subprocess not invoked again."""
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):
"""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_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_gh_cli", return_value=None
) as mock_gh, patch.object(
GitHubTokenManager,
"resolve_credential_from_git",
side_effect=lambda h, port=None: f"tok-{h}",
Expand All @@ -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):
Expand Down
Loading