Skip to content

Commit 545d0af

Browse files
feat: Add credential helpers for package manager authentication
Add credential helpers that automatically authenticate package managers with Cloudsmith registries using the credential provider chain (Environment Variable → Config File → Keyring → OIDC). Supported formats: Docker docker-credential-cloudsmith Terraform terraform-credentials-cloudsmith Cargo cargo-credential-cloudsmith pnpm cloudsmith-token-helper NuGet CredentialProvider.Cloudsmith pip/twine keyring backend (auto-discovered) Conda conda plugin (auto-discovered) ## Docker Configure `~/.docker/config.json`: { "credHelpers": { "docker.cloudsmith.io": "cloudsmith" } } Then: docker pull docker.cloudsmith.io/myorg/myrepo/myimage:latest ## Terraform Install the helper and configure `~/.terraformrc`: mkdir -p ~/.terraform.d/plugins ln -sf "$(which terraform-credentials-cloudsmith)" ~/.terraform.d/plugins/ credentials_helper "cloudsmith" { args = [] } Requires CLOUDSMITH_ORG and CLOUDSMITH_REPO environment variables. Token format is org/repo/token per Cloudsmith's Terraform registry API. ## Cargo Configure `~/.cargo/config.toml`: [registries.cloudsmith] index = "sparse+https://cargo.cloudsmith.io/myorg/myrepo/" credential-provider = ["cargo-credential-cloudsmith"] Then: cargo add my-crate --registry cloudsmith ## pnpm Configure `~/.npmrc` (requires absolute path to helper): registry=https://npm.cloudsmith.io/myorg/myrepo/ //npm.cloudsmith.io/myorg/myrepo/:tokenHelper=/usr/local/bin/cloudsmith-token-helper Returns "Bearer <token>" as pnpm does not auto-add the prefix. ## NuGet Set NUGET_CREDENTIALPROVIDERS_PATH to the directory containing CredentialProvider.Cloudsmith and add a package source: <packageSources> <add key="cloudsmith" value="https://nuget.cloudsmith.io/myorg/myrepo/v3/index.json" /> </packageSources> Then: dotnet restore ## pip / twine Auto-discovered via the keyring.backends entry point. No configuration needed beyond installing cloudsmith-cli: pip install --index-url=https://dl.cloudsmith.io/basic/myorg/myrepo/python/simple/ mypkg ## Conda Auto-discovered via the conda plugin entry point. Install cloudsmith-cli into conda's base Python environment. Configure `~/.condarc`: channel_settings: - channel: https://conda.cloudsmith.io/myorg/myrepo/ auth: cloudsmith channels: - https://conda.cloudsmith.io/myorg/myrepo/ - defaults Then: conda install my-package ## Architecture - cloudsmith_cli/credential_helpers/common.py: shared resolve_credentials(), extract_hostname(), is_cloudsmith_domain() used by all helpers - CredentialProviderChain defaults to the standard 4-provider chain - Networking config (proxy, TLS, headers) read from CLOUDSMITH_API_PROXY, CLOUDSMITH_WITHOUT_API_SSL_VERIFY, CLOUDSMITH_API_USER_AGENT, CLOUDSMITH_API_HEADERS env vars and config.ini - Custom domain discovery via GET /orgs/{org}/custom-domains/ with 1-hour filesystem cache in ~/.cloudsmith/cache/custom_domains/ Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent df70f37 commit 545d0af

17 files changed

Lines changed: 1012 additions & 4 deletions

File tree

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
"""
2+
Cargo credential helper command.
3+
4+
Implements credential retrieval for Cargo registries hosted on Cloudsmith.
5+
"""
6+
7+
import json
8+
import sys
9+
10+
import click
11+
12+
from ....credential_helpers.cargo import get_credentials
13+
14+
15+
@click.command()
16+
@click.argument("index_url", required=False, default=None)
17+
def cargo(index_url):
18+
"""
19+
Cargo credential helper for Cloudsmith registries.
20+
21+
Returns credentials as a Bearer token for Cargo sparse registries.
22+
23+
If INDEX_URL is provided as an argument, uses it directly.
24+
Otherwise reads from stdin.
25+
26+
Examples:
27+
# Direct usage
28+
$ cloudsmith credential-helper cargo sparse+https://cargo.cloudsmith.io/org/repo/
29+
Bearer eyJ0eXAiOiJKV1Qi...
30+
31+
# Via wrapper (called by Cargo)
32+
$ cargo-credential-cloudsmith --cargo-plugin
33+
34+
Environment variables:
35+
CLOUDSMITH_API_KEY: API key for authentication (optional)
36+
CLOUDSMITH_ORG: Organization slug (required for OIDC)
37+
CLOUDSMITH_SERVICE_SLUG: Service account slug (required for OIDC)
38+
"""
39+
try:
40+
if not index_url:
41+
index_url = sys.stdin.read().strip()
42+
43+
if not index_url:
44+
click.echo("Error: No index URL provided", err=True)
45+
sys.exit(1)
46+
47+
token = get_credentials(index_url, debug=False)
48+
49+
if not token:
50+
click.echo(
51+
"Error: Unable to retrieve credentials. "
52+
"Set CLOUDSMITH_API_KEY or configure OIDC.",
53+
err=True,
54+
)
55+
sys.exit(1)
56+
57+
click.echo(json.dumps({"token": f"Bearer {token}"}))
58+
59+
except Exception as e: # pylint: disable=broad-exception-caught
60+
click.echo(f"Error: {e}", err=True)
61+
sys.exit(1)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""
2+
Conda credential helper command.
3+
4+
Provides credential retrieval for Conda channels hosted on Cloudsmith.
5+
"""
6+
7+
import json
8+
import sys
9+
10+
import click
11+
12+
from ....credential_helpers.conda import get_credentials
13+
14+
15+
@click.command()
16+
@click.argument("channel_url", required=False, default=None)
17+
def conda(channel_url):
18+
"""
19+
Conda credential helper for Cloudsmith channels.
20+
21+
Returns credentials as JSON for Cloudsmith Conda channels.
22+
23+
If CHANNEL_URL is provided as an argument, uses it directly.
24+
Otherwise reads from stdin.
25+
26+
Examples:
27+
# Direct usage
28+
$ cloudsmith credential-helper conda https://conda.cloudsmith.io/org/repo/
29+
{"username":"token","password":"eyJ0eXAiOiJKV1Qi..."}
30+
31+
The conda plugin (cloudsmith_cli.credential_helpers.conda.plugin) provides
32+
automatic authentication when installed as a conda plugin.
33+
34+
Environment variables:
35+
CLOUDSMITH_API_KEY: API key for authentication (optional)
36+
CLOUDSMITH_ORG: Organization slug (required for OIDC)
37+
CLOUDSMITH_SERVICE_SLUG: Service account slug (required for OIDC)
38+
"""
39+
try:
40+
if not channel_url:
41+
channel_url = sys.stdin.read().strip()
42+
43+
if not channel_url:
44+
click.echo("Error: No channel URL provided", err=True)
45+
sys.exit(1)
46+
47+
creds = get_credentials(channel_url, debug=False)
48+
49+
if not creds:
50+
click.echo(
51+
"Error: Unable to retrieve credentials. "
52+
"Set CLOUDSMITH_API_KEY or configure OIDC.",
53+
err=True,
54+
)
55+
sys.exit(1)
56+
57+
username, password = creds
58+
click.echo(json.dumps({"username": username, "password": password}))
59+
60+
except Exception as e: # pylint: disable=broad-exception-caught
61+
click.echo(f"Error: {e}", err=True)
62+
sys.exit(1)
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
"""
2+
npm/pnpm token helper command.
3+
4+
Prints a raw Cloudsmith API token for use with pnpm's tokenHelper.
5+
"""
6+
7+
import sys
8+
9+
import click
10+
11+
from ....credential_helpers.npm import get_token
12+
13+
14+
@click.command()
15+
def npm():
16+
"""
17+
npm/pnpm token helper for Cloudsmith registries.
18+
19+
Prints a raw API token to stdout for use with pnpm's tokenHelper configuration.
20+
21+
Examples:
22+
# Direct usage
23+
$ cloudsmith credential-helper npm
24+
eyJ0eXAiOiJKV1Qi...
25+
26+
# Via wrapper (called by pnpm)
27+
$ cloudsmith-token-helper
28+
eyJ0eXAiOiJKV1Qi...
29+
30+
Configuration in ~/.npmrc:
31+
//npm.cloudsmith.io/:tokenHelper=/absolute/path/to/cloudsmith-token-helper
32+
33+
Find the path with: which cloudsmith-token-helper
34+
35+
Environment variables:
36+
CLOUDSMITH_API_KEY: API key for authentication (optional)
37+
CLOUDSMITH_ORG: Organization slug (required for OIDC)
38+
CLOUDSMITH_SERVICE_SLUG: Service account slug (required for OIDC)
39+
"""
40+
try:
41+
token = get_token(debug=False)
42+
43+
if not token:
44+
click.echo(
45+
"Error: Unable to retrieve credentials. "
46+
"Set CLOUDSMITH_API_KEY or configure OIDC.",
47+
err=True,
48+
)
49+
sys.exit(1)
50+
51+
# Raw token output — no JSON, no trailing newline issues
52+
sys.stdout.write(token)
53+
sys.stdout.flush()
54+
55+
except Exception as e: # pylint: disable=broad-exception-caught
56+
click.echo(f"Error: {e}", err=True)
57+
sys.exit(1)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
"""
2+
NuGet credential helper command.
3+
4+
Implements credential retrieval for NuGet feeds hosted on Cloudsmith.
5+
"""
6+
7+
import json
8+
import sys
9+
10+
import click
11+
12+
from ....credential_helpers.nuget import get_credentials
13+
14+
15+
@click.command()
16+
@click.argument("uri", required=False, default=None)
17+
def nuget(uri):
18+
"""
19+
NuGet credential helper for Cloudsmith feeds.
20+
21+
Returns credentials in NuGet's expected JSON format:
22+
{"Username": "token", "Password": "...", "Message": ""}
23+
24+
If URI is provided as an argument, uses it directly.
25+
Otherwise reads from stdin.
26+
27+
Examples:
28+
# Direct usage
29+
$ cloudsmith credential-helper nuget https://nuget.cloudsmith.io/org/repo/v3/index.json
30+
{"Username":"token","Password":"eyJ0eXAiOiJKV1Qi...","Message":""}
31+
32+
# Via wrapper (called by NuGet)
33+
$ CredentialProvider.Cloudsmith -uri https://nuget.cloudsmith.io/org/repo/v3/index.json
34+
35+
Environment variables:
36+
CLOUDSMITH_API_KEY: API key for authentication (optional)
37+
CLOUDSMITH_ORG: Organization slug (required for OIDC)
38+
CLOUDSMITH_SERVICE_SLUG: Service account slug (required for OIDC)
39+
"""
40+
try:
41+
if not uri:
42+
uri = sys.stdin.read().strip()
43+
44+
if not uri:
45+
click.echo("Error: No URI provided", err=True)
46+
sys.exit(1)
47+
48+
credentials = get_credentials(uri, debug=False)
49+
50+
if not credentials:
51+
click.echo(
52+
"Error: Unable to retrieve credentials. "
53+
"Set CLOUDSMITH_API_KEY or configure OIDC.",
54+
err=True,
55+
)
56+
sys.exit(1)
57+
58+
click.echo(json.dumps({**credentials, "Message": ""}))
59+
60+
except Exception as e: # pylint: disable=broad-exception-caught
61+
click.echo(f"Error: {e}", err=True)
62+
sys.exit(1)
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
"""
2+
Terraform credential helper command.
3+
4+
Implements the Terraform credential helper protocol for Cloudsmith registries.
5+
"""
6+
7+
import json
8+
import sys
9+
10+
import click
11+
12+
from ....credential_helpers.terraform import get_credentials
13+
14+
15+
@click.command()
16+
@click.argument("hostname", required=False, default=None)
17+
def terraform(hostname):
18+
"""
19+
Terraform credential helper for Cloudsmith registries.
20+
21+
Returns credentials in Terraform's expected JSON format: {"token": "..."}
22+
23+
Can be used directly or via the terraform-credentials-cloudsmith wrapper.
24+
25+
If HOSTNAME is provided as an argument, uses it directly.
26+
Otherwise reads from stdin (for use with the wrapper binary).
27+
28+
Examples:
29+
# Direct usage
30+
$ cloudsmith credential-helper terraform terraform.cloudsmith.io
31+
{"token":"eyJ0eXAiOiJKV1Qi..."}
32+
33+
# Via wrapper
34+
$ terraform-credentials-cloudsmith get terraform.cloudsmith.io
35+
36+
Environment variables:
37+
CLOUDSMITH_API_KEY: API key for authentication (optional)
38+
CLOUDSMITH_ORG: Organization slug (required for OIDC)
39+
CLOUDSMITH_SERVICE_SLUG: Service account slug (required for OIDC)
40+
"""
41+
try:
42+
if not hostname:
43+
hostname = sys.stdin.read().strip()
44+
45+
if not hostname:
46+
click.echo("Error: No hostname provided", err=True)
47+
sys.exit(1)
48+
49+
token = get_credentials(hostname, debug=False)
50+
51+
if not token:
52+
click.echo(
53+
"Error: Unable to retrieve credentials. "
54+
"Set CLOUDSMITH_API_KEY or configure OIDC.",
55+
err=True,
56+
)
57+
sys.exit(1)
58+
59+
click.echo(json.dumps({"token": token}))
60+
61+
except Exception as e: # pylint: disable=broad-exception-caught
62+
click.echo(f"Error: {e}", err=True)
63+
sys.exit(1)

cloudsmith_cli/core/credentials/__init__.py

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -51,10 +51,29 @@ def resolve(self, context: CredentialContext) -> CredentialResult | None:
5151

5252

5353
class CredentialProviderChain:
54-
"""Evaluates credential providers in order, returning the first valid result."""
55-
56-
def __init__(self, providers: list[CredentialProvider]):
57-
self.providers = providers
54+
"""Evaluates credential providers in order, returning the first valid result.
55+
56+
If no providers are given, uses the default chain:
57+
EnvironmentVariable → ConfigFile → Keyring → OIDC.
58+
"""
59+
60+
def __init__(self, providers: list[CredentialProvider] | None = None):
61+
if providers is not None:
62+
self.providers = providers
63+
else:
64+
from .providers import (
65+
ConfigFileProvider,
66+
EnvironmentVariableProvider,
67+
KeyringProvider,
68+
OidcProvider,
69+
)
70+
71+
self.providers = [
72+
EnvironmentVariableProvider(),
73+
ConfigFileProvider(),
74+
KeyringProvider(),
75+
OidcProvider(),
76+
]
5877

5978
def resolve(self, context: CredentialContext) -> CredentialResult | None:
6079
"""Evaluate each provider in order. Return the first successful result."""
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
"""
2+
Cargo credential provider logic for Cloudsmith.
3+
4+
This module provides functions for retrieving credentials for Cargo registries
5+
using the existing Cloudsmith credential provider chain (OIDC, API keys, config, keyring).
6+
"""
7+
8+
from ..common import is_cloudsmith_domain, resolve_credentials
9+
10+
11+
def get_credentials(index_url, debug=False):
12+
"""
13+
Get a token for a Cloudsmith Cargo registry.
14+
15+
Resolves credentials first, then verifies the URL is a Cloudsmith registry
16+
(including custom domains, authenticated via the resolved token).
17+
18+
Args:
19+
index_url: The registry index URL (e.g., "sparse+https://cargo.cloudsmith.io/org/repo/")
20+
debug: Enable debug logging
21+
22+
Returns:
23+
str: API token or None if not available
24+
"""
25+
result = resolve_credentials(debug)
26+
if not result:
27+
return None
28+
29+
if not is_cloudsmith_domain(index_url):
30+
return None
31+
32+
return result.api_key

0 commit comments

Comments
 (0)