Skip to content

feat(ENG-13680): add a NuGet credential provider - #494

Draft
cloudsmith-iduffy wants to merge 14 commits into
masterfrom
iduffy/nuget-credential-provider
Draft

cloudsmith-iduffy wants to merge 14 commits into
masterfrom
iduffy/nuget-credential-provider

Conversation

@cloudsmith-iduffy

@cloudsmith-iduffy cloudsmith-iduffy commented Oct 16, 2026 •

Copy link
Copy Markdown
Collaborator

Description

Adds a NuGet credential provider (ENG-13680). After a one-off install, dotnet restore, MSBuild and Visual Studio authenticate to Cloudsmith feeds with the CLI's own credentials: an API key, a SAML session from the keyring, or OIDC. No token goes into nuget.config.

$ cloudsmith credential-helper install nuget --workspace acme
$ dotnet restore

Install. credential-helper install nuget writes a nuget-plugin-cloudsmith launcher next to cloudsmith, as a .bat on Windows. NuGet 6.13 and later (.NET SDK 9.0.200+) finds any nuget-plugin-* executable on PATH, so no NuGet config changes.

NuGet often runs the plugin from an IDE or a build agent that does not have your shell's environment. So the launcher keeps the CLOUDSMITH_* settings that you gave at install time as a flag or an environment variable, with the shared code from #511. These values win over the runtime environment. Secrets and the proxy URL are never written.

Runtime. credential-helper nuget speaks NuGet's cross-platform plugin protocol. NuGet asks every plugin about every feed:

  • An https feed on *.cloudsmith.io, *.cloudsmith.com or a NuGet domain of the Workspace → Basic token:<credential>.
  • Any other feed, or a failed domain lookup → NotFound, so NuGet tries its other credential sources.
  • A Cloudsmith feed with no credential → Error, with a message that says how to authenticate.
  • A retry after a 401 resolves the credential again, so a renewed SAML or OIDC token is used.

--domain <host> adds hosts that always match. --no-discover turns off the domain lookup, so only those hosts and the Cloudsmith hosts match. Stdout carries only protocol messages, also under --debug.

Changes outside NuGet.

  • core/api/init.py writes its --debug credential messages to stderr. One of them now says "SAML" instead of "SSO".
  • resolve_credentials keeps its CredentialContext on opts for the retry. is_cloudsmith_domain passes configure_api through, so the domain lookup keeps the CLI's proxy and TLS settings.

Type of Change

  • Bug fix
  • New feature
  • Breaking change
  • Documentation update
  • Refactoring
  • Other (please describe)

Additional Notes

This replaces #458, which has the earlier review history.

Testing. A temporary workflow ran a real dotnet restore (SDK 10) on Linux, macOS and Windows, from both a pip install and the PyInstaller binary. It covered a standard feed, a custom domain found by the live lookup with --debug on, and --domain --no-discover with an unreachable API host. Each job checks that the restore fails with 401 without the plugin and succeeds with it. All 18 jobs passed: run 38967385354.

I also ran dotnet nuget push and dotnet restore locally against a private repo with only a SAML session. Both worked with no API key.

Known limits.

  • The pnpm and cargo launchers do not keep install-time settings yet.

NuGet's plugin timeouts are 30 seconds in ProtocolConstants, not the 5 seconds in the Microsoft docs. Credentials resolve before the handshake, so the plugin needs no threads or progress messages.

🤖 Generated with Claude Code

Copilot AI balanced review requested due to automatic review settings October 16, 2026 21:36
@cloudsmith-iduffy
cloudsmith-iduffy requested a review from a team as a code owner October 16, 2026 21:36

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Blocking credential operations can exceed NuGet protocol deadlines, and status detection can miss default installations.

3 open issues
What changed in this PR

Adds a NuGet credential provider that authenticates Cloudsmith feeds using the CLI’s existing credentials.

Changes:

  • Implements the NuGet plugin protocol and feed matching.
  • Adds installation, registration, and saved launcher settings.
  • Adds tests and protects protocol output from debug messages.
File Description
cloudsmith_cli/​credential_helpers/​nuget/​runtime.py Implements plugin messages and authentication.
cloudsmith_cli/​credential_helpers/​nuget/​installer.py Manages NuGet launcher installation.
cloudsmith_cli/​credential_helpers/​nuget/​__init__.py Exports the runtime entry point.
cloudsmith_cli/​credential_helpers/​launchers.py Supports saved settings and Windows batch launchers.
cloudsmith_cli/​credential_helpers/​common.py Allows discovery using existing API configuration.
cloudsmith_cli/​core/​api/​init.py Sends authentication debug output to stderr.
cloudsmith_cli/​cli/​tests/​test_credential_helper_nuget.py Tests protocol, command, and installation behavior.
cloudsmith_cli/​cli/​tests/​commands/​test_credential_helper_install.py Tests launcher settings and escaping.
cloudsmith_cli/​cli/​decorators.py Exposes credential context for renewal.
cloudsmith_cli/​cli/​commands/​credential_helper/​nuget.py Adds the NuGet command.
cloudsmith_cli/​cli/​commands/​credential_helper/​manage.py Registers installation and captures settings.
cloudsmith_cli/​cli/​commands/​credential_helper/​__init__.py Registers the NuGet subcommand.
CHANGELOG.md Documents provider usage and requirements.

Review effort: Balanced

Comment on lines +141 to +143
launcher_path = self._launcher_path(resolve_bin_dir())
if not launcher_path.exists():
return {"launcher": None, "hosts": []}
@cloudsmith-iduffy
cloudsmith-iduffy marked this pull request as draft October 19, 2026 09:15
Base automatically changed from iduffy/shared-credential-helper-code to master October 20, 2026 08:24
@cloudsmith-iduffy
cloudsmith-iduffy force-pushed the iduffy/nuget-credential-provider branch from 091ed1c to 54ddc6e Compare October 21, 2026 13:11
@cloudsmith-iduffy
cloudsmith-iduffy removed this pull request from stack #495 October 21, 2026 13:11
@cloudsmith-iduffy
cloudsmith-iduffy changed the base branch from master to iduffy/launcher-install-time-settings October 21, 2026 13:12
@cloudsmith-iduffy
cloudsmith-iduffy force-pushed the iduffy/launcher-install-time-settings branch from 1f65903 to a7601da Compare October 21, 2026 14:22
@cloudsmith-iduffy
cloudsmith-iduffy force-pushed the iduffy/nuget-credential-provider branch from 54ddc6e to 22711f2 Compare October 21, 2026 14:22
@cloudsmith-iduffy
cloudsmith-iduffy force-pushed the iduffy/launcher-install-time-settings branch from a7601da to 3b92542 Compare October 21, 2026 15:08
@cloudsmith-iduffy
cloudsmith-iduffy force-pushed the iduffy/nuget-credential-provider branch from 22711f2 to 956f61a Compare October 21, 2026 15:09
@cloudsmith-iduffy
cloudsmith-iduffy force-pushed the iduffy/launcher-install-time-settings branch from 3b92542 to c2ee2fb Compare October 21, 2026 15:13
@cloudsmith-iduffy
cloudsmith-iduffy force-pushed the iduffy/nuget-credential-provider branch from 956f61a to ba516f4 Compare October 21, 2026 15:14
@cloudsmith-iduffy
cloudsmith-iduffy force-pushed the iduffy/launcher-install-time-settings branch from c2ee2fb to e1a1c2e Compare October 21, 2026 15:34
@cloudsmith-iduffy
cloudsmith-iduffy force-pushed the iduffy/nuget-credential-provider branch from ba516f4 to cc4365d Compare October 21, 2026 15:34
@cloudsmith-iduffy
cloudsmith-iduffy force-pushed the iduffy/launcher-install-time-settings branch from e1a1c2e to 23abdbd Compare October 21, 2026 16:30
@cloudsmith-iduffy
cloudsmith-iduffy force-pushed the iduffy/nuget-credential-provider branch from cc4365d to 6d4af20 Compare October 21, 2026 16:31
cloudsmith-iduffy and others added 3 commits October 21, 2026 18:10
The keyring module read CLOUDSMITH_NO_KEYRING, CLOUDSMITH_KEYRING_BACKEND,
CLOUDSMITH_KEYRING_KEY, CLOUDSMITH_KEYRING_FILE_PATH and
CLOUDSMITH_KEYRING_DIR with os.environ.get(). These settings have no
flags, so Click did not know about them.

- `keyring_options` on the `main` group adds hidden options that read
  the five variables. It gives the values to the keyring module through
  `keyring.configure(KeyringSettings(...))`.
- The keyring module reads its settings from `configure()`, not from the
  environment. It still copies them to PYTHON_KEYRING_BACKEND and
  KEYRING_PROPERTY_*, because the keyring library reads only those.
- The path settings are expanded and made absolute when Click parses
  them, not when the keyring uses them.
- CLOUDSMITH_NO_KEYRING keeps its parsing: only 1, true and yes, in any
  case, turn off the keyring. Other values leave it on, as before.
- A root conftest fixture resets the settings before each test, so tests
  no longer depend on the keyring variables of the developer shell.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ttings

A package manager can run its credential helper from an IDE, Docker
Desktop or a build agent that does not have the environment of the
install. This change adds the shared code that writes the install-time
CLOUDSMITH_* settings into a launcher.

- `write_launcher(env=...)` in `launchers.py` writes the settings as
  `export` lines, or as `set` lines in a Windows batch file. The settings
  replace the values of the caller.
- `batch_value()` refuses a value that a batch file cannot hold. A
  non-ASCII path under the user profile becomes `%USERPROFILE%`.
- `install` passes `launcher_env` to an installer that sets
  `KEEPS_INSTALL_TIME_SETTINGS`. It holds each option with a
  CLOUDSMITH_* environment variable whose value came from a flag or the
  environment, also on a parent command, so the keyring settings of
  `main` are kept too. The API key, the custom headers, the keyring key
  and the proxy never go into it.

No installer sets `KEEPS_INSTALL_TIME_SETTINGS` yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`cloudsmith credential-helper install nuget` writes a
`nuget-plugin-cloudsmith` launcher (`.bat` on Windows) on PATH. NuGet 6.13+
finds it and speaks the cross-platform plugin protocol to
`cloudsmith credential-helper nuget`. The plugin answers for Cloudsmith hosts
and the NuGet domains of the Workspace that is written into the launcher.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

# Conflicts:
#	CHANGELOG.md
#	cloudsmith_cli/cli/commands/credential_helper/manage.py
#	cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py
#	cloudsmith_cli/core/api/init.py
#	cloudsmith_cli/credential_helpers/launchers.py

# Conflicts:
#	cloudsmith_cli/cli/commands/credential_helper/manage.py
#	cloudsmith_cli/cli/tests/commands/test_credential_helper_install.py
@cloudsmith-iduffy
cloudsmith-iduffy force-pushed the iduffy/launcher-install-time-settings branch from 23abdbd to 6db2380 Compare October 21, 2026 17:11
@cloudsmith-iduffy
cloudsmith-iduffy force-pushed the iduffy/nuget-credential-provider branch from 6d4af20 to 87c0faa Compare October 21, 2026 17:11
@cloudsmith-iduffy
cloudsmith-iduffy force-pushed the iduffy/launcher-install-time-settings branch 2 times, most recently from 1c68857 to d68e448 Compare October 23, 2026 12:58
The pnpm install wrote .npmrc without a final newline. A shell append
such as `echo ... >> ~/.npmrc` then joined the last tokenHelper line.

Write a final newline after each line. Add the newline to an existing
.npmrc that does not have one. Show each tokenHelper line that a real
install sets, as the dry run does.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: cloudsmith-iduffy <178375997+cloudsmith-iduffy@users.noreply.github.com>
…ttings

A package manager can run its credential helper from an IDE, Docker
Desktop or a build agent that does not have the environment of the
install. This change adds the shared code that writes the install-time
CLOUDSMITH_* settings into a launcher.

- `write_launcher(env=...)` in `launchers.py` writes the settings as
  `export` lines, or as `set` lines in a Windows batch file. The settings
  replace the values of the caller.
- `batch_value()` refuses a value that a batch file cannot hold. A
  non-ASCII path under the user profile becomes `%USERPROFILE%`.
- `install` passes `launcher_env` to an installer that sets
  `KEEPS_INSTALL_TIME_SETTINGS`. It holds each option with a
  CLOUDSMITH_* environment variable whose value came from a flag or the
  environment, also on a parent command, so the keyring settings of
  `main` are kept too. The API key, the custom headers, the keyring key
  and the proxy never go into it.

No installer sets `KEEPS_INSTALL_TIME_SETTINGS` yet.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudsmith-iduffy
cloudsmith-iduffy force-pushed the iduffy/launcher-install-time-settings branch from d68e448 to 66fb588 Compare October 28, 2026 11:42
…credential-provider

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@cloudsmith-iduffy
cloudsmith-iduffy force-pushed the iduffy/launcher-install-time-settings branch from 66fb588 to 25fd194 Compare November 2, 2026 13:28
The test runs `install nuget` with no --bin-dir and does not patch the
resolver, so `list` must look in ~/.cloudsmith/bin.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Base automatically changed from iduffy/launcher-install-time-settings to master November 3, 2026 10:51
cloudsmith-iduffy and others added 3 commits November 3, 2026 10:53
A worker thread answers GetAuthenticationCredentials. The main thread
sends Progress at half of the Initialize request timeout until the
answer is ready. It keeps reading stdin, so a Cancel gets a Cancel at
once and the late answer is not sent.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
`initialise_api` and `resolve_credentials` take a `defer` option. The
NuGet command uses it, so it sends the handshake before the provider
chain runs. The first credentials request resolves the credential in
the worker thread, which sends Progress. The command keeps the
credential on `opts`, where telemetry redacts it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Non-interactive credential lookup can still trigger keyring password prompts, and in-flight lookups can delay shutdown or emit responses after NuGet disconnects.

3 open issues
2 resolved since last review

Review effort: Balanced

# The first request resolves a credential that the caller did not give.
# NuGet retries after the feed rejected the last credential, which
# can have expired since the plugin started.
self.credential = self.refresh_credential()
cloudsmith-iduffy and others added 3 commits November 3, 2026 11:11
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Close, the end of stdin or a stdin error now ends the session while a
request runs. The plugin sends no more Progress and drops the late
answer. Each request runs in a daemon thread, so a slow lookup cannot
delay the exit. A request that raises gets a Fault.

The tests now play NuGet: they send Close or end stdin only after the
plugin answers each request.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The credential callback takes the request's IsNonInteractive value.
When it is true, the command calls keyring.forbid_password_prompts(),
so an encrypted keyring refuses to prompt also at a terminal.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 Needs a closer look

Initial credential failures persist on subsequent non-retry requests, and unrelated feeds can trigger unnecessary network token retrieval when discovery is disabled.

2 open issues
2 previously missed issues

In code that hasn't changed since last review

Medium severity Reject unallowed hosts before resolving credentials

cloudsmith_cli/​credential_helpers/​nuget/​runtime.py:236

With --no-discover, an unrelated feed still triggers credential resolution before its host is rejected. A cold OIDC cache can therefore cause network token retrieval and exchange, delaying authentication for another provider's feed just to return NotFound. Reject hosts outside the standard and explicit allowlists before resolving credentials when discovery is disabled.

Medium severity Do not cache failed credential resolution

cloudsmith_cli/​credential_helpers/​nuget/​runtime.py:247

If the first credential resolution returns None, later non-retry requests reuse that failure even if credentials have since become available. Only mark the credential resolved when a credential was returned, so an initial failure can recover on a subsequent request. Add a regression test where resolution returns None and then succeeds on a non-retry request.

1 resolved since last review

Review effort: Balanced

…led one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟢 Approval recommended

No active review issues were identified for the NuGet credential provider change.

2 open issues

Review effort: Balanced

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants