Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
10 changes: 6 additions & 4 deletions docs/src/content/docs/getting-started/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,14 @@ APM works without tokens for public packages on github.com. Authentication is ne

## How APM resolves authentication

APM resolves tokens per `(host, org)` pair. For each dependency, it walks a resolution chain until it finds a token:
APM resolves tokens per `(host, org)` pair, and includes repo-path context when available for credential-helper lookups. 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)
3. **GitHub CLI active account** — `gh auth token --hostname <host>` (GitHub-like hosts)
4. **Git credential helper** — `git credential fill` with repo-path context when available (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. When APM knows the repository URL, it includes the repo path in the helper query to reduce ambiguous multi-account prompts on hosts like github.com. 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, the only token source is `ADO_APM_PAT`.

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 and repo-path | System credential manager |

When APM knows the repository URL, it includes the repo path in the credential-helper query. APM also 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
2 changes: 2 additions & 0 deletions src/apm_cli/commands/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -500,6 +500,7 @@ def _check_repo(token, git_env):
return auth_resolver.try_with_fallback(
host, _check_repo,
org=org,
repo_path=f"{dep_ref.repo_url}.git",
unauth_first=True,
verbose_callback=verbose_log,
)
Expand Down Expand Up @@ -549,6 +550,7 @@ def _check_repo_fallback(token, git_env):
return auth_resolver.try_with_fallback(
host, _check_repo_fallback,
org=org,
repo_path=f"{repo_path}.git",
unauth_first=True,
verbose_callback=verbose_log,
)
Expand Down
48 changes: 36 additions & 12 deletions src/apm_cli/core/auth.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
"""Centralized authentication resolution for APM CLI.

Every APM operation that touches a remote host MUST use AuthResolver.
Resolution is per-(host, org) pair, thread-safe, and cached per-process.
Resolution is per-(host, org, repo-path) tuple when repo context is known,
thread-safe, and cached per-process.

All token-bearing requests use HTTPS — that is the transport security
boundary. Global env vars are tried for every host; if the token is
Expand Down Expand Up @@ -178,9 +179,18 @@ 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."""
key = (host.lower() if host else host, org.lower() if org else org)
def resolve(
self,
host: str,
org: Optional[str] = None,
repo_path: Optional[str] = None,
) -> AuthContext:
"""Resolve auth for *(host, org, repo_path)*. Cached & thread-safe."""
key = (
host.lower() if host else host,
org.lower() if org else org,
repo_path,
)
with self._lock:
cached = self._cache.get(key)
if cached is not None:
Expand All @@ -193,7 +203,7 @@ def resolve(self, host: str, org: Optional[str] = None) -> AuthContext:
# Bounded by APM_GIT_CREDENTIAL_TIMEOUT (default 60s). No deadlock
# risk: single lock, never nested.
host_info = self.classify_host(host)
token, source = self._resolve_token(host_info, org)
token, source = self._resolve_token(host_info, org, repo_path=repo_path)
token_type = self.detect_token_type(token) if token else "unknown"
git_env = self._build_git_env(token)

Expand All @@ -211,11 +221,13 @@ def resolve_for_dep(self, dep_ref: "DependencyReference") -> AuthContext:
"""Resolve auth from a ``DependencyReference``."""
host = dep_ref.host or default_host()
org: Optional[str] = None
repo_path: Optional[str] = None
if dep_ref.repo_url:
parts = dep_ref.repo_url.split("/")
if parts:
org = parts[0]
return self.resolve(host, org)
repo_path = f"{dep_ref.repo_url}.git"
return self.resolve(host, org, repo_path=repo_path)

# -- fallback strategy --------------------------------------------------

Expand All @@ -225,6 +237,7 @@ def try_with_fallback(
operation: Callable[..., T],
*,
org: Optional[str] = None,
repo_path: Optional[str] = None,
unauth_first: bool = False,
verbose_callback: Optional[Callable[[str], None]] = None,
) -> T:
Expand All @@ -247,7 +260,7 @@ def try_with_fallback(
(e.g. a github.com PAT tried on ``*.ghe.com``), the method
retries with ``git credential fill`` before giving up.
"""
auth_ctx = self.resolve(host, org)
auth_ctx = self.resolve(host, org, repo_path=repo_path)
host_info = auth_ctx.host_info
git_env = auth_ctx.git_env

Expand All @@ -261,8 +274,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}")
cred = self._token_manager.resolve_credential_from_git(host)
_log(f"Token from {auth_ctx.source} failed, trying fallback credentials for {host}")
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, path=repo_path)
if cred:
return operation(cred, self._build_git_env(cred))
raise exc
Expand Down Expand Up @@ -353,7 +369,7 @@ def build_error_context(
# -- internals ----------------------------------------------------------

def _resolve_token(
self, host_info: HostInfo, org: Optional[str]
self, host_info: HostInfo, org: Optional[str], repo_path: Optional[str] = None
) -> tuple[Optional[str], str]:
"""Walk the token resolution chain. Returns (token, source).

Expand Down Expand Up @@ -382,9 +398,17 @@ 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)
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)
credential = self._token_manager.resolve_credential_from_git(
host_info.host,
path=repo_path,
)
if credential:
return credential, "git-credential-fill"

Expand Down
80 changes: 69 additions & 11 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 Down Expand Up @@ -92,7 +92,11 @@ def _get_credential_timeout(cls) -> int:
return max(1, min(val, cls.MAX_CREDENTIAL_TIMEOUT))

@staticmethod
def resolve_credential_from_git(host: str) -> Optional[str]:
def resolve_credential_from_git(
host: str,
path: Optional[str] = None,
username: Optional[str] = None,
) -> Optional[str]:
"""Resolve a credential from the git credential store.

Uses `git credential fill` to query the user's configured credential
Expand All @@ -101,14 +105,26 @@ def resolve_credential_from_git(host: str) -> Optional[str]:

Args:
host: The git host to resolve credentials for (e.g., "github.com")
path: Optional repository path (e.g., "owner/repo.git") used to
disambiguate multi-account credential helpers.
username: Optional username hint for credential helpers.

Returns:
The password/token from the credential store, or None if unavailable
"""
try:
request_lines = ['protocol=https', f'host={host}']
if path:
normalized_path = path.lstrip('/')
if normalized_path:
request_lines.append(f'path={normalized_path}')
if username:
request_lines.append(f'username={username}')
request = '\n'.join(request_lines) + '\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 +144,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,16 +215,26 @@ 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,
path: Optional[str] = None,
username: Optional[str] = None,
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.)
host: Git host to resolve credentials for (e.g., "github.com")
path: Optional repository path for credential-helper disambiguation
username: Optional username hint for credential helpers
env: Environment to check (defaults to os.environ)

Returns:
Expand All @@ -192,11 +244,17 @@ 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, path or '', username or '')
if cache_key in self._credential_cache:
return self._credential_cache[cache_key]

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
credential = self.resolve_credential_from_git(host, path=path, username=username)
self._credential_cache[cache_key] = credential
return credential

def validate_tokens(self, env: Optional[Dict[str, str]] = None) -> Tuple[bool, str]:
Expand Down
1 change: 1 addition & 0 deletions src/apm_cli/marketplace/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ def _do_fetch(token, _git_env):
source.host,
_do_fetch,
org=source.owner,
repo_path=f"{source.owner}/{source.repo}.git",
unauth_first=True,
)
except Exception as exc:
Expand Down
Loading
Loading