Skip to content

Commit 9ec0f43

Browse files
frblondindanielmeppielCopilot
authored
fix: share AuthResolver across install to prevent duplicate auth popups (#424)
* fix: share AuthResolver across install to prevent duplicate auth popups Create a single AuthResolver at the top of the install command and thread it through _validate_and_add_packages_to_apm_yml, _validate_package_exists, and _install_apm_dependencies so that all validation and download steps within one CLI invocation share the same credential cache. Also fixes a lock inversion in AuthResolver.resolve() that allowed two concurrent callers for the same (host, org) key to each trigger their own credential-helper lookup before either result was cached. * Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> * fix: move AuthResolver import to improve clarity and prevent potential issues * fix: remove accidentally tracked dist.old build artifact The dist.old/apm-windows-x86_64.sha256 checksum file was an accidental build artifact committed to the repo. Nothing in the codebase references dist.old/, and dist/ is already in .gitignore. * fix: reuse shared auth_resolver for verbose auth logging in install The verbose auth logging block inside _install_apm_dependencies was creating a fresh AuthResolver() instance instead of reusing the shared resolver passed to the function. This meant the resolved credentials were not cached, potentially triggering duplicate credential-helper popups when the resolver was used for logging after downloads. Replace the per-verbose-block AuthResolver() construction with direct use of the auth_resolver parameter already available in scope. * fix: resolve per-dep auth before building GHES/ADO validation URL In _validate_package_exists, the URL for git ls-remote was built via _build_repo_url() without a token for GHES and ADO hosts. Since the downloader constructor no longer resolves credentials eagerly (to prevent duplicate auth popups), self.github_token / self.ado_token are now env-only. Credential-helper-only users on GHES/ADO would therefore receive a tokenless ls-remote URL and see an OS credential prompt -- reintroducing exactly the problem this PR targets. Fix: call auth_resolver.resolve_for_dep(dep_ref) for non-generic (GHES/ADO) hosts before building the URL, and pass the resolved token to _build_repo_url(token=...). Generic hosts are excluded because they rely on git credential helpers through the relaxed validate_env, not on APM-managed auth. * fix: use resolved dep_token in clone error handler for credential-helper users The error handler in _clone_with_fallback used self.has_github_token to determine whether to show an 'authentication not configured' message. Since the constructor no longer calls auth_resolver.resolve() eagerly, self.has_github_token reflects env-var tokens only. Credential-helper- only users (gh auth login, macOS Keychain, etc.) have has_github_token=False even when credentials are available via helper, causing a misleading 'set up authentication' error when the actual cause is a permissions or repo-not-found issue. Switch to has_token, which is derived from dep_ctx.token (the result of per-dependency resolve_for_dep(), including credential-helper tokens). When has_token is True but clone still fails, the error correctly falls through to the generic 'check permissions' message. When has_token is False (truly no auth configured), the auth setup guidance is shown. * docs: clarify why AuthResolver is imported outside APM_DEPS_AVAILABLE guard AuthResolver has no optional dependencies (stdlib + internal utils only). Add comments explaining it must be imported unconditionally, not inside the APM_DEPS_AVAILABLE try/except block: if an optional dep like GitPython is missing, a gated import would produce a NameError inside install() before the graceful APM_DEPS_AVAILABLE guard can produce a useful error. * docs: document lock hold trade-off in AuthResolver.resolve() Add a comment explaining why the lock is held for the full duration of credential resolution, not just for the cache check/write. Key points: - Prevents duplicate OS credential-helper popups under parallel downloads - First caller fills cache; subsequent same-key calls are O(1) hits - Bounded by APM_GIT_CREDENTIAL_TIMEOUT (default 60s) - No deadlock risk: single lock, never nested * refactor: remove unused _default_github_ctx field from GitHubPackageDownloader The field was assigned in _setup_git_environment() but never read anywhere in the codebase. Remove it to eliminate dead code and reduce confusion about its purpose. --------- Co-authored-by: Daniel Meppiel <51440732+danielmeppiel@users.noreply.github.com> Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
1 parent 783845a commit 9ec0f43

5 files changed

Lines changed: 120 additions & 56 deletions

File tree

src/apm_cli/commands/install.py

Lines changed: 52 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,12 @@
3535
list = builtins.list
3636
dict = builtins.dict
3737

38+
# AuthResolver has no optional deps (stdlib + internal utils only), so it must
39+
# be imported unconditionally here -- NOT inside the APM_DEPS_AVAILABLE guard.
40+
# If it were gated, a missing optional dep (e.g. GitPython) would cause a
41+
# NameError in install() before the graceful APM_DEPS_AVAILABLE check fires.
42+
from ..core.auth import AuthResolver
43+
3844
# APM Dependencies (conditional import for graceful degradation)
3945
APM_DEPS_AVAILABLE = False
4046
_APM_IMPORT_ERROR = None
@@ -56,7 +62,7 @@
5662
# ---------------------------------------------------------------------------
5763

5864

59-
def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, logger=None):
65+
def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, logger=None, auth_resolver=None):
6066
"""Validate packages exist and can be accessed, then add to apm.yml dependencies section.
6167
6268
Implements normalize-on-write: any input form (HTTPS URL, SSH URL, FQDN, shorthand)
@@ -68,6 +74,7 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
6874
dry_run: If True, only show what would be added.
6975
dev: If True, write to devDependencies instead of dependencies.
7076
logger: InstallLogger for structured output.
77+
auth_resolver: Shared auth resolver for caching credentials.
7178
7279
Returns:
7380
Tuple of (validated_packages list, _ValidationOutcome).
@@ -146,7 +153,7 @@ def _validate_and_add_packages_to_apm_yml(packages, dry_run=False, dev=False, lo
146153

147154
# Validate package exists and is accessible
148155
verbose = bool(logger and logger.verbose)
149-
if _validate_package_exists(package, verbose=verbose):
156+
if _validate_package_exists(package, verbose=verbose, auth_resolver=auth_resolver):
150157
valid_outcomes.append((canonical, already_in_deps))
151158
if logger:
152159
logger.validation_pass(canonical, already_present=already_in_deps)
@@ -258,15 +265,17 @@ def _local_path_no_markers_hint(local_dir, verbose_log=None):
258265
_rich_echo(f" ... and {len(found) - 5} more", color="dim")
259266

260267

261-
def _validate_package_exists(package, verbose=False):
268+
def _validate_package_exists(package, verbose=False, auth_resolver=None):
262269
"""Validate that a package exists and is accessible on GitHub, Azure DevOps, or locally."""
263270
import os
264271
import subprocess
265272
import tempfile
266273
from apm_cli.core.auth import AuthResolver
267274

268275
verbose_log = (lambda msg: _rich_echo(f" {msg}", color="dim")) if verbose else None
269-
auth_resolver = AuthResolver()
276+
# Use provided resolver or create new one if not in a CLI session context
277+
if auth_resolver is None:
278+
auth_resolver = AuthResolver()
270279

271280
try:
272281
# Parse the package to check if it's a virtual package or ADO
@@ -300,8 +309,8 @@ def _validate_package_exists(package, verbose=False):
300309
org = dep_ref.repo_url.split('/')[0] if dep_ref.repo_url and '/' in dep_ref.repo_url else None
301310
if verbose_log:
302311
verbose_log(f"Auth resolved: host={host}, org={org}, source={ctx.source}, type={ctx.token_type}")
303-
downloader = GitHubPackageDownloader(auth_resolver=auth_resolver)
304-
result = downloader.validate_virtual_package_exists(dep_ref)
312+
virtual_downloader = GitHubPackageDownloader(auth_resolver=auth_resolver)
313+
result = virtual_downloader.validate_virtual_package_exists(dep_ref)
305314
if not result and verbose_log:
306315
try:
307316
err_ctx = auth_resolver.build_error_context(host, f"accessing {package}", org=org)
@@ -316,26 +325,39 @@ def _validate_package_exists(package, verbose=False):
316325
if dep_ref.is_azure_devops() or (dep_ref.host and dep_ref.host != "github.com"):
317326
from apm_cli.utils.github_host import is_github_hostname, is_azure_devops_hostname
318327

319-
downloader = GitHubPackageDownloader()
328+
# Determine host type before building the URL so we know whether to
329+
# embed a token. Generic (non-GitHub, non-ADO) hosts are excluded
330+
# from APM-managed auth; they rely on git credential helpers via the
331+
# relaxed validate_env below.
332+
is_generic = not is_github_hostname(dep_ref.host) and not is_azure_devops_hostname(dep_ref.host)
333+
334+
# For GHES / ADO: resolve per-dependency auth up front so the URL
335+
# carries an embedded token and avoids triggering OS credential
336+
# helper popups during git ls-remote validation.
337+
_url_token = None
338+
if not is_generic:
339+
_dep_ctx = auth_resolver.resolve_for_dep(dep_ref)
340+
_url_token = _dep_ctx.token
341+
342+
ado_downloader = GitHubPackageDownloader(auth_resolver=auth_resolver)
320343
# Set the host
321344
if dep_ref.host:
322-
downloader.github_host = dep_ref.host
345+
ado_downloader.github_host = dep_ref.host
323346

324-
# Build authenticated URL using downloader's auth
325-
package_url = downloader._build_repo_url(
326-
dep_ref.repo_url, use_ssh=False, dep_ref=dep_ref
347+
# Build authenticated URL using the resolved per-dep token.
348+
package_url = ado_downloader._build_repo_url(
349+
dep_ref.repo_url, use_ssh=False, dep_ref=dep_ref, token=_url_token
327350
)
328351

329352
# For generic hosts (not GitHub, not ADO), relax the env so native
330353
# credential helpers (SSH keys, macOS Keychain, etc.) can work.
331354
# This mirrors _clone_with_fallback() which does the same relaxation.
332-
is_generic = not is_github_hostname(dep_ref.host) and not is_azure_devops_hostname(dep_ref.host)
333355
if is_generic:
334-
validate_env = {k: v for k, v in downloader.git_env.items()
356+
validate_env = {k: v for k, v in ado_downloader.git_env.items()
335357
if k not in ('GIT_ASKPASS', 'GIT_CONFIG_GLOBAL', 'GIT_CONFIG_NOSYSTEM')}
336358
validate_env['GIT_TERMINAL_PROMPT'] = '0'
337359
else:
338-
validate_env = {**os.environ, **downloader.git_env}
360+
validate_env = {**os.environ, **ado_downloader.git_env}
339361

340362
if verbose_log:
341363
verbose_log(f"Trying git ls-remote for {dep_ref.host}")
@@ -550,6 +572,10 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
550572
is_partial = bool(packages)
551573
logger = InstallLogger(verbose=verbose, dry_run=dry_run, partial=is_partial)
552574

575+
# Create shared auth resolver for all downloads in this CLI invocation
576+
# to ensure credentials are cached and reused (prevents duplicate auth popups)
577+
auth_resolver = AuthResolver()
578+
553579
# Check if apm.yml exists
554580
apm_yml_exists = Path(APM_YML_FILENAME).exists()
555581

@@ -571,7 +597,7 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
571597
# If packages are specified, validate and add them to apm.yml first
572598
if packages:
573599
validated_packages, outcome = _validate_and_add_packages_to_apm_yml(
574-
packages, dry_run, dev=dev, logger=logger,
600+
packages, dry_run, dev=dev, logger=logger, auth_resolver=auth_resolver,
575601
)
576602
# Short-circuit: all packages failed validation — nothing to install
577603
if outcome.all_failed:
@@ -672,6 +698,7 @@ def install(ctx, packages, runtime, exclude, only, update, dry_run, force, verbo
672698
apm_package, update, verbose, only_pkgs, force=force,
673699
parallel_downloads=parallel_downloads,
674700
logger=logger,
701+
auth_resolver=auth_resolver,
675702
target=target,
676703
)
677704
apm_count = install_result.installed_count
@@ -1092,6 +1119,7 @@ def _install_apm_dependencies(
10921119
force: bool = False,
10931120
parallel_downloads: int = 4,
10941121
logger: "InstallLogger" = None,
1122+
auth_resolver: "AuthResolver" = None,
10951123
target: str = None,
10961124
):
10971125
"""Install APM package dependencies.
@@ -1104,6 +1132,7 @@ def _install_apm_dependencies(
11041132
force: Whether to overwrite locally-authored files on collision
11051133
parallel_downloads: Max concurrent downloads (0 disables parallelism)
11061134
logger: InstallLogger for structured output
1135+
auth_resolver: Shared auth resolver for caching credentials
11071136
target: Explicit target override from --target CLI flag
11081137
"""
11091138
if not APM_DEPS_AVAILABLE:
@@ -1137,8 +1166,12 @@ def _install_apm_dependencies(
11371166
apm_modules_dir = project_root / APM_MODULES_DIR
11381167
apm_modules_dir.mkdir(exist_ok=True)
11391168

1169+
# Use provided resolver or create new one if not in a CLI session context
1170+
if auth_resolver is None:
1171+
auth_resolver = AuthResolver()
1172+
11401173
# Create downloader early so it can be used for transitive dependency resolution
1141-
downloader = GitHubPackageDownloader()
1174+
downloader = GitHubPackageDownloader(auth_resolver=auth_resolver)
11421175

11431176
# Track direct dependency keys so the download callback can distinguish them from transitive
11441177
direct_dep_keys = builtins.set(dep.get_unique_key() for dep in apm_deps)
@@ -1499,13 +1532,9 @@ def _collect_descendants(node, visited=None):
14991532
_pre_downloaded_keys = builtins.set(_pre_download_results.keys())
15001533

15011534
# Create progress display for sequential integration
1502-
_auth_resolver = None
1503-
if verbose:
1504-
try:
1505-
from apm_cli.core.auth import AuthResolver
1506-
_auth_resolver = AuthResolver()
1507-
except Exception:
1508-
pass
1535+
# Reuse the shared auth_resolver (already created in this invocation) so
1536+
# verbose auth logging does not trigger a duplicate credential-helper popup.
1537+
_auth_resolver = auth_resolver
15091538

15101539
with Progress(
15111540
SpinnerColumn(),

src/apm_cli/core/auth.py

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -182,25 +182,30 @@ def resolve(self, host: str, org: Optional[str] = None) -> AuthContext:
182182
"""Resolve auth for *(host, org)*. Cached & thread-safe."""
183183
key = (host.lower() if host else host, org.lower() if org else org)
184184
with self._lock:
185-
if key in self._cache:
186-
return self._cache[key]
187-
188-
host_info = self.classify_host(host)
189-
token, source = self._resolve_token(host_info, org)
190-
token_type = self.detect_token_type(token) if token else "unknown"
191-
git_env = self._build_git_env(token)
192-
193-
ctx = AuthContext(
194-
token=token,
195-
source=source,
196-
token_type=token_type,
197-
host_info=host_info,
198-
git_env=git_env,
199-
)
200-
201-
with self._lock:
185+
cached = self._cache.get(key)
186+
if cached is not None:
187+
return cached
188+
189+
# Hold lock during entire credential resolution to prevent duplicate
190+
# credential-helper popups when parallel downloads resolve the same
191+
# (host, org) concurrently. The first caller fills the cache; all
192+
# subsequent callers for the same key become O(1) cache hits.
193+
# Bounded by APM_GIT_CREDENTIAL_TIMEOUT (default 60s). No deadlock
194+
# risk: single lock, never nested.
195+
host_info = self.classify_host(host)
196+
token, source = self._resolve_token(host_info, org)
197+
token_type = self.detect_token_type(token) if token else "unknown"
198+
git_env = self._build_git_env(token)
199+
200+
ctx = AuthContext(
201+
token=token,
202+
source=source,
203+
token_type=token_type,
204+
host_info=host_info,
205+
git_env=git_env,
206+
)
202207
self._cache[key] = ctx
203-
return ctx
208+
return ctx
204209

205210
def resolve_for_dep(self, dep_ref: "DependencyReference") -> AuthContext:
206211
"""Resolve auth from a ``DependencyReference``."""

src/apm_cli/deps/github_downloader.py

Lines changed: 15 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -186,20 +186,18 @@ def _setup_git_environment(self) -> Dict[str, Any]:
186186
else:
187187
env['GIT_CONFIG_GLOBAL'] = '/dev/null'
188188

189-
# Resolve default host tokens via AuthResolver (backward compat properties)
190-
default_ctx = self.auth_resolver.resolve(default_host())
191-
self._default_github_ctx = default_ctx
192-
self.github_token = default_ctx.token
193-
self.has_github_token = default_ctx.token is not None
194-
self._github_token_from_credential_fill = (
195-
self.has_github_token
196-
and self.token_manager.get_token_for_purpose('modules', env) is None
197-
)
198-
199-
# Azure DevOps
200-
ado_ctx = self.auth_resolver.resolve("dev.azure.com")
201-
self.ado_token = ado_ctx.token
202-
self.has_ado_token = ado_ctx.token is not None
189+
# IMPORTANT: Do not resolve credentials via helpers at construction time.
190+
# AuthResolver.resolve(...) can trigger OS credential helper UI. If we do
191+
# this eagerly (host-only key) and later resolve per-dependency (host+org),
192+
# users can see duplicate auth prompts. Keep constructor token state env-only
193+
# and resolve lazily per dependency during clone/validate flows.
194+
self.github_token = self.token_manager.get_token_for_purpose('modules', env)
195+
self.has_github_token = self.github_token is not None
196+
self._github_token_from_credential_fill = False
197+
198+
# Azure DevOps (env-only at init; lazy auth resolution happens per dep)
199+
self.ado_token = self.token_manager.get_token_for_purpose('ado_modules', env)
200+
self.has_ado_token = self.ado_token is not None
203201

204202
# JFrog Artifactory (not host-based, uses dedicated env var)
205203
self.artifactory_token = self.token_manager.get_token_for_purpose('artifactory_modules', env)
@@ -648,7 +646,9 @@ def _clone_with_fallback(self, repo_url_base: str, target_path: Path, progress_r
648646
f"If this package lives on a different server (e.g., github.com), "
649647
f"use the full hostname in apm.yml: {suggested}"
650648
)
651-
elif not self.has_github_token:
649+
elif not has_token:
650+
# No auth was resolved (neither env var nor credential helper).
651+
# Guide the user through setting up authentication.
652652
host = dep_host or default_host()
653653
org = dep_ref.repo_url.split('/')[0] if dep_ref and dep_ref.repo_url else None
654654
error_msg += self.auth_resolver.build_error_context(host, "clone", org=org)

tests/test_github_downloader.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,15 @@ def test_setup_git_environment_no_token(self):
6969
# Should not have GitHub tokens in environment
7070
assert 'GITHUB_TOKEN' not in env or not env['GITHUB_TOKEN']
7171
assert 'GH_TOKEN' not in env or not env['GH_TOKEN']
72+
73+
def test_setup_git_environment_does_not_eagerly_call_credential_helper(self):
74+
"""Constructor should not invoke git credential helper (lazy per-dep auth)."""
75+
with patch.dict(os.environ, {}, clear=True):
76+
with patch(
77+
'apm_cli.core.token_manager.GitHubTokenManager.resolve_credential_from_git'
78+
) as mock_cred:
79+
GitHubPackageDownloader()
80+
mock_cred.assert_not_called()
7281

7382
@patch('apm_cli.deps.github_downloader.Repo')
7483
@patch('tempfile.mkdtemp')

tests/unit/test_auth.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
"""Unit tests for AuthResolver, HostInfo, and AuthContext."""
22

33
import os
4+
import time
5+
from concurrent.futures import ThreadPoolExecutor
46
from unittest.mock import patch
57

68
import pytest
@@ -134,6 +136,25 @@ def test_caching(self):
134136
ctx2 = resolver.resolve("github.com", org="microsoft")
135137
assert ctx1 is ctx2
136138

139+
def test_caching_is_singleflight_under_concurrency(self):
140+
"""Concurrent resolve() calls for the same key should populate cache once."""
141+
resolver = AuthResolver()
142+
143+
def _slow_resolve_token(host_info, org):
144+
time.sleep(0.05)
145+
return ("cred-token", "git-credential-fill")
146+
147+
with patch.object(AuthResolver, "_resolve_token", side_effect=_slow_resolve_token) as mock_resolve:
148+
with ThreadPoolExecutor(max_workers=8) as pool:
149+
futures = [
150+
pool.submit(resolver.resolve, "github.com", "microsoft")
151+
for _ in range(8)
152+
]
153+
contexts = [f.result() for f in futures]
154+
155+
assert mock_resolve.call_count == 1
156+
assert all(ctx is contexts[0] for ctx in contexts)
157+
137158
def test_different_orgs_different_cache(self):
138159
"""Different orgs get different cache entries."""
139160
with patch.dict(os.environ, {

0 commit comments

Comments
 (0)