Skip to content

Commit 5aa1a42

Browse files
feat(CENG-686): Add --verbose output for cloudsmith whoami command (#264)
* add --verbose output to cloudsmith whoami command exposing more info about current authentication methods
1 parent 0c996cf commit 5aa1a42

File tree

3 files changed

+161
-19
lines changed

3 files changed

+161
-19
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1111

1212
- Added `CLOUDSMITH_NO_KEYRING` environment variable to disable keyring usage globally. Set `CLOUDSMITH_NO_KEYRING=1` to skip system keyring operations.
1313
- 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`.
14+
- 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`.
1415
- Added `cloudsmith logout` command to clear stored authentication credentials and SSO tokens.
1516
- Clears credentials from `credentials.ini` and SSO tokens from the system keyring
1617
- `--keyring-only` to only clear SSO tokens from the system keyring

cloudsmith_cli/cli/commands/whoami.py

Lines changed: 150 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,150 @@
1-
"""CLI/Commands - Get an API token."""
1+
"""CLI/Commands - Retrieve authentication status."""
2+
3+
import os
24

35
import click
46

5-
from ...core.api.user import get_user_brief
7+
from ...core import keyring
8+
from ...core.api.exceptions import ApiException
9+
from ...core.api.user import get_token_metadata, get_user_brief
610
from .. import decorators, utils
11+
from ..config import CredentialsReader
712
from ..exceptions import handle_api_exceptions
813
from .main import main
914

1015

16+
def _get_active_method(api_config):
17+
"""Inspect API config to determine SSO, API key, or no auth."""
18+
headers = getattr(api_config, "headers", {}) or {}
19+
if headers.get("Authorization", "").startswith("Bearer "):
20+
return "sso_token"
21+
if (getattr(api_config, "api_key", {}) or {}).get("X-Api-Key"):
22+
return "api_key"
23+
return "none"
24+
25+
26+
def _get_api_key_source(opts):
27+
"""Determine where the API key was loaded from.
28+
29+
Checks in priority order matching actual resolution:
30+
CLI --api-key flag > CLOUDSMITH_API_KEY env var > credentials.ini.
31+
"""
32+
if not opts.api_key:
33+
return {"configured": False, "source": None, "source_key": None}
34+
35+
env_key = os.environ.get("CLOUDSMITH_API_KEY")
36+
37+
# If env var is set but differs from the resolved key, CLI flag won
38+
if env_key and opts.api_key != env_key:
39+
source, key = "CLI --api-key flag", "cli_flag"
40+
elif env_key:
41+
suffix = env_key[-4:]
42+
source, key = f"CLOUDSMITH_API_KEY env var (ends with ...{suffix})", "env_var"
43+
elif creds := CredentialsReader.find_existing_files():
44+
source, key = f"credentials.ini ({creds[0]})", "credentials_file"
45+
else:
46+
source, key = "CLI --api-key flag", "cli_flag"
47+
48+
return {"configured": True, "source": source, "source_key": key}
49+
50+
51+
def _get_sso_status(api_host):
52+
"""Return SSO token status from the system keyring."""
53+
enabled = keyring.should_use_keyring()
54+
has_tokens = enabled and keyring.has_sso_tokens(api_host)
55+
refreshed = keyring.get_refresh_attempted_at(api_host) if has_tokens else None
56+
57+
return {
58+
"configured": has_tokens,
59+
"keyring_enabled": enabled,
60+
"source": "System Keyring" if has_tokens else None,
61+
"last_refreshed": utils.fmt_datetime(refreshed) if refreshed else None,
62+
}
63+
64+
65+
def _get_verbose_auth_data(opts, api_host):
66+
"""Gather all auth details for verbose output."""
67+
api_key_info = _get_api_key_source(opts)
68+
sso_info = _get_sso_status(api_host)
69+
70+
# Fetch token metadata (extra API call, graceful fallback)
71+
token_meta = None
72+
if api_key_info["configured"]:
73+
try:
74+
token_meta = get_token_metadata()
75+
except ApiException:
76+
token_meta = None
77+
78+
created = token_meta.get("created") if token_meta else None
79+
api_key_info["slug"] = token_meta["slug"] if token_meta else None
80+
api_key_info["created"] = utils.fmt_datetime(created) if created else None
81+
82+
return {
83+
"active_method": _get_active_method(opts.api_config),
84+
"api_key": api_key_info,
85+
"sso": sso_info,
86+
}
87+
88+
89+
def _print_user_line(name, username, email):
90+
"""Print a styled user identity line."""
91+
styled_name = click.style(name or "Unknown", fg="cyan")
92+
styled_slug = click.style(username or "Unknown", fg="magenta")
93+
email_part = f", email: {click.style(email, fg='green')}" if email else ""
94+
click.echo(f"User: {styled_name} (slug: {styled_slug}{email_part})")
95+
96+
97+
def _print_verbose_text(data):
98+
"""Print verbose authentication details as styled text."""
99+
click.echo()
100+
_print_user_line(data["name"], data["username"], data.get("email"))
101+
102+
auth = data["auth"]
103+
active = auth["active_method"]
104+
ak = auth["api_key"]
105+
sso = auth["sso"]
106+
107+
click.echo()
108+
if active == "sso_token":
109+
click.secho("Authentication Method: SSO Token (primary)", fg="cyan", bold=True)
110+
if sso.get("source"):
111+
click.echo(f" Source: {sso['source']}")
112+
if sso.get("last_refreshed"):
113+
click.echo(
114+
f" Last Refreshed: {sso['last_refreshed']} (refreshes every 30 min)"
115+
)
116+
if ak["configured"]:
117+
click.echo()
118+
click.secho("API Key: Also configured", fg="yellow")
119+
if ak.get("source"):
120+
click.echo(f" Source: {ak['source']}")
121+
click.echo(" Note: SSO token is being used instead")
122+
elif active == "api_key":
123+
click.secho("Authentication Method: API Key", fg="cyan", bold=True)
124+
for label, field in [
125+
("Source", "source"),
126+
("Token Slug", "slug"),
127+
("Created", "created"),
128+
]:
129+
if ak.get(field):
130+
click.echo(f" {label}: {ak[field]}")
131+
else:
132+
click.secho("Authentication Method: None (anonymous)", fg="yellow", bold=True)
133+
134+
if active != "sso_token":
135+
click.echo()
136+
if not sso["keyring_enabled"]:
137+
click.secho(
138+
"SSO Status: Keyring disabled (CLOUDSMITH_NO_KEYRING)", fg="yellow"
139+
)
140+
elif sso["configured"]:
141+
click.secho("SSO Status: Configured (not active)", fg="yellow")
142+
click.echo(f" Source: {sso['source']}")
143+
else:
144+
click.echo("SSO Status: Not configured")
145+
click.echo(" Keyring: Enabled (no tokens stored)")
146+
147+
11148
@main.command()
12149
@decorators.common_cli_config_options
13150
@decorators.common_cli_output_options
@@ -37,26 +174,20 @@ def whoami(ctx, opts):
37174
"name": name,
38175
}
39176

177+
if opts.verbose:
178+
api_host = getattr(opts.api_config, "host", None) or opts.api_host
179+
data["auth"] = _get_verbose_auth_data(opts, api_host)
180+
40181
if utils.maybe_print_as_json(opts, data):
41182
return
42183

43-
click.echo("You are authenticated as:")
44184
if not is_auth:
185+
click.echo("You are authenticated as:")
45186
click.secho("Nobody (i.e. anonymous user)", fg="yellow")
46-
else:
47-
click.secho(
48-
"%(name)s (slug: %(username)s"
49-
% {
50-
"name": click.style(name, fg="cyan"),
51-
"username": click.style(username, fg="magenta"),
52-
},
53-
nl=False,
54-
)
55-
56-
if email:
57-
click.secho(
58-
f", email: {click.style(email, fg='green')}",
59-
nl=False,
60-
)
187+
return
61188

62-
click.echo(")")
189+
if opts.verbose:
190+
_print_verbose_text(data)
191+
else:
192+
click.echo("You are authenticated as:")
193+
_print_user_line(name, username, email)

cloudsmith_cli/core/api/user.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -97,3 +97,13 @@ def refresh_user_token(token_slug: str) -> dict:
9797

9898
ratelimits.maybe_rate_limit(client, headers)
9999
return data
100+
101+
102+
def get_token_metadata() -> dict | None:
103+
"""Retrieve metadata for the user's first API token.
104+
105+
Raises ApiException on failure; callers should handle gracefully.
106+
"""
107+
if t := next(iter(list_user_tokens()), None):
108+
return {"slug": t.slug_perm, "created": t.created}
109+
return None

0 commit comments

Comments
 (0)