-
Notifications
You must be signed in to change notification settings - Fork 37
feat: Add OIDC authentication with automatic CI/CD platform detection #267
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -17,6 +17,7 @@ | |
| metrics, | ||
| move, | ||
| policy, | ||
| print_token, | ||
| push, | ||
| quarantine, | ||
| quota, | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,104 @@ | ||
| """CLI/Commands - Print the active authentication token.""" | ||
|
|
||
| import click | ||
|
|
||
| from .. import decorators, utils | ||
| from .main import main | ||
|
|
||
|
|
||
| def _extract_token(api_config): | ||
| """Extract the active token from the API configuration. | ||
|
|
||
| Returns (token, token_type) where token_type is 'bearer' or 'api_key'. | ||
| """ | ||
| headers = getattr(api_config, "headers", {}) or {} | ||
| auth_header = headers.get("Authorization", "") | ||
|
|
||
| if auth_header.startswith("Bearer "): | ||
| return auth_header[len("Bearer ") :], "bearer" | ||
|
|
||
| api_keys = getattr(api_config, "api_key", {}) or {} | ||
| api_key = api_keys.get("X-Api-Key") | ||
| if api_key: | ||
| return api_key, "api_key" | ||
|
|
||
| return None, None | ||
|
|
||
|
|
||
| @main.command(name="print-token") | ||
| @decorators.common_cli_config_options | ||
| @decorators.common_cli_output_options | ||
| @decorators.common_api_auth_options | ||
| @decorators.initialise_api | ||
| @click.pass_context | ||
| def token(ctx, opts): | ||
| """Print the active authentication token. | ||
|
|
||
| Outputs the token currently used to authenticate with the Cloudsmith API. | ||
| This is useful for passing to other tools like curl, docker, pip, etc. | ||
|
|
||
| Note: This prints your CURRENT/ACTIVE token. To LOGIN and get a NEW token | ||
| interactively, use 'cloudsmith login' (or its alias 'cloudsmith token'). | ||
|
|
||
| ⚠️ WARNING: This command prints sensitive credentials to stdout. | ||
| Avoid running this command in logged/recorded terminal sessions. | ||
| The token will appear in your shell history if stored in a variable. | ||
|
|
||
| For safer usage, pipe directly to another command without storing | ||
| in variables. | ||
|
|
||
| \b | ||
| Examples: | ||
| # Use with curl (pipe directly) | ||
| cloudsmith print-token | xargs -I{} curl -H "X-Api-Key: {}" https://api.cloudsmith.io/v1/user/self/ | ||
|
|
||
| # Use with docker login (pipe to stdin) | ||
| cloudsmith print-token | docker login docker.cloudsmith.io -u token --password-stdin | ||
|
|
||
| # Avoid: storing in shell variable (appears in history) | ||
| # export CS_TOKEN=$(cloudsmith print-token) # NOT RECOMMENDED | ||
|
|
||
| \b | ||
| See also: | ||
| cloudsmith login Interactive login to get a NEW token | ||
| cloudsmith whoami Show current authentication status | ||
| """ | ||
| active_token, token_type = _extract_token(opts.api_config) | ||
|
|
||
| if not active_token: | ||
| click.secho("Error: No authentication token available", fg="red", err=True) | ||
| click.echo(err=True) | ||
| click.echo("Try one of these commands to authenticate:", err=True) | ||
| click.echo( | ||
| " " | ||
| + click.style("cloudsmith login", fg="cyan") | ||
| + " # Interactive login", | ||
| err=True, | ||
| ) | ||
| click.echo( | ||
| " " | ||
| + click.style("cloudsmith authenticate", fg="cyan") | ||
| + " # SAML/SSO login", | ||
| err=True, | ||
| ) | ||
| click.echo( | ||
| " " | ||
| + click.style("export CLOUDSMITH_API_KEY=...", fg="cyan") | ||
| + " # Set via environment variable", | ||
| err=True, | ||
| ) | ||
| click.echo(err=True) | ||
| click.echo( | ||
| "For OIDC auto-discovery, set CLOUDSMITH_ORG and CLOUDSMITH_SERVICE_SLUG", | ||
| err=True, | ||
| ) | ||
| ctx.exit(1) | ||
|
|
||
| if utils.maybe_print_as_json( | ||
| opts, | ||
| {"token": active_token, "type": token_type}, | ||
| ): | ||
| return | ||
|
|
||
| # Print bare token to stdout (stderr used for any messages) | ||
| click.echo(active_token) | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -27,27 +27,74 @@ def _get_api_key_source(opts): | |
| """Determine where the API key was loaded from. | ||
|
|
||
| Checks in priority order matching actual resolution: | ||
| CLI --api-key flag > CLOUDSMITH_API_KEY env var > credentials.ini. | ||
| CLI --api-key flag > CLOUDSMITH_API_KEY env var > credentials.ini > OIDC. | ||
| """ | ||
| if not opts.api_key: | ||
| # Check if ANY API key is configured (from any source) | ||
| api_key_configured = opts.api_key or ( | ||
| hasattr(opts, "api_config") and opts.api_config and opts.api_config.api_key | ||
| ) | ||
|
|
||
| if not api_key_configured: | ||
| return {"configured": False, "source": None, "source_key": None} | ||
|
|
||
| env_key = os.environ.get("CLOUDSMITH_API_KEY") | ||
|
|
||
| # If env var is set but differs from the resolved key, CLI flag won | ||
| if env_key and opts.api_key != env_key: | ||
| source, key = "CLI --api-key flag", "cli_flag" | ||
| # If CLI --api-key flag was explicitly passed | ||
| if opts.api_key: | ||
| # If env var is set but differs from the CLI flag, CLI flag won | ||
| if env_key and opts.api_key != env_key: | ||
| source, key = "CLI --api-key flag", "cli_flag" | ||
| # If env var is set and matches, it's actually from env var | ||
| elif env_key and opts.api_key == env_key: | ||
| suffix = env_key[-4:] | ||
| source, key = ( | ||
| f"CLOUDSMITH_API_KEY env var (ends with ...{suffix})", | ||
| "env_var", | ||
| ) | ||
| # CLI flag was set explicitly (not from env) | ||
| else: | ||
| source, key = "CLI --api-key flag", "cli_flag" | ||
| # No CLI flag, check other sources | ||
|
Comment on lines
+32
to
+57
|
||
| elif env_key: | ||
| suffix = env_key[-4:] | ||
| source, key = f"CLOUDSMITH_API_KEY env var (ends with ...{suffix})", "env_var" | ||
| elif creds := CredentialsReader.find_existing_files(): | ||
| source, key = f"credentials.ini ({creds[0]})", "credentials_file" | ||
| elif _is_oidc_configured(): | ||
| org = os.environ.get("CLOUDSMITH_ORG", "") | ||
| detector_name = _get_oidc_detector_name() | ||
| if detector_name: | ||
| source = f"OIDC auto-discovery: {detector_name} (org: {org})" | ||
| else: | ||
| source = f"OIDC auto-discovery (org: {org})" | ||
| key = "oidc" | ||
| else: | ||
| source, key = "CLI --api-key flag", "cli_flag" | ||
| source, key = "Unknown source", "unknown" | ||
|
|
||
| return {"configured": True, "source": source, "source_key": key} | ||
|
|
||
|
|
||
| def _get_oidc_detector_name(): | ||
| """Get the name of the OIDC detector that would be used.""" | ||
| try: | ||
| from cloudsmith_cli.core.credentials.oidc.detectors import detect_environment | ||
|
|
||
| detector = detect_environment(debug=False) | ||
| if detector: | ||
| return detector.name | ||
| except Exception: # pylint: disable=broad-exception-caught | ||
| # Gracefully handle any detection failures - this is for display only | ||
| pass | ||
| return None | ||
|
|
||
|
|
||
| def _is_oidc_configured(): | ||
| """Check if OIDC environment variables are set.""" | ||
| return bool( | ||
| os.environ.get("CLOUDSMITH_ORG") and os.environ.get("CLOUDSMITH_SERVICE_SLUG") | ||
| ) | ||
|
|
||
|
|
||
| def _get_sso_status(api_host): | ||
| """Return SSO token status from the system keyring.""" | ||
| enabled = keyring.should_use_keyring() | ||
|
|
@@ -120,14 +167,23 @@ 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"), | ||
| ("Created", "created"), | ||
| ]: | ||
| if ak.get(field): | ||
| click.echo(f" {label}: {ak[field]}") | ||
| click.echo() | ||
| click.echo( | ||
| "💡 Export this token: " + click.style("cloudsmith print-token", fg="cyan") | ||
| ) | ||
| else: | ||
| click.secho("Authentication Method: None (anonymous)", fg="yellow", bold=True) | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,6 +1,7 @@ | ||
| """Cloudsmith API - Initialisation.""" | ||
|
|
||
| import base64 | ||
| import logging | ||
| from typing import Type, TypeVar | ||
|
|
||
| import click | ||
|
|
@@ -11,6 +12,37 @@ | |
| from ..rest import RestClient | ||
| from .exceptions import ApiException | ||
|
|
||
| logger = logging.getLogger(__name__) | ||
|
|
||
|
|
||
| def _try_oidc_credential(config): | ||
| """Attempt OIDC auto-discovery as a last-resort credential provider. | ||
|
|
||
| Only activates when CLOUDSMITH_ORG and CLOUDSMITH_SERVICE_SLUG are set. | ||
| """ | ||
| from ..credentials import CredentialContext | ||
| from ..credentials.providers import OidcProvider | ||
|
|
||
| context = CredentialContext( | ||
| api_host=config.host, | ||
| debug=config.debug, | ||
| proxy=config.proxy, | ||
| ssl_verify=config.verify_ssl, | ||
| user_agent=config.user_agent, | ||
| headers=config.headers.copy() if config.headers else None, | ||
| ) | ||
|
|
||
| provider = OidcProvider() | ||
| result = provider.resolve(context) | ||
|
|
||
| if result is not None: | ||
| config.api_key["X-Api-Key"] = result.api_key | ||
|
|
||
| if config.debug: | ||
| click.echo(f"OIDC credential resolved: {result.source_detail}") | ||
| elif config.debug: | ||
| logger.debug("OIDC auto-discovery did not resolve credentials") | ||
|
Comment on lines
18
to
44
|
||
|
|
||
|
|
||
| def initialise_api( | ||
| debug=False, | ||
|
|
@@ -104,6 +136,9 @@ def initialise_api( | |
|
|
||
| if config.debug: | ||
| click.echo("User API key config value set") | ||
| else: | ||
| # No access token and no API key provided — try OIDC auto-discovery | ||
| _try_oidc_credential(config) | ||
|
|
||
| auth_header = headers and config.headers.get("Authorization") | ||
| if auth_header and " " in auth_header: | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
New source files in this repo appear to include a copyright header comment near the top (e.g.,
cloudsmith_cli/cli/commands/logout.py:1). Consider adding# Copyright 2026 Cloudsmith Ltdabove the module docstring here to match that convention.