Skip to content
Open
Show file tree
Hide file tree
Changes from 3 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
16 changes: 9 additions & 7 deletions docs/src/content/docs/getting-started/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,15 +8,16 @@ 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, repo_path)` tuple when repo context is known. It also 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)
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` 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.
Results are cached per-process -- the same `(host, org, repo_path)` tuple 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 and repo-path | System credential manager |
| -- | None | -- | Unauthenticated (public GitHub repos only) |

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.

## Per-org setup

Use per-org tokens when accessing packages across multiple organizations:
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
59 changes: 44 additions & 15 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 @@ -85,7 +86,8 @@ class AuthResolver:
"""Single source of truth for auth resolution.

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

def __init__(self, token_manager: Optional[GitHubTokenManager] = None):
Expand Down Expand Up @@ -178,9 +180,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 +204,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 +222,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 +238,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 @@ -245,9 +259,10 @@ 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)
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 +276,12 @@ 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}")
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, path=repo_path)
if cred:
Comment thread
awakecoding marked this conversation as resolved.
return operation(cred, self._build_git_env(cred))
raise exc
Expand Down Expand Up @@ -353,7 +372,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 All @@ -362,7 +381,8 @@ def _resolve_token(
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 @@ -382,9 +402,18 @@ 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)
credential = self._token_manager.resolve_credential_from_git(
host_info.host,
path=repo_path,
)
if credential:
return credential, "git-credential-fill"

Expand Down
Loading
Loading