diff --git a/CHANGELOG.md b/CHANGELOG.md index c19a90ce..9e44d12e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ## [Unreleased] +### Added + +- `cloudsmith tokens show` prints the API token the CLI is authenticating with, resolved through the standard credential chain (`--api-key` flag, `CLOUDSMITH_API_KEY`, credentials file, keyring, OIDC auto-discovery) — performing the OIDC token exchange when that is the resolving source. This is the explicit, opt-in read path for feeding the OIDC-exchanged token to third-party registry clients (`.npmrc`, pip, `docker login`). Plain output is the bare token on stdout. + ## [1.20.2] - 2026-07-31 ### Fixed diff --git a/README.md b/README.md index 545edfb7..9cf3587f 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ The CLI currently supports the following commands (and sub-commands): - `tokens`: Manage API tokens. - `list`|`ls`: List API tokens. - `refresh`: Refresh an API token. + - `show`: Show the API token the CLI is authenticating with. - `upstream`: Manage upstreams for a repository. - `cran`: Manage cran upstreams for a repository. - `dart`: Manage dart upstreams for a repository. @@ -352,6 +353,38 @@ For convenience the CLI will ask you if you want to install the default configur If the configuration files already exist, you'll have to manually put the API key into the configuration files, but the CLI will print out their locations. +#### Reading the Effective API Token + +`cloudsmith tokens show` prints the token the CLI is authenticating with, resolved through the same credential chain as every other command (`--api-key` flag, `CLOUDSMITH_API_KEY`, credentials file, keyring, OIDC auto-discovery). If OIDC auto-discovery is the resolving source, the OIDC token exchange is performed and the short-lived Cloudsmith token is printed. Only the token is written to stdout, so it can be exported as an environment variable for anything that expects one: + +```bash +export CLOUDSMITH_API_KEY=$(cloudsmith tokens show) +``` + +This is useful in CI when a third-party client needs the OIDC-exchanged token to authenticate against a Cloudsmith registry — nothing is exported automatically, so retrieving the token is always an explicit step. On GitHub Actions, mask the token and export it to subsequent steps via `$GITHUB_ENV`: + +```yaml +- name: Export the Cloudsmith token for later steps + run: | + TOKEN=$(cloudsmith tokens show) + echo "::add-mask::$TOKEN" + echo "CLOUDSMITH_API_KEY=$TOKEN" >> "$GITHUB_ENV" +``` + +The token also works anywhere a registry client takes a credential directly: + +```bash +TOKEN=$(cloudsmith tokens show) +npm config set //npm.cloudsmith.io/example-org/example-repo/:_authToken "$TOKEN" +``` + +Use `--output-format json` to also see which credential source resolved the token and, for OIDC tokens, when it expires: + +```bash +cloudsmith tokens show --output-format json +{"data": {"auth_type": "api_key", "expires_at": "2026-07-31T12:34:56Z", "source": "oidc", "source_detail": "OIDC via GitHub Actions (org: example-org, service: example-service)", "token": "..."}} +``` + ## Uploading Packages diff --git a/cloudsmith_cli/cli/commands/tokens.py b/cloudsmith_cli/cli/commands/tokens.py index d1aff28f..e3943120 100644 --- a/cloudsmith_cli/cli/commands/tokens.py +++ b/cloudsmith_cli/cli/commands/tokens.py @@ -1,7 +1,10 @@ +from datetime import datetime, timezone + import click from ...core.api import exceptions, user as api from ...core.config import create_config_files, new_config_messaging +from ...core.credentials.oidc.cache import decode_jwt_expiry from .. import command, decorators, utils from ..exceptions import handle_api_exceptions from .main import main @@ -198,6 +201,60 @@ def refresh(ctx, opts, token_slug, force, save_config): return new_token +@tokens.command() +@decorators.common_cli_config_options +@decorators.common_cli_output_options +@decorators.common_api_auth_options +@decorators.resolve_credentials +@click.pass_context +def show(ctx, opts): + """Show the API token the CLI is authenticating with. + + Resolves credentials through the standard chain (--api-key flag, + CLOUDSMITH_API_KEY, credentials file, keyring, OIDC auto-discovery) and + prints the resulting token to stdout, performing the OIDC token exchange + if that is the resolving source. No other output is written to stdout, + so the token can be captured or exported directly: + + \b + export CLOUDSMITH_API_KEY=$(cloudsmith tokens show) + + Use --output-format json to also see the resolving source and, for OIDC + tokens, the expiry time. + """ + credential = opts.credential + + if credential is None: + click.secho( + "No credentials could be resolved. Try 'cloudsmith auth' (or " + "'cloudsmith auth --request-api-key'), set CLOUDSMITH_API_KEY, or " + "set CLOUDSMITH_ORG and CLOUDSMITH_SERVICE_SLUG to use OIDC " + "auto-discovery, then try again.", + fg="red", + err=True, + ) + ctx.exit(1) + + data = { + "token": credential.api_key, + "source": credential.source_name, + "source_detail": credential.source_detail, + "auth_type": credential.auth_type, + } + + if credential.source_name == "oidc": + expires_at = decode_jwt_expiry(credential.api_key) + if expires_at is not None: + data["expires_at"] = utils.fmt_datetime( + datetime.fromtimestamp(expires_at, tz=timezone.utc) + ) + + if utils.maybe_print_as_json(opts, data): + return + + click.echo(credential.api_key) + + def print_tokens(tokens): for token in tokens: click.echo( diff --git a/cloudsmith_cli/cli/tests/commands/test_tokens_show.py b/cloudsmith_cli/cli/tests/commands/test_tokens_show.py new file mode 100644 index 00000000..09070c3a --- /dev/null +++ b/cloudsmith_cli/cli/tests/commands/test_tokens_show.py @@ -0,0 +1,138 @@ +import json +import os +import time +from datetime import datetime, timezone +from unittest import mock +from unittest.mock import patch + +import click.testing +import jwt +import pytest + +from ...commands.tokens import show +from ...config import ConfigReader, CredentialsReader + +HOST = "https://api.example.com" +ARGS = ["--api-host", HOST] + + +@pytest.fixture() +def runner(): + return click.testing.CliRunner() + + +@pytest.fixture() +def isolated_config(tmp_path): + """Keep credential resolution away from real env vars, configs and keyring.""" + env = {k: v for k, v in os.environ.items() if not k.startswith("CLOUDSMITH_")} + env.pop("GITHUB_ACTIONS", None) + env["CLOUDSMITH_NO_KEYRING"] = "1" + with ( + mock.patch.dict(os.environ, env, clear=True), + patch.object(ConfigReader, "config_searchpath", [str(tmp_path)]), + patch.object(CredentialsReader, "config_searchpath", [str(tmp_path)]), + ): + yield + + +def mock_oidc_session(vendor_token, exchanged_token): + """Return a session mock covering the vendor token fetch and the exchange.""" + get_response = mock.Mock() + get_response.json.return_value = {"value": vendor_token} + post_response = mock.Mock() + post_response.status_code = 200 + post_response.json.return_value = {"token": exchanged_token} + session = mock.Mock() + session.get.return_value = get_response + session.post.return_value = post_response + return session + + +def invoke_show_via_oidc(runner, exchanged_token, extra_args=None): + """Invoke tokens show with GitHub Actions OIDC as the resolving source.""" + env = { + "CLOUDSMITH_ORG": "example-org", + "CLOUDSMITH_SERVICE_SLUG": "example-service", + "GITHUB_ACTIONS": "true", + "ACTIONS_ID_TOKEN_REQUEST_URL": "https://token.actions.example/req", + "ACTIONS_ID_TOKEN_REQUEST_TOKEN": "request-token", + } + session = mock_oidc_session("vendor-jwt", exchanged_token) + with ( + mock.patch.dict(os.environ, env), + patch( + "cloudsmith_cli.core.credentials.oidc.cache.get_cached_token", + return_value=None, + ), + patch("cloudsmith_cli.core.credentials.oidc.cache.store_cached_token"), + patch("cloudsmith_cli.cli.decorators._create_session", return_value=session), + ): + return runner.invoke(show, ARGS + (extra_args or []), catch_exceptions=False) + + +def json_payload(result): + return json.loads( + "".join(line for line in result.output.splitlines() if line.startswith("{")) + ) + + +class TestTokensShowCommand: + """Tests for the cloudsmith tokens show command.""" + + def test_env_var_api_key_plain_output_is_token_only(self, runner, isolated_config): + with mock.patch.dict(os.environ, {"CLOUDSMITH_API_KEY": "env-api-key"}): + result = runner.invoke(show, ARGS, catch_exceptions=False) + + assert result.exit_code == 0 + assert result.stdout == "env-api-key\n" + + def test_env_var_api_key_json_output(self, runner, isolated_config): + with mock.patch.dict(os.environ, {"CLOUDSMITH_API_KEY": "env-api-key"}): + result = runner.invoke( + show, ARGS + ["--output-format", "json"], catch_exceptions=False + ) + + assert result.exit_code == 0 + data = json_payload(result)["data"] + assert data["token"] == "env-api-key" + assert data["source"] == "env_var" + assert data["auth_type"] == "api_key" + assert "expires_at" not in data + + def test_oidc_resolved_plain_output_is_token_only(self, runner, isolated_config): + result = invoke_show_via_oidc(runner, "exchanged-token") + + assert result.exit_code == 0 + assert result.stdout == "exchanged-token\n" + + def test_oidc_resolved_json_output_includes_expiry(self, runner, isolated_config): + exp = int(time.time()) + 3600 + exchanged_token = jwt.encode({"exp": exp}, "s" * 32, algorithm="HS256") + + result = invoke_show_via_oidc( + runner, exchanged_token, extra_args=["--output-format", "json"] + ) + + assert result.exit_code == 0 + data = json_payload(result)["data"] + assert data["token"] == exchanged_token + assert data["source"] == "oidc" + expected_expiry = ( + datetime.fromtimestamp(exp, tz=timezone.utc) + .isoformat() + .replace("+00:00", "Z") + ) + assert data["expires_at"] == expected_expiry + + def test_no_credentials_exits_nonzero(self, runner, isolated_config): + result = runner.invoke(show, ARGS) + + assert result.exit_code == 1 + assert result.stdout == "" + assert "No credentials could be resolved" in result.stderr + + def test_no_credentials_json_exits_nonzero(self, runner, isolated_config): + result = runner.invoke(show, ARGS + ["--output-format", "json"]) + + assert result.exit_code == 1 + assert "No credentials could be resolved" in result.stderr diff --git a/cloudsmith_cli/core/credentials/oidc/cache.py b/cloudsmith_cli/core/credentials/oidc/cache.py index 2888e004..e4ac3adc 100644 --- a/cloudsmith_cli/core/credentials/oidc/cache.py +++ b/cloudsmith_cli/core/credentials/oidc/cache.py @@ -40,7 +40,7 @@ def _cache_key(api_host: str, org: str, service_slug: str) -> str: return f"oidc_{digest}.json" -def _decode_jwt_exp(token: str) -> float | None: +def decode_jwt_expiry(token: str) -> float | None: """Read the exp claim from a JWT payload. The token is only inspected to determine a cache TTL; it is never used to @@ -150,7 +150,7 @@ def _get_from_disk(api_host: str, org: str, service_slug: str) -> str | 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) + expires_at = decode_jwt_expiry(token) data = { "token": token,