diff --git a/.pylintrc b/.pylintrc index 9156d6a9..b37610fc 100644 --- a/.pylintrc +++ b/.pylintrc @@ -455,7 +455,9 @@ disable=raw-checker-failed, used-before-assignment, unneeded-not, duplicate-code, - cyclic-import + cyclic-import, + too-many-public-methods, + too-many-instance-attributes # Enable the message, report, category or checker with the given id(s). You can diff --git a/README.md b/README.md index 3ace54d3..a33e4fcf 100644 --- a/README.md +++ b/README.md @@ -136,6 +136,30 @@ Or you can get the latest pre-release version from Cloudsmith: pip install --upgrade cloudsmith-cli --extra-index-url=https://dl.cloudsmith.io/public/cloudsmith/cli/python/index/ ``` +### Optional Dependencies + +The CLI supports optional extras for additional functionality: + +#### AWS OIDC Support + +For AWS environments (ECS, EKS, EC2), install with `aws` extra to enable automatic credential discovery: + +``` +pip install cloudsmith-cli[aws] +``` + +This installs `boto3[crt]` for AWS credential chain support, STS token generation, and AWS SSO compatibility. + +#### All Optional Features + +To install all optional dependencies: + +``` +pip install cloudsmith-cli[all] +``` + +**Note:** If you don't install the AWS extra, the AWS OIDC detector will gracefully skip itself with no errors. + ## Configuration There are two configuration files used by the CLI: diff --git a/cloudsmith_cli/cli/commands/whoami.py b/cloudsmith_cli/cli/commands/whoami.py index 59fa31c2..319752d3 100644 --- a/cloudsmith_cli/cli/commands/whoami.py +++ b/cloudsmith_cli/cli/commands/whoami.py @@ -108,7 +108,12 @@ def _print_verbose_text(data): click.echo(f" Source: {ak['source']}") click.echo(" Note: SSO token is being used instead") elif active == "api_key": - click.secho("Authentication Method: API Key", fg="cyan", bold=True) + if ak.get("source_key") == "oidc": + click.secho( + "Authentication Method: OIDC Auto-Discovery", fg="cyan", bold=True + ) + else: + click.secho("Authentication Method: API Key", fg="cyan", bold=True) for label, field in [ ("Source", "source"), ("Token Slug", "slug"), diff --git a/cloudsmith_cli/cli/config.py b/cloudsmith_cli/cli/config.py index 2c1465e0..c73d567a 100644 --- a/cloudsmith_cli/cli/config.py +++ b/cloudsmith_cli/cli/config.py @@ -66,6 +66,9 @@ class Default(SectionSchema): api_user_agent = ConfigParam(name="api_user_agent", type=str) mcp_allowed_tools = ConfigParam(name="mcp_allowed_tools", type=str) mcp_allowed_tool_groups = ConfigParam(name="mcp_allowed_tool_groups", type=str) + oidc_audience = ConfigParam(name="oidc_audience", type=str) + oidc_org = ConfigParam(name="oidc_org", type=str) + oidc_service_slug = ConfigParam(name="oidc_service_slug", type=str) @matches_section("profile:*") class Profile(Default): @@ -416,6 +419,48 @@ def mcp_allowed_tool_groups(self, value): self._set_option("mcp_allowed_tool_groups", tool_groups) + @property + def oidc_audience(self): + """Get value for OIDC audience.""" + return self._get_option("oidc_audience") + + @oidc_audience.setter + def oidc_audience(self, value): + """Set value for OIDC audience.""" + self._set_option("oidc_audience", value) + + @property + def oidc_org(self): + """Get value for OIDC organisation slug.""" + return self._get_option("oidc_org") + + @oidc_org.setter + def oidc_org(self, value): + """Set value for OIDC organisation slug.""" + self._set_option("oidc_org", value) + + @property + def oidc_service_slug(self): + """Get value for OIDC service slug.""" + return self._get_option("oidc_service_slug") + + @oidc_service_slug.setter + def oidc_service_slug(self, value): + """Set value for OIDC service slug.""" + self._set_option("oidc_service_slug", value) + + @property + def oidc_discovery_disabled(self): + """Get value for OIDC discovery disabled flag.""" + return self._get_option("oidc_discovery_disabled", default=False) + + @oidc_discovery_disabled.setter + def oidc_discovery_disabled(self, value): + """Set value for OIDC discovery disabled flag.""" + self._set_option( + "oidc_discovery_disabled", bool(value) if value is not None else False + ) + @property def output(self): """Get value for output format.""" diff --git a/cloudsmith_cli/cli/decorators.py b/cloudsmith_cli/cli/decorators.py index 06ce7836..685e6ec0 100644 --- a/cloudsmith_cli/cli/decorators.py +++ b/cloudsmith_cli/cli/decorators.py @@ -293,12 +293,48 @@ def wrapper(ctx, *args, **kwargs): def resolve_credentials(f): """Resolve credentials via the provider chain. Depends on initialise_session.""" + @click.option( + "--oidc-audience", + envvar="CLOUDSMITH_OIDC_AUDIENCE", + help="The OIDC audience for token requests.", + ) + @click.option( + "--oidc-org", + envvar="CLOUDSMITH_ORG", + help="The Cloudsmith organisation slug for OIDC token exchange.", + ) + @click.option( + "--oidc-service-slug", + envvar="CLOUDSMITH_SERVICE_SLUG", + help="The Cloudsmith service slug for OIDC token exchange.", + ) + @click.option( + "--oidc-discovery-disabled", + default=None, + is_flag=True, + envvar="CLOUDSMITH_OIDC_DISCOVERY_DISABLED", + help="Disable OIDC auto-discovery.", + ) @click.pass_context @functools.wraps(f) def wrapper(ctx, *args, **kwargs): # pylint: disable=missing-docstring opts = config.get_or_create_options(ctx) + oidc_audience = kwargs.pop("oidc_audience") + oidc_org = kwargs.pop("oidc_org") + oidc_service_slug = kwargs.pop("oidc_service_slug") + oidc_discovery_disabled = _pop_boolean_flag(kwargs, "oidc_discovery_disabled") + + if oidc_audience: + opts.oidc_audience = oidc_audience + if oidc_org: + opts.oidc_org = oidc_org + if oidc_service_slug: + opts.oidc_service_slug = oidc_service_slug + if oidc_discovery_disabled: + opts.oidc_discovery_disabled = oidc_discovery_disabled + context = CredentialContext( session=opts.session, api_key=opts.api_key, @@ -306,6 +342,10 @@ def wrapper(ctx, *args, **kwargs): creds_file_path=ctx.meta.get("creds_file"), profile=ctx.meta.get("profile"), debug=opts.debug, + oidc_audience=opts.oidc_audience, + oidc_org=opts.oidc_org, + oidc_service_slug=opts.oidc_service_slug, + oidc_discovery_disabled=opts.oidc_discovery_disabled, ) chain = CredentialProviderChain() diff --git a/cloudsmith_cli/core/credentials/__init__.py b/cloudsmith_cli/core/credentials/__init__.py index d55413ce..1d668967 100644 --- a/cloudsmith_cli/core/credentials/__init__.py +++ b/cloudsmith_cli/core/credentials/__init__.py @@ -31,6 +31,10 @@ class CredentialContext: profile: str | None = None debug: bool = False keyring_refresh_failed: bool = False + oidc_audience: str | None = None + oidc_org: str | None = None + oidc_service_slug: str | None = None + oidc_discovery_disabled: bool = False @dataclass @@ -57,18 +61,19 @@ class CredentialProviderChain: """Evaluates credential providers in order, returning the first valid result. If no providers are given, uses the default chain: - Keyring → CLIFlag. + Keyring → CLIFlag → OIDC. """ def __init__(self, providers: list[CredentialProvider] | None = None): if providers is not None: self.providers = providers else: - from .providers import CLIFlagProvider, KeyringProvider + from .providers import CLIFlagProvider, KeyringProvider, OidcProvider self.providers = [ KeyringProvider(), CLIFlagProvider(), + OidcProvider(), ] def resolve(self, context: CredentialContext) -> CredentialResult | None: diff --git a/cloudsmith_cli/core/credentials/oidc/__init__.py b/cloudsmith_cli/core/credentials/oidc/__init__.py new file mode 100644 index 00000000..e3c414ab --- /dev/null +++ b/cloudsmith_cli/core/credentials/oidc/__init__.py @@ -0,0 +1,6 @@ +"""OIDC support for the Cloudsmith CLI credential chain. + +References: + https://help.cloudsmith.io/docs/openid-connect + https://cloudsmith.com/blog/securely-connect-cloudsmith-to-your-cicd-using-oidc-authentication +""" diff --git a/cloudsmith_cli/core/credentials/oidc/cache.py b/cloudsmith_cli/core/credentials/oidc/cache.py new file mode 100644 index 00000000..183a9615 --- /dev/null +++ b/cloudsmith_cli/core/credentials/oidc/cache.py @@ -0,0 +1,219 @@ +"""OIDC token cache. + +Caches Cloudsmith API tokens obtained via OIDC exchange to avoid unnecessary +re-exchanges. Uses system keyring when available (respecting CLOUDSMITH_NO_KEYRING), +with automatic fallback to filesystem storage when keyring is unavailable. +""" + +from __future__ import annotations + +import hashlib +import json +import logging +import os +import time + +logger = logging.getLogger(__name__) + +EXPIRY_MARGIN_SECONDS = 60 + +_CACHE_DIR_NAME = "oidc_token_cache" + + +def _get_cache_dir() -> str: + """Return the cache directory path, creating it if needed.""" + from ....cli.config import get_default_config_path + + base = get_default_config_path() + cache_dir = os.path.join(base, _CACHE_DIR_NAME) + if not os.path.isdir(cache_dir): + os.makedirs(cache_dir, mode=0o700, exist_ok=True) + return cache_dir + + +def _cache_key(api_host: str, org: str, service_slug: str) -> str: + """Compute a deterministic cache filename from the exchange parameters.""" + raw = f"{api_host}|{org}|{service_slug}" + digest = hashlib.sha256(raw.encode()).hexdigest()[:32] + return f"oidc_{digest}.json" + + +def _decode_jwt_exp(token: str) -> float | None: + """Decode the exp claim from a JWT without verification.""" + try: + import jwt + + payload = jwt.decode( + token, + options={"verify_signature": False}, + algorithms=["RS256", "ES256", "HS256"], + ) + exp = payload.get("exp") + if exp is not None: + return float(exp) + except Exception: # pylint: disable=broad-exception-caught + logger.debug("Failed to decode JWT expiry", exc_info=True) + return None + + +def get_cached_token(api_host: str, org: str, service_slug: str) -> str | None: + """Return a cached token if it exists and is not expired.""" + token = _get_from_keyring(api_host, org, service_slug) + if token: + return token + return _get_from_disk(api_host, org, service_slug) + + +def _get_from_keyring(api_host: str, org: str, service_slug: str) -> str | None: + """Try to get token from keyring.""" + try: + from ...keyring import get_oidc_token + + token_data = get_oidc_token(api_host, org, service_slug) + if not token_data: + return None + + data = json.loads(token_data) + token = data.get("token") + expires_at = data.get("expires_at") + + if not token: + return None + + if expires_at is not None: + remaining = expires_at - time.time() + if remaining < EXPIRY_MARGIN_SECONDS: + logger.debug( + "Keyring OIDC token expired or expiring soon " + "(%.0fs remaining, margin=%ds)", + remaining, + EXPIRY_MARGIN_SECONDS, + ) + from ...keyring import delete_oidc_token + + delete_oidc_token(api_host, org, service_slug) + return None + logger.debug("Using keyring OIDC token (expires in %.0fs)", remaining) + else: + logger.debug("Using keyring OIDC token (no expiry information)") + + return token + + except Exception: # pylint: disable=broad-exception-caught + logger.debug("Failed to read OIDC token from keyring", exc_info=True) + return None + + +def _get_from_disk(api_host: str, org: str, service_slug: str) -> str | None: + """Try to get token from disk cache.""" + cache_dir = _get_cache_dir() + cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug)) + + if not os.path.isfile(cache_file): + return None + + try: + with open(cache_file) as f: + data = json.load(f) + + token = data.get("token") + expires_at = data.get("expires_at") + + if not token: + return None + + if expires_at is not None: + remaining = expires_at - time.time() + if remaining < EXPIRY_MARGIN_SECONDS: + logger.debug( + "Disk cached OIDC token expired or expiring soon " + "(%.0fs remaining, margin=%ds)", + remaining, + EXPIRY_MARGIN_SECONDS, + ) + _remove_cache_file(cache_file) + return None + logger.debug("Using disk cached OIDC token (expires in %.0fs)", remaining) + else: + logger.debug("Using disk cached OIDC token (no expiry information)") + + return token + + except (json.JSONDecodeError, OSError, KeyError): + logger.debug("Failed to read OIDC token from disk cache", exc_info=True) + _remove_cache_file(cache_file) + return None + + +def store_cached_token(api_host: str, org: str, service_slug: str, token: str) -> None: + """Cache a token in keyring (if available) or filesystem.""" + expires_at = _decode_jwt_exp(token) + + data = { + "token": token, + "expires_at": expires_at, + "api_host": api_host, + "org": org, + "service_slug": service_slug, + "cached_at": time.time(), + } + + if _store_in_keyring(api_host, org, service_slug, data): + return + + _store_on_disk(api_host, org, service_slug, data) + + +def _store_in_keyring(api_host: str, org: str, service_slug: str, data: dict) -> bool: + """Try to store token in keyring.""" + try: + from ...keyring import store_oidc_token + + token_data = json.dumps(data) + success = store_oidc_token(api_host, org, service_slug, token_data) + if success: + logger.debug( + "Stored OIDC token in keyring (expires_at=%s)", data.get("expires_at") + ) + return success + except Exception: # pylint: disable=broad-exception-caught + logger.debug("Failed to store OIDC token in keyring", exc_info=True) + return False + + +def _store_on_disk(api_host: str, org: str, service_slug: str, data: dict) -> None: + """Store token on disk.""" + cache_dir = _get_cache_dir() + cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug)) + + try: + fd = os.open(cache_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, "w") as f: + json.dump(data, f) + logger.debug( + "Stored OIDC token on disk (expires_at=%s)", data.get("expires_at") + ) + except OSError: + logger.debug("Failed to write OIDC token to disk cache", exc_info=True) + + +def invalidate_cached_token(api_host: str, org: str, service_slug: str) -> None: + """Remove a cached token from both keyring and disk.""" + try: + from ...keyring import delete_oidc_token + + delete_oidc_token(api_host, org, service_slug) + except Exception: # pylint: disable=broad-exception-caught + logger.debug("Failed to delete OIDC token from keyring", exc_info=True) + + cache_dir = _get_cache_dir() + cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug)) + _remove_cache_file(cache_file) + + +def _remove_cache_file(path: str) -> None: + """Safely remove a cache file.""" + try: + os.unlink(path) + except (OSError, TypeError): + pass diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py new file mode 100644 index 00000000..9b88077c --- /dev/null +++ b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py @@ -0,0 +1,42 @@ +"""Environment detectors for OIDC token retrieval.""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +from .aws import AWSDetector +from .base import EnvironmentDetector + +if TYPE_CHECKING: + from ... import CredentialContext + +logger = logging.getLogger(__name__) + +_DETECTORS: list[type[EnvironmentDetector]] = [ + AWSDetector, +] + + +def detect_environment( + context: CredentialContext, +) -> EnvironmentDetector | None: + """Try each detector in order, returning the first that matches.""" + for detector_cls in _DETECTORS: + detector = detector_cls(context=context) + try: + if detector.detect(): + if context.debug: + logger.debug("Detected OIDC environment: %s", detector.name) + return detector + except Exception: # pylint: disable=broad-exception-caught + logger.debug( + "Detector %s raised an exception during detection", + detector.name, + exc_info=True, + ) + continue + + if context.debug: + logger.debug("No supported OIDC environment detected") + return None diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/aws.py b/cloudsmith_cli/core/credentials/oidc/detectors/aws.py new file mode 100644 index 00000000..81c2a765 --- /dev/null +++ b/cloudsmith_cli/core/credentials/oidc/detectors/aws.py @@ -0,0 +1,84 @@ +"""AWS OIDC detector. + +Uses boto3 to auto-discover AWS credentials and calls STS GetWebIdentityToken +to obtain a signed JWT for Cloudsmith. + +Requires boto3 (optional dependency): pip install cloudsmith-cli[aws] + +References: + https://cloudsmith.com/blog/authenticate-to-cloudsmith-with-your-aws-identity +""" + +from __future__ import annotations + +import logging + +from .base import EnvironmentDetector + +logger = logging.getLogger(__name__) + +DEFAULT_AUDIENCE = "cloudsmith" + + +class AWSDetector(EnvironmentDetector): + """Detects AWS environments and obtains a JWT via STS GetWebIdentityToken.""" + + name = "AWS" + + def __init__(self, context): + super().__init__(context) + self._session = None + + def detect(self) -> bool: + try: + import boto3 + from botocore.exceptions import ( + BotoCoreError, + ClientError, + MissingDependencyException, + NoCredentialsError, + ) + except ImportError: + logger.debug("AWSDetector: boto3 not installed, skipping") + return False + + try: + self._session = boto3.Session() + credentials = self._session.get_credentials() + if credentials is None: + return False + # Resolve to verify credentials are usable + credentials = credentials.get_frozen_credentials() + return bool(credentials.access_key) + except MissingDependencyException as e: + logger.debug( + "AWSDetector: Missing boto3 dependency for SSO credentials: %s. " + "Install with: pip install 'botocore[crt]' or 'boto3[crt]'", + e, + ) + return False + except (BotoCoreError, NoCredentialsError, ClientError): + return False + except Exception: # pylint: disable=broad-exception-caught + logger.debug( + "AWSDetector: unexpected error during detection", exc_info=True + ) + return False + + def get_token(self) -> str: + import boto3 # pylint: disable=import-error + + audience = self.context.oidc_audience or DEFAULT_AUDIENCE + session = self._session or boto3.Session() + sts = session.client("sts") + response = sts.get_web_identity_token( + Audience=[audience], + SigningAlgorithm="RS256", + ) + + token = response.get("WebIdentityToken") + if not token: + raise ValueError( + "AWS STS GetWebIdentityToken did not return a WebIdentityToken" + ) + return token diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/base.py b/cloudsmith_cli/core/credentials/oidc/detectors/base.py new file mode 100644 index 00000000..9bdfb249 --- /dev/null +++ b/cloudsmith_cli/core/credentials/oidc/detectors/base.py @@ -0,0 +1,25 @@ +"""Base class and utilities for OIDC environment detectors.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from ... import CredentialContext + + +class EnvironmentDetector: + """Base class for OIDC environment detectors.""" + + name: str = "base" + + def __init__(self, context: CredentialContext): + self.context = context + + def detect(self) -> bool: + """Return True if running in a supported OIDC environment.""" + raise NotImplementedError + + def get_token(self) -> str: + """Retrieve the OIDC JWT from this environment. Raises on failure.""" + raise NotImplementedError diff --git a/cloudsmith_cli/core/credentials/oidc/exchange.py b/cloudsmith_cli/core/credentials/oidc/exchange.py new file mode 100644 index 00000000..a4e51f62 --- /dev/null +++ b/cloudsmith_cli/core/credentials/oidc/exchange.py @@ -0,0 +1,87 @@ +"""Cloudsmith OIDC token exchange. + +Exchanges a vendor OIDC JWT for a short-lived Cloudsmith API token +via the POST /openid/{org}/ endpoint. + +References: + https://help.cloudsmith.io/docs/openid-connect +""" + +from __future__ import annotations + +import logging +from typing import TYPE_CHECKING + +import requests + +from ..session import create_session + +if TYPE_CHECKING: + from ... import CredentialContext + +logger = logging.getLogger(__name__) + + +def exchange_oidc_token( + context: CredentialContext, + org: str, + service_slug: str, + oidc_token: str, +) -> str: + """Exchange a vendor OIDC JWT for a Cloudsmith API token. + + Raises: + OidcExchangeError: If the exchange fails. + """ + host = context.api_host.rstrip("/") + if not host.startswith("http"): + host = f"https://{host}" + + url = f"{host}/openid/{org}/" + payload = { + "oidc_token": oidc_token, + "service_slug": service_slug, + } + + session = context.session or create_session() + + try: + try: + response = session.post( + url, + json=payload, + timeout=30, + ) + except requests.exceptions.RequestException as exc: + raise OidcExchangeError( + f"OIDC token exchange request failed: {exc}" + ) from exc + + if response.status_code in (200, 201): + data = response.json() + token = data.get("token") + if not token or not isinstance(token, str) or not token.strip(): + raise OidcExchangeError( + "Cloudsmith OIDC exchange returned an empty or invalid token" + ) + return token + + try: + error_json = response.json() + error_detail = error_json.get( + "detail", error_json.get("error", str(error_json)) + ) + except Exception: # pylint: disable=broad-exception-caught + error_detail = response.text[:200] + + raise OidcExchangeError( + f"OIDC token exchange failed with {response.status_code}: " + f"{error_detail}" + ) + finally: + if not context.session: + session.close() + + +class OidcExchangeError(Exception): + """Raised when the OIDC token exchange with Cloudsmith fails.""" diff --git a/cloudsmith_cli/core/credentials/providers/__init__.py b/cloudsmith_cli/core/credentials/providers/__init__.py index 5482e397..d67db9a2 100644 --- a/cloudsmith_cli/core/credentials/providers/__init__.py +++ b/cloudsmith_cli/core/credentials/providers/__init__.py @@ -2,8 +2,10 @@ from .cli_flag import CLIFlagProvider from .keyring_provider import KeyringProvider +from .oidc_provider import OidcProvider __all__ = [ "CLIFlagProvider", "KeyringProvider", + "OidcProvider", ] diff --git a/cloudsmith_cli/core/credentials/providers/oidc_provider.py b/cloudsmith_cli/core/credentials/providers/oidc_provider.py new file mode 100644 index 00000000..5b423037 --- /dev/null +++ b/cloudsmith_cli/core/credentials/providers/oidc_provider.py @@ -0,0 +1,114 @@ +"""OIDC credential provider.""" + +from __future__ import annotations + +import logging + +from .. import CredentialContext, CredentialProvider, CredentialResult + +logger = logging.getLogger(__name__) + + +class OidcProvider(CredentialProvider): + """Resolves credentials via OIDC auto-discovery. + + Requires CLOUDSMITH_ORG and CLOUDSMITH_SERVICE_SLUG to be set (via env + vars or click options). Auto-detects the environment, fetches the vendor + OIDC JWT, and exchanges it for a short-lived Cloudsmith API token. + """ + + name = "oidc" + + def resolve( # pylint: disable=too-many-return-statements + self, context: CredentialContext + ) -> CredentialResult | None: + if context.oidc_discovery_disabled: + if context.debug: + logger.debug( + "OidcProvider: OIDC auto-discovery disabled via " + "CLOUDSMITH_OIDC_DISCOVERY_DISABLED" + ) + return None + + org = context.oidc_org + service_slug = context.oidc_service_slug + + if not org or not service_slug: + if context.debug: + logger.debug( + "OidcProvider: CLOUDSMITH_ORG and/or CLOUDSMITH_SERVICE_SLUG " + "not set, skipping OIDC auto-discovery" + ) + return None + + from ..oidc.cache import get_cached_token, store_cached_token + + # Check cache BEFORE environment detection — detection can be expensive + # (e.g. boto3 credential resolution, IMDS calls) and is unnecessary when + # we already hold a valid exchanged token. + cached = get_cached_token(context.api_host, org, service_slug) + if cached: + logger.debug("OidcProvider: Using cached OIDC token") + return CredentialResult( + api_key=cached, + source_name="oidc", + source_detail=f"OIDC [cached] (org: {org}, service: {service_slug})", + ) + + from ..oidc.detectors import detect_environment + from ..oidc.exchange import OidcExchangeError, exchange_oidc_token + + detector = detect_environment(context=context) + if detector is None: + if context.debug: + logger.debug( + "OidcProvider: No supported OIDC environment detected, skipping" + ) + return None + + try: + vendor_token = detector.get_token() + except Exception: # pylint: disable=broad-exception-caught + logger.warning( + "OIDC: Failed to retrieve identity token from %s. " + "Use --debug for details.", + detector.name, + ) + logger.debug( + "OidcProvider: %s token retrieval error", + detector.name, + exc_info=True, + ) + return None + + if not vendor_token: + logger.warning("OIDC: %s detector returned an empty token.", detector.name) + return None + + try: + cloudsmith_token = exchange_oidc_token( + context=context, + org=org, + service_slug=service_slug, + oidc_token=vendor_token, + ) + except OidcExchangeError as exc: + logger.warning("OIDC: Token exchange failed: %s", exc) + return None + except Exception: # pylint: disable=broad-exception-caught + logger.warning( + "OIDC: Token exchange failed unexpectedly. Use --debug for details." + ) + logger.debug("OidcProvider: OIDC token exchange error", exc_info=True) + return None + + if not cloudsmith_token: + return None + + store_cached_token(context.api_host, org, service_slug, cloudsmith_token) + + return CredentialResult( + api_key=cloudsmith_token, + source_name="oidc", + source_detail=f"OIDC via {detector.name} (org: {org}, service: {service_slug})", + ) diff --git a/cloudsmith_cli/core/keyring.py b/cloudsmith_cli/core/keyring.py index 974598dd..7aa12e98 100644 --- a/cloudsmith_cli/core/keyring.py +++ b/cloudsmith_cli/core/keyring.py @@ -138,3 +138,34 @@ def delete_sso_tokens(api_host): """Delete all SSO tokens from the keyring for the given host.""" results = [_delete_value(key) for key in _sso_keys(api_host)] return any(results) + + +OIDC_TOKEN_KEY = "cloudsmith_cli-oidc_token-{api_host}-{org}-{service_slug}" + + +def store_oidc_token(api_host, org, service_slug, token_data): + """Store OIDC token in keyring if enabled.""" + if not should_use_keyring(): + return False + + key = OIDC_TOKEN_KEY.format(api_host=api_host, org=org, service_slug=service_slug) + try: + _set_value(key, token_data) + return True + except KeyringError: + return False + + +def get_oidc_token(api_host, org, service_slug): + """Retrieve OIDC token from keyring.""" + if not should_use_keyring(): + return None + + key = OIDC_TOKEN_KEY.format(api_host=api_host, org=org, service_slug=service_slug) + return _get_value(key) + + +def delete_oidc_token(api_host, org, service_slug): + """Delete OIDC token from keyring.""" + key = OIDC_TOKEN_KEY.format(api_host=api_host, org=org, service_slug=service_slug) + return _delete_value(key) diff --git a/cloudsmith_cli/core/tests/test_credential_provider_chain.py b/cloudsmith_cli/core/tests/test_credential_provider_chain.py index 94eb1e46..8b9f9884 100644 --- a/cloudsmith_cli/core/tests/test_credential_provider_chain.py +++ b/cloudsmith_cli/core/tests/test_credential_provider_chain.py @@ -75,6 +75,7 @@ def test_empty_chain(self): def test_default_chain_order(self): chain = CredentialProviderChain() - assert len(chain.providers) == 2 + assert len(chain.providers) == 3 assert chain.providers[0].name == "keyring" assert chain.providers[1].name == "cli_flag" + assert chain.providers[2].name == "oidc" diff --git a/requirements.txt b/requirements.txt index 1801940e..fcab57e2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -98,12 +98,16 @@ json5==0.13.0 # via cloudsmith-cli (setup.py) keyring==25.7.0 # via cloudsmith-cli (setup.py) +markdown-it-py==4.0.0 + # via rich mccabe==0.7.0 # via pylint mcp==1.9.1 # via # -r requirements.in # cloudsmith-cli (setup.py) +mdurl==0.1.2 + # via markdown-it-py more-itertools==10.8.0 # via # jaraco-classes @@ -136,7 +140,11 @@ pydantic-core==2.41.5 pydantic-settings==2.12.0 # via mcp pygments==2.19.2 - # via pytest + # via + # pytest + # rich +pyjwt==2.12.1 + # via cloudsmith-cli (setup.py) pylint==4.0.4 # via -r requirements.in pyproject-hooks==1.2.0 @@ -155,6 +163,10 @@ python-dotenv==1.2.1 # via pydantic-settings python-multipart==0.0.21 # via mcp +python-toon==0.1.2 + # via + # -r requirements.in + # cloudsmith-cli (setup.py) pyyaml==6.0.3 # via pre-commit requests==2.32.5 @@ -187,10 +199,6 @@ tomli==2.4.0 # pytest tomlkit==0.14.0 # via pylint -python-toon==0.1.2 - # via - # -r requirements.in - # cloudsmith-cli (setup.py) typing-extensions==4.15.0 # via # anyio @@ -198,6 +206,7 @@ typing-extensions==4.15.0 # exceptiongroup # pydantic # pydantic-core + # pyjwt # starlette # typing-inspection # uvicorn diff --git a/setup.py b/setup.py index e6775e2d..5550b53b 100644 --- a/setup.py +++ b/setup.py @@ -57,6 +57,7 @@ def get_long_description(): "cloudsmith-api>=2.0.24,<3.0", # Compatible upto (but excluding) 3.0+ "keyring>=25.4.1", "mcp==1.9.1", + "PyJWT>=2.0.0", "python-toon==0.1.2", "requests>=2.18.4", "requests_toolbelt>=1.0.0", @@ -64,6 +65,14 @@ def get_long_description(): "semver>=2.7.9", "urllib3>=2.5", ], + extras_require={ + "aws": [ + "boto3[crt]>=1.26.0", + ], + "all": [ + "boto3[crt]>=1.26.0", + ], + }, entry_points={ "console_scripts": ["cloudsmith=cloudsmith_cli.cli.commands.main:main"] },