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
14 changes: 8 additions & 6 deletions docs/src/content/docs/getting-started/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <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.
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.

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, the only token source is `ADO_APM_PAT`.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 <host>` | 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:
Expand Down
32 changes: 23 additions & 9 deletions src/apm_cli/core/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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:
Comment thread
awakecoding marked this conversation as resolved.
return operation(cred, self._build_git_env(cred))
Expand Down Expand Up @@ -362,17 +371,16 @@ 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:
1. Per-org env var ``GITHUB_APM_PAT_{ORG}`` (any host)
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
Expand All @@ -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:
Expand Down
87 changes: 77 additions & 10 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

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."""
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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",
Expand All @@ -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 <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: Optional[Dict[str, str]] = None) -> Dict[str, str]:
"""Set up complete token environment for all runtimes.
Expand Down Expand Up @@ -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.
Comment thread
awakecoding marked this conversation as resolved.

Args:
purpose: Token purpose ('modules', etc.)
Expand All @@ -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]:
Expand Down
Loading
Loading