fix(auth): thread dep_ref.port into credential resolution (#785)#788
fix(auth): thread dep_ref.port into credential resolution (#785)#788edenfunf wants to merge 1 commit intomicrosoft:mainfrom
Conversation
) DependencyReference.port was parsed and stored but never reached the authentication path, so same-host multi-port setups (e.g. Bitbucket Datacenter with distinct PATs on 7990 and 7991) could collapse onto a single credential entry via the AuthResolver cache. - HostInfo gains a port field and a display_name property for user-facing identifiers; classify_host stays transport-agnostic -- port does not influence host kind. - AuthResolver._cache key widens to (host, port, org). port=None keeps the pre-fix key for the >99% common case; custom-port setups get per-port entries. - TokenManager._credential_cache mirrors the widening, keyed by (host, port). A half-widened cache would return the wrong cached credential at the collapsed layer. - git credential fill sends host=host:port per gitcredentials(7). The credential protocol has no standalone port= attribute; a separate port= line is silently dropped by every helper. - resolve_for_dep threads dep_ref.port; downloader, validation, and sources call sites forward the port argument. - build_error_context surfaces host:port in the failure summary and appends an [i] hint when a port is set, pointing users at credential helpers that key by hostname only. Lockfile identity is unaffected -- DependencyReference.get_unique_key() still excludes port by design; the widened keys are purely in-process memoisation. Refs: microsoft#661, microsoft#665, microsoft#784
|
@danielmeppiel — ready for review when you have a cycle. Scope is the 9-item PR spec from your review-panel comment on #785 (standalone PR, both caches widened, |
| msg = resolver.build_error_context( | ||
| "bitbucket.corp.com", "clone", port=7999 | ||
| ) | ||
| assert "bitbucket.corp.com:7999" in msg |
APM Review Panel — PR #788[+] Verdict: APPROVE — merge as-is, file two follow-ups @edenfunf delivered a faithful 9-for-9 execution on the panel ask from #785. Four specialists (python-architect, auth-expert, cli-logging-expert, devx-ux-expert) converge on mergeable. The only "blocker" surfaced (architect flag on What's excellent
Findings
Merge callShip it. Open follow-ups for #2, #3, #4. @edenfunf — this is the standard for contributor work on a multi-part ask: read the full thread, execute every item, write the tests that prove the contract. Thank you for the precision. [+] Growth angle
Reviewed via the |
There was a problem hiding this comment.
Pull request overview
Threads DependencyReference.port through APM’s auth/credential resolution so same-host multi-port setups don’t collide in in-process caches or git credential fill lookups.
Changes:
- Add
porttoHostInfo, widenAuthResolvercache keys, and propagateportthrough resolve/fallback/error-context paths. - Embed port into
git credential fillashost=host:portand segregateGitHubTokenManagercredential cache by(host, port). - Add/adjust unit tests, update auth docs, and add a changelog entry.
Show a summary per file
| File | Description |
|---|---|
src/apm_cli/core/auth.py |
Adds port-aware HostInfo, port-threading through auth resolution, cache key widening, and port-aware error messaging. |
src/apm_cli/core/token_manager.py |
Embeds port into git-credential host field and keys credential cache by (host, port). |
src/apm_cli/deps/github_downloader.py |
Threads dep_ref.port into auth error-context generation and GitHub file auth resolution. |
src/apm_cli/install/validation.py |
Threads dep_ref.port into auth classification, resolution, fallback, and verbose diagnostics. |
src/apm_cli/install/sources.py |
Threads dep_ref.port into auth resolution used for package auth logging. |
tests/unit/test_auth.py |
Adds coverage for port propagation, cache discrimination, error context display/hints, and fallback behavior. |
tests/test_token_manager.py |
Updates existing assertions and adds tests for (host, port) credential cache + credential-fill stdin format. |
tests/test_github_downloader.py |
Adjusts mocks for new resolve_credential_from_git(host, port=...) signature. |
docs/src/content/docs/getting-started/authentication.md |
Documents port-in-host behavior and helper port-awareness caveats. |
CHANGELOG.md |
Adds an Unreleased “Fixed” entry for port-discriminated token resolution. |
Copilot's findings
- Files reviewed: 10/10 changed files
- Comments generated: 5
| key = ( | ||
| host.lower() if host else host, | ||
| port, | ||
| org.lower() if org else "", | ||
| ) |
There was a problem hiding this comment.
AuthResolver cache key changed for org=None: the old code used org=None in the tuple, but this now normalizes to an empty string. That means port=None does not actually preserve the pre-existing key shape and also collapses org=None with org="". Consider keeping None for missing org (and only lowercasing when org is not None), or update the PR description/comments to match the new behavior.
| Use this wherever user-facing text identifies the host — errors, log | ||
| lines, diagnostic output. Bare ``host`` in those places misleads | ||
| users when port is what actually differentiates the target. | ||
| """ | ||
| return f"{self.host}:{self.port}" if self.port else self.host |
There was a problem hiding this comment.
HostInfo.display_name uses truthiness (if self.port) to decide whether to include the port. For correctness and consistency with other checks in this file (host_info.port is not None), consider using an explicit is not None check so port values like 0 (or other falsey ints) are not silently treated as absent.
| ``host:port``. Sending a separate ``port=`` line is silently ignored by | ||
| helpers, collapsing two different services into one credential entry. | ||
| """ | ||
| return f"{host}:{port}" if port else host |
There was a problem hiding this comment.
_format_credential_host() uses if port which treats falsey integers as absent. Using port is not None is safer and matches the rest of the port-handling logic (where None is the sentinel for "no port").
| return f"{host}:{port}" if port else host | |
| return f"{host}:{port}" if port is not None else host |
| #### Custom-port hosts and per-port credentials | ||
|
|
||
| For self-hosted Git instances on non-standard ports (e.g. Bitbucket Datacenter on port 7999), APM sends `host=<host>:<port>` to `git credential fill` per the [`gitcredentials(7)`](https://git-scm.com/docs/gitcredentials) protocol. Whether distinct credentials are returned for different ports depends on the helper: | ||
|
|
||
| | Helper | Honors port-in-host? | | ||
| |---|---| | ||
| | git-credential-manager (GCM) | Yes | | ||
| | macOS Keychain (`osxkeychain`) | Yes (stores full `host:port` as key) | | ||
| | `libsecret` (Linux) | Yes (port in URI) | | ||
| | `gh auth git-credential` | No -- but only used for GitHub hosts, which do not use custom ports | | ||
|
|
||
| If APM resolves the wrong credential for a custom-port host, confirm your helper keys by `host:port`; otherwise either switch helpers or store credentials under fully qualified `https://<host>:<port>/` URLs. |
There was a problem hiding this comment.
This doc update changes user guidance around git credential helpers for custom-port hosts. Per the repo docs workflow, please mirror this information (at least the host:port behavior / helper caveat) into packages/apm-guide/.apm/skills/apm-usage/authentication.md so the in-repo APM usage skill stays consistent with the public docs.
|
@edenfunf check Copilot Review and CodeQL scan alert and I will approve after that's done, thanks! |
Description
Closes #785. Follow-up from the #665 review panel's auth-expert finding and the design discussion on the issue thread.
DependencyReference.portwas parsed and stored by #665 but never reached the authentication path. Same-host multi-port setups (e.g. Bitbucket Datacenter with distinct PATs on 7990 and 7991) therefore collapsed onto a single credential entry via theAuthResolvercache, causing APM's pre-resolved token path (Method 1: authenticated HTTPS) to return the wrong credential.The fix threads
portthrough every layer that identifies a credential target:HostInfo, both in-process caches,git credential fillstdin, and user-facing error output.What changed
HostInfogains aport: Optional[int] = Nonefield and adisplay_nameproperty ("host:port" if port else host). Classification stays transport-agnostic --classify_hostcarries the port through but never uses it to decidekind.AuthResolver._cachekey widens to(host.lower(), port, org.lower() if org else "").port=Nonepreserves the pre-fix key for the >99% common case; multi-port setups get per-port entries. Lock semantics unchanged.TokenManager._credential_cacheis keyed by(host, port)tuple. Both caches widen together -- a half-widened cache would return the wrong cached credential at the collapsed layer.git credential fillreceiveshost=host:portpergitcredentials(7). The credential protocol has no standaloneport=attribute; sending one would be silently ignored by every helper. The_format_credential_hosthelper and an anti-pattern test guard against this regression.resolve_for_depthreadsdep_ref.port;github_downloader,validation, andsourcescall sites forward the port argument.build_error_contextuseshost_info.display_namein the failure summary and, when a port is set, appends one[i]hint pointing users at credential helpers that key by hostname only.docs/getting-started/authentication.mdgains a per-helper port-awareness table under the "Git credential helper not found" section.[Unreleased]/Fixed.Lockfile identity is unaffected --
DependencyReference.get_unique_key()still excludes port by design; the widened cache keys are purely in-process memoisation.Type of change
Testing
New test coverage (23 cases)
TestHostInfoPort-- field,display_name,classify_hostport attachment + transport-agnostic invariant.TestResolvePortDiscrimination-- same host / different port → separate entries; same(host, port, org)hits cache;port=Nonevsport=<int>are distinct;resolve_for_depthreads port end-to-end for bothssh://andhttps://URLs;HostInfocarries port on resolve.TestBuildErrorContextWithPort--display_namein error;[i]hint appears when port is set; no hint when port is absent.TestTryWithFallbackWithPort-- port flows into the credential-fill fallback path.TestCredentialFillPortEmbedding--host=host:portstdin format; no standaloneport=line (anti-pattern guard); backward-compat with bare host whenport=None.TestGetTokenWithCredentialFallbackgains same-host-different-port + same-host-same-port cache tests.Manual end-to-end verification
Ran a scripted check covering: ssh/https port capture,
resolve_for_deppropagation, cache-hit behaviour, git credential fill stdin format,TokenManagercache segmentation, and error-context hint presence/absence. All six scenarios pass.Notes for reviewers
port=line ingit credential fillstdin) is not implemented -- the port is embedded into thehostfield instead, pergitcredentials(7). An anti-pattern regression test asserts no\nport=ever appears in the stdin.classify_hostacceptsportas a kwarg and attaches it to the returnedHostInfo. Classification logic itself remains transport-agnostic as per the architect's note; the kwarg is purely metadata.policy/discovery.py,marketplace/client.py,registry_proxy.py) are intentionally left alone -- they either do not parse adep_ref(policy discovery) or only usehost_info.kind/api_basefor classification, where port is irrelevant.Refs: #661, #665, #784