Skip to content

Commit 4e72204

Browse files
feat: add Docker credential helper for Cloudsmith registries
Implement the Docker credential helper protocol so Docker can automatically authenticate with Cloudsmith registries (including custom domains) without manual `docker login`. Key changes: - Add `cloudsmith credential-helper docker` CLI command - Add `docker-credential-cloudsmith` wrapper binary (entry point) - Add credential resolution using the existing provider chain (OIDC, API keys, config, keyring) - Add custom domain discovery via Cloudsmith API with filesystem caching - Extract shared `create_session` helper, reused by SAML auth flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent a60887d commit 4e72204

File tree

10 files changed

+528
-16
lines changed

10 files changed

+528
-16
lines changed

cloudsmith_cli/cli/commands/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
auth,
55
check,
66
copy,
7+
credential_helper,
78
delete,
89
dependencies,
910
docs,
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
"""
2+
Credential helper commands for Cloudsmith.
3+
4+
This module provides credential helper commands for package managers
5+
that follow their respective credential helper protocols.
6+
"""
7+
8+
import click
9+
10+
from ..main import main
11+
from .docker import docker as docker_cmd
12+
13+
14+
@click.group()
15+
def credential_helper():
16+
"""
17+
Credential helpers for package managers.
18+
19+
These commands provide credentials for package managers like Docker.
20+
They are typically called by wrapper binaries
21+
(e.g., docker-credential-cloudsmith) or used directly for debugging.
22+
23+
Examples:
24+
# Test Docker credential helper
25+
$ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker
26+
"""
27+
28+
29+
credential_helper.add_command(docker_cmd, name="docker")
30+
31+
main.add_command(credential_helper, name="credential-helper")
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""
2+
Docker credential helper command.
3+
4+
Implements the Docker credential helper protocol for Cloudsmith registries.
5+
6+
See: https://github.com/docker/docker-credential-helpers
7+
"""
8+
9+
import json
10+
import sys
11+
12+
import click
13+
14+
from ....credential_helpers.docker import get_credentials
15+
from ...decorators import common_api_auth_options, resolve_credentials
16+
17+
18+
@click.command()
19+
@common_api_auth_options
20+
@resolve_credentials
21+
def docker(opts):
22+
"""
23+
Docker credential helper for Cloudsmith registries.
24+
25+
Reads a Docker registry server URL from stdin and returns credentials in JSON format.
26+
This command implements the 'get' operation of the Docker credential helper protocol.
27+
28+
Only provides credentials for Cloudsmith Docker registries (docker.cloudsmith.io).
29+
30+
Input (stdin):
31+
Server URL as plain text (e.g., "docker.cloudsmith.io")
32+
33+
Output (stdout):
34+
JSON: {"Username": "token", "Secret": "<cloudsmith-token>"}
35+
36+
Exit codes:
37+
0: Success
38+
1: Error (no credentials available, not a Cloudsmith registry, etc.)
39+
40+
Examples:
41+
# Manual testing
42+
$ echo "docker.cloudsmith.io" | cloudsmith credential-helper docker
43+
{"Username":"token","Secret":"eyJ0eXAiOiJKV1Qi..."}
44+
45+
# Called by Docker via wrapper
46+
$ echo "docker.cloudsmith.io" | docker-credential-cloudsmith get
47+
{"Username":"token","Secret":"eyJ0eXAiOiJKV1Qi..."}
48+
49+
Environment variables:
50+
CLOUDSMITH_API_KEY: API key for authentication (optional)
51+
CLOUDSMITH_ORG: Organization slug (required for custom domain support)
52+
"""
53+
try:
54+
server_url = sys.stdin.read().strip()
55+
56+
if not server_url:
57+
click.echo("Error: No server URL provided on stdin", err=True)
58+
sys.exit(1)
59+
60+
credentials = get_credentials(
61+
server_url,
62+
credential=opts.credential,
63+
session=opts.session,
64+
api_host=opts.api_host or "https://api.cloudsmith.io",
65+
)
66+
67+
if not credentials:
68+
click.echo(
69+
"Error: Unable to retrieve credentials. "
70+
"Make sure you have a valid cloudsmith-cli session, "
71+
"this can be checked with `cloudsmith whoami`.",
72+
err=True,
73+
)
74+
sys.exit(1)
75+
76+
click.echo(json.dumps(credentials))
77+
78+
except Exception as e: # pylint: disable=broad-exception-caught
79+
click.echo(f"Error: {e}", err=True)
80+
sys.exit(1)

cloudsmith_cli/cli/saml.py

Lines changed: 7 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -3,27 +3,19 @@
33
import requests
44

55
from ..core.api.exceptions import ApiException
6+
from ..core.credentials.session import create_session
67

78

89
def create_configured_session(opts):
910
"""
1011
Create a requests session configured with the options from opts.
1112
"""
12-
session = requests.Session()
13-
14-
if hasattr(opts, "api_ssl_verify") and opts.api_ssl_verify is not None:
15-
session.verify = opts.api_ssl_verify
16-
17-
if hasattr(opts, "api_proxy") and opts.api_proxy:
18-
session.proxies = {"http": opts.api_proxy, "https": opts.api_proxy}
19-
20-
if hasattr(opts, "api_user_agent") and opts.api_user_agent:
21-
session.headers.update({"User-Agent": opts.api_user_agent})
22-
23-
if hasattr(opts, "api_headers") and opts.api_headers:
24-
session.headers.update(opts.api_headers)
25-
26-
return session
13+
return create_session(
14+
proxy=getattr(opts, "api_proxy", None),
15+
ssl_verify=getattr(opts, "api_ssl_verify", True),
16+
user_agent=getattr(opts, "api_user_agent", None),
17+
headers=getattr(opts, "api_headers", None),
18+
)
2719

2820

2921
def get_idp_url(api_host, owner, session):
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
"""
2+
Credential helpers for various package managers.
3+
4+
This package provides credential helper implementations for Docker, pip, npm, etc.
5+
Each helper follows its respective package manager's credential helper protocol.
6+
"""
Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,86 @@
1+
"""
2+
Shared utilities for credential helpers.
3+
4+
Provides domain checking used by all credential helpers.
5+
"""
6+
7+
import logging
8+
import os
9+
10+
logger = logging.getLogger(__name__)
11+
12+
13+
def extract_hostname(url):
14+
"""
15+
Extract bare hostname from any URL format.
16+
17+
Handles protocols, sparse+ prefix, ports, paths, and trailing slashes.
18+
19+
Args:
20+
url: URL in any format (e.g., "sparse+https://cargo.cloudsmith.io/org/repo/")
21+
22+
Returns:
23+
str: Lowercase hostname (e.g., "cargo.cloudsmith.io")
24+
"""
25+
if not url:
26+
return ""
27+
28+
normalized = url.lower().strip()
29+
30+
# Remove sparse+ prefix (Cargo)
31+
if normalized.startswith("sparse+"):
32+
normalized = normalized[7:]
33+
34+
# Remove protocol
35+
if "://" in normalized:
36+
normalized = normalized.split("://", 1)[1]
37+
38+
# Remove userinfo (user@host)
39+
if "@" in normalized.split("/")[0]:
40+
normalized = normalized.split("@", 1)[1]
41+
42+
# Extract hostname (before first / or :)
43+
hostname = normalized.split("/")[0].split(":")[0]
44+
45+
return hostname
46+
47+
48+
def is_cloudsmith_domain(url, session=None, api_key=None, api_host=None):
49+
"""
50+
Check if a URL points to a Cloudsmith service.
51+
52+
Checks standard *.cloudsmith.io domains first (no auth needed).
53+
If not a standard domain, queries the Cloudsmith API for custom domains.
54+
55+
Args:
56+
url: URL or hostname to check
57+
session: Pre-configured requests.Session with proxy/SSL settings
58+
api_key: API key for authenticating custom domain lookups
59+
api_host: Cloudsmith API host URL
60+
61+
Returns:
62+
bool: True if this is a Cloudsmith domain
63+
"""
64+
hostname = extract_hostname(url)
65+
if not hostname:
66+
return False
67+
68+
# Standard Cloudsmith domains — no auth needed
69+
if hostname.endswith("cloudsmith.io") or hostname == "cloudsmith.io":
70+
return True
71+
72+
# Custom domains require org + auth
73+
org = os.environ.get("CLOUDSMITH_ORG", "").strip()
74+
if not org:
75+
return False
76+
77+
if not api_key:
78+
return False
79+
80+
from .custom_domains import get_custom_domains_for_org
81+
82+
custom_domains = get_custom_domains_for_org(
83+
org, session=session, api_key=api_key, api_host=api_host
84+
)
85+
86+
return hostname in [d.lower() for d in custom_domains]

0 commit comments

Comments
 (0)