From e19a8f4f2f87c0ee9f802d01e463080f38e95901 Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Fri, 31 Jul 2026 17:01:21 +0100 Subject: [PATCH 1/3] feat(no-ticket): add a command that prints the effective API token CI users previously received the OIDC-exchanged Cloudsmith token from the v2 GitHub Action and fed it to registry clients (.npmrc, pip, docker login). v3 integrations keep the exchange inside the CLI by design, leaving no sanctioned way to retrieve the token for third-party consumers. 'cloudsmith tokens show' is the explicit, opt-in read path: it resolves credentials through the same chain as every authenticated command (flag, env, credentials file, keyring, OIDC), performing the lazy OIDC exchange when that is the resolving source, and prints only the token to stdout. Nothing is auto-exported, so the leak-free default stays intact, and no API endpoint is called. Co-Authored-By: Claude Fable 5 --- CHANGELOG.md | 4 + README.md | 33 +++++ cloudsmith_cli/cli/commands/tokens.py | 57 ++++++++ .../cli/tests/commands/test_tokens_show.py | 138 ++++++++++++++++++ cloudsmith_cli/core/credentials/oidc/cache.py | 4 +- 5 files changed, 234 insertions(+), 2 deletions(-) create mode 100644 cloudsmith_cli/cli/tests/commands/test_tokens_show.py diff --git a/CHANGELOG.md b/CHANGELOG.md index c19a90ce..fc93eaad 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`) that previously consumed the token output of the v2 GitHub Action; nothing is auto-exported and no API endpoint is called. Plain output is the bare token on stdout, so restoring the previous workflow is a one-liner: `export CLOUDSMITH_API_KEY=$(cloudsmith tokens show)` (on GitHub Actions, `echo "CLOUDSMITH_API_KEY=$TOKEN" >> "$GITHUB_ENV"` for later steps); `--output-format json` adds the resolving source and, for OIDC tokens, the expiry time. When capturing the token in CI, mask it in the job log — on GitHub Actions: `echo "::add-mask::$TOKEN"`. + ## [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..3a5cc642 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 getting your API key via " + "'cloudsmith token', or access token via 'cloudsmith auth', 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, From 3371f22eb86b012a58e18723fcdc8f60e21af6bd Mon Sep 17 00:00:00 2001 From: Bartosz Blizniak Date: Mon, 3 Aug 2026 09:10:31 +0100 Subject: [PATCH 2/3] update changelog wording --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fc93eaad..9e44d12e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### 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`) that previously consumed the token output of the v2 GitHub Action; nothing is auto-exported and no API endpoint is called. Plain output is the bare token on stdout, so restoring the previous workflow is a one-liner: `export CLOUDSMITH_API_KEY=$(cloudsmith tokens show)` (on GitHub Actions, `echo "CLOUDSMITH_API_KEY=$TOKEN" >> "$GITHUB_ENV"` for later steps); `--output-format json` adds the resolving source and, for OIDC tokens, the expiry time. When capturing the token in CI, mask it in the job log — on GitHub Actions: `echo "::add-mask::$TOKEN"`. +- `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 From debf71d3a1a05b591cb335f1d2ffcdd45b7d80d4 Mon Sep 17 00:00:00 2001 From: BB <55028730+BartoszBlizniak@users.noreply.github.com> Date: Mon, 3 Aug 2026 10:39:55 +0100 Subject: [PATCH 3/3] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- cloudsmith_cli/cli/commands/tokens.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cloudsmith_cli/cli/commands/tokens.py b/cloudsmith_cli/cli/commands/tokens.py index 3a5cc642..e3943120 100644 --- a/cloudsmith_cli/cli/commands/tokens.py +++ b/cloudsmith_cli/cli/commands/tokens.py @@ -226,8 +226,8 @@ def show(ctx, opts): if credential is None: click.secho( - "No credentials could be resolved. Try getting your API key via " - "'cloudsmith token', or access token via 'cloudsmith auth', or " + "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",