Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

- Added `CLOUDSMITH_NO_KEYRING` environment variable to disable keyring usage globally. Set `CLOUDSMITH_NO_KEYRING=1` to skip system keyring operations.
- Added `--request-api-key` flag to `cloudsmith auth` command for fully automated, non-interactive API token retrieval. Auto-creates a token if none exists, or auto-rotates (with warning) if one already exists. Compatible with `--save-config` and `CLOUDSMITH_NO_KEYRING`.
- Added `--verbose` (`-v`) flag to `cloudsmith whoami` to show detailed authentication information including active method (API Key or SSO Token), credential source, token metadata, and SSO status. Supports `--output-format json`.
- Added `cloudsmith logout` command to clear stored authentication credentials and SSO tokens.
- Clears credentials from `credentials.ini` and SSO tokens from the system keyring
- `--keyring-only` to only clear SSO tokens from the system keyring
Expand Down
169 changes: 150 additions & 19 deletions cloudsmith_cli/cli/commands/whoami.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,150 @@
"""CLI/Commands - Get an API token."""
"""CLI/Commands - Retrieve authentication status."""

import os

import click

from ...core.api.user import get_user_brief
from ...core import keyring
from ...core.api.exceptions import ApiException
from ...core.api.user import get_token_metadata, get_user_brief
from .. import decorators, utils
from ..config import CredentialsReader
from ..exceptions import handle_api_exceptions
from .main import main


def _get_active_method(api_config):
"""Inspect API config to determine SSO, API key, or no auth."""
headers = getattr(api_config, "headers", {}) or {}
if headers.get("Authorization", "").startswith("Bearer "):
return "sso_token"
if (getattr(api_config, "api_key", {}) or {}).get("X-Api-Key"):
return "api_key"
return "none"


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.
"""
if not opts.api_key:
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"
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"
else:
source, key = "CLI --api-key flag", "cli_flag"

return {"configured": True, "source": source, "source_key": key}


def _get_sso_status(api_host):
"""Return SSO token status from the system keyring."""
enabled = keyring.should_use_keyring()
has_tokens = enabled and keyring.has_sso_tokens(api_host)
refreshed = keyring.get_refresh_attempted_at(api_host) if has_tokens else None

return {
"configured": has_tokens,
"keyring_enabled": enabled,
"source": "System Keyring" if has_tokens else None,
"last_refreshed": utils.fmt_datetime(refreshed) if refreshed else None,
}


def _get_verbose_auth_data(opts, api_host):
"""Gather all auth details for verbose output."""
api_key_info = _get_api_key_source(opts)
sso_info = _get_sso_status(api_host)

# Fetch token metadata (extra API call, graceful fallback)
token_meta = None
if api_key_info["configured"]:
try:
token_meta = get_token_metadata()
except ApiException:
token_meta = None

created = token_meta.get("created") if token_meta else None
api_key_info["slug"] = token_meta["slug"] if token_meta else None
api_key_info["created"] = utils.fmt_datetime(created) if created else None

return {
"active_method": _get_active_method(opts.api_config),
"api_key": api_key_info,
"sso": sso_info,
}


def _print_user_line(name, username, email):
"""Print a styled user identity line."""
styled_name = click.style(name or "Unknown", fg="cyan")
styled_slug = click.style(username or "Unknown", fg="magenta")
email_part = f", email: {click.style(email, fg='green')}" if email else ""
click.echo(f"User: {styled_name} (slug: {styled_slug}{email_part})")


def _print_verbose_text(data):
"""Print verbose authentication details as styled text."""
click.echo()
_print_user_line(data["name"], data["username"], data.get("email"))

auth = data["auth"]
active = auth["active_method"]
ak = auth["api_key"]
sso = auth["sso"]

click.echo()
if active == "sso_token":
click.secho("Authentication Method: SSO Token (primary)", fg="cyan", bold=True)
if sso.get("source"):
click.echo(f" Source: {sso['source']}")
if sso.get("last_refreshed"):
click.echo(
f" Last Refreshed: {sso['last_refreshed']} (refreshes every 30 min)"
)
if ak["configured"]:
click.echo()
click.secho("API Key: Also configured", fg="yellow")
if ak.get("source"):
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)
for label, field in [
("Source", "source"),
("Token Slug", "slug"),
("Created", "created"),
]:
if ak.get(field):
click.echo(f" {label}: {ak[field]}")
else:
click.secho("Authentication Method: None (anonymous)", fg="yellow", bold=True)

if active != "sso_token":
click.echo()
if not sso["keyring_enabled"]:
click.secho(
"SSO Status: Keyring disabled (CLOUDSMITH_NO_KEYRING)", fg="yellow"
)
elif sso["configured"]:
click.secho("SSO Status: Configured (not active)", fg="yellow")
click.echo(f" Source: {sso['source']}")
else:
click.echo("SSO Status: Not configured")
click.echo(" Keyring: Enabled (no tokens stored)")


@main.command()
@decorators.common_cli_config_options
@decorators.common_cli_output_options
Expand Down Expand Up @@ -37,26 +174,20 @@ def whoami(ctx, opts):
"name": name,
}

if opts.verbose:
api_host = getattr(opts.api_config, "host", None) or opts.api_host
data["auth"] = _get_verbose_auth_data(opts, api_host)

if utils.maybe_print_as_json(opts, data):
return

click.echo("You are authenticated as:")
if not is_auth:
click.echo("You are authenticated as:")
click.secho("Nobody (i.e. anonymous user)", fg="yellow")
else:
click.secho(
"%(name)s (slug: %(username)s"
% {
"name": click.style(name, fg="cyan"),
"username": click.style(username, fg="magenta"),
},
nl=False,
)

if email:
click.secho(
f", email: {click.style(email, fg='green')}",
nl=False,
)
return

click.echo(")")
if opts.verbose:
_print_verbose_text(data)
else:
click.echo("You are authenticated as:")
_print_user_line(name, username, email)
10 changes: 10 additions & 0 deletions cloudsmith_cli/core/api/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,3 +97,13 @@ def refresh_user_token(token_slug: str) -> dict:

ratelimits.maybe_rate_limit(client, headers)
return data


def get_token_metadata() -> dict | None:
"""Retrieve metadata for the user's first API token.

Raises ApiException on failure; callers should handle gracefully.
"""
if t := next(iter(list_user_tokens()), None):
return {"slug": t.slug_perm, "created": t.created}
return None