Skip to content
Closed
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 33 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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": "..."}}
Comment thread
BartoszBlizniak marked this conversation as resolved.
```


## Uploading Packages

Expand Down
57 changes: 57 additions & 0 deletions cloudsmith_cli/cli/commands/tokens.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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(
Expand Down
138 changes: 138 additions & 0 deletions cloudsmith_cli/cli/tests/commands/test_tokens_show.py
Original file line number Diff line number Diff line change
@@ -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
4 changes: 2 additions & 2 deletions cloudsmith_cli/core/credentials/oidc/cache.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down