From c95f1f24d6c93bf307d08d2cbc1b3250aea63ef2 Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Tue, 25 Aug 2026 18:50:37 +0100 Subject: [PATCH] fix: include auth scheme in Cargo credentials Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../cli/tests/test_credential_helper_cargo.py | 26 ++++++--- .../credentials/providers/oidc_provider.py | 2 + .../core/tests/test_oidc_provider.py | 54 +++++++++++++++++++ .../credential_helpers/cargo/runtime.py | 18 +++++-- 4 files changed, 91 insertions(+), 9 deletions(-) create mode 100644 cloudsmith_cli/core/tests/test_oidc_provider.py diff --git a/cloudsmith_cli/cli/tests/test_credential_helper_cargo.py b/cloudsmith_cli/cli/tests/test_credential_helper_cargo.py index 5ea6a4ae..5987ddd9 100644 --- a/cloudsmith_cli/cli/tests/test_credential_helper_cargo.py +++ b/cloudsmith_cli/cli/tests/test_credential_helper_cargo.py @@ -114,7 +114,7 @@ def test_get_returns_token_for_cloudsmith_registry(credential): assert messages[1] == { "Ok": { "kind": "get", - "token": "k_abc", + "token": "token k_abc", "cache": "session", "operation_independent": True, } @@ -127,7 +127,7 @@ def test_get_serves_every_operation(operation, credential): request = _request(operation=operation, name="sample", vers="0.1.0") _, _, messages = _session(request, credential=credential) - assert messages[1]["Ok"]["token"] == "k_abc" + assert messages[1]["Ok"]["token"] == "token k_abc" def test_get_uses_the_cargo_backend_kind_for_custom_domains(credential): @@ -149,10 +149,24 @@ def test_get_uses_the_cargo_backend_kind_for_custom_domains(credential): def test_index_url_keeps_its_sparse_prefix_out_of_the_host_match(credential): """Cargo's `sparse+` prefix and the repo path don't defeat the host check.""" assert ( - get_credentials(CLOUDSMITH_INDEX, credential=credential, org="acme") == "k_abc" + get_credentials(CLOUDSMITH_INDEX, credential=credential, org="acme") + == "token k_abc" ) +def test_get_returns_bearer_scheme_for_sso_credential(): + """An SSO credential is returned with the Bearer authorization scheme.""" + credential = CredentialResult( + api_key="jwt_token", + source_name="keyring", + auth_type="bearer", + ) + + _, _, messages = _session(_request(), credential=credential) + + assert messages[1]["Ok"]["token"] == "Bearer jwt_token" + + # --------------------------------------------------------------------------- # 3. get — the refusal paths, which Cargo distinguishes # --------------------------------------------------------------------------- @@ -247,7 +261,7 @@ def test_malformed_request_lines_are_answered_not_crashed(line, credential): assert (code, stderr) == (0, None) assert messages[1]["Err"]["kind"] == "other" # The following well-formed request is still served. - assert messages[2]["Ok"]["token"] == "k_abc" + assert messages[2]["Ok"]["token"] == "token k_abc" def test_blank_lines_are_skipped(credential): @@ -322,7 +336,7 @@ def test_cli_speaks_the_protocol_on_stdin_and_stdout(runner): messages = [json.loads(line) for line in result.stdout.splitlines()] assert result.exit_code == 0 assert messages[0] == hello() - assert messages[1]["Ok"]["token"] == "k_abc" + assert messages[1]["Ok"]["token"] == "token k_abc" def test_cli_accepts_the_cargo_plugin_flag_and_extra_provider_args(runner): @@ -336,7 +350,7 @@ def test_cli_accepts_the_cargo_plugin_flag_and_extra_provider_args(runner): messages = [json.loads(line) for line in result.stdout.splitlines()] assert result.exit_code == 0 - assert messages[1]["Ok"]["token"] == "k_abc" + assert messages[1]["Ok"]["token"] == "token k_abc" def test_cli_exits_non_zero_with_a_hint_when_no_credential_resolves(runner): diff --git a/cloudsmith_cli/core/credentials/providers/oidc_provider.py b/cloudsmith_cli/core/credentials/providers/oidc_provider.py index d2eee69f..680571b6 100644 --- a/cloudsmith_cli/core/credentials/providers/oidc_provider.py +++ b/cloudsmith_cli/core/credentials/providers/oidc_provider.py @@ -54,6 +54,7 @@ def resolve( # pylint: disable=too-many-return-statements api_key=cached, source_name="oidc", source_detail=f"OIDC [cached] (org: {org}, service: {service_slug})", + auth_type="bearer", ) from ..oidc.detectors import detect_environment @@ -112,4 +113,5 @@ def resolve( # pylint: disable=too-many-return-statements api_key=cloudsmith_token, source_name="oidc", source_detail=f"OIDC via {detector.name} (org: {org}, service: {service_slug})", + auth_type="bearer", ) diff --git a/cloudsmith_cli/core/tests/test_oidc_provider.py b/cloudsmith_cli/core/tests/test_oidc_provider.py new file mode 100644 index 00000000..734fdddc --- /dev/null +++ b/cloudsmith_cli/core/tests/test_oidc_provider.py @@ -0,0 +1,54 @@ +"""Tests for the OIDC credential provider.""" + +from unittest.mock import Mock, patch + +from cloudsmith_cli.core.credentials.models import CredentialContext +from cloudsmith_cli.core.credentials.providers.oidc_provider import OidcProvider + + +def _context() -> CredentialContext: + return CredentialContext( + org="cloudsmith", + oidc_service_slug="github-actions", + ) + + +def test_cached_oidc_token_is_a_bearer_credential(): + """Cached exchanged tokens retain their HTTP authorization scheme.""" + with patch( + "cloudsmith_cli.core.credentials.oidc.cache.get_cached_token", + return_value="cached-token", + ): + credential = OidcProvider().resolve(_context()) + + assert credential is not None + assert credential.auth_type == "bearer" + + +def test_exchanged_oidc_token_is_a_bearer_credential(): + """Freshly exchanged tokens use Bearer authentication.""" + detector = Mock(name="github-actions") + detector.name = "github-actions" + detector.get_token.return_value = "vendor-token" + + with ( + 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.core.credentials.oidc.detectors.detect_environment", + return_value=detector, + ), + patch( + "cloudsmith_cli.core.credentials.oidc.exchange.exchange_oidc_token", + return_value="exchanged-token", + ), + ): + credential = OidcProvider().resolve(_context()) + + assert credential is not None + assert credential.auth_type == "bearer" diff --git a/cloudsmith_cli/credential_helpers/cargo/runtime.py b/cloudsmith_cli/credential_helpers/cargo/runtime.py index 97e742bd..d6587543 100644 --- a/cloudsmith_cli/credential_helpers/cargo/runtime.py +++ b/cloudsmith_cli/credential_helpers/cargo/runtime.py @@ -99,7 +99,8 @@ def get_credentials(index_url, credential=None, api_host=None, org=None): org: Organisation slug whose custom domains to match against Returns: - str: The token Cargo sends as its ``Authorization`` header, or None + str: The complete value Cargo sends as its ``Authorization`` header, + or None """ if not credential or not credential.api_key: return None @@ -109,7 +110,18 @@ def get_credentials(index_url, credential=None, api_host=None, org=None): ): return None - return credential.api_key + return _authorization_value(credential) + + +def _authorization_value(credential) -> str: + """Return the complete HTTP Authorization value for *credential*. + + Cargo forwards the credential provider's ``token`` field verbatim as the + Authorization header. Cloudsmith API credentials use the ``token`` scheme, + while SSO credentials use ``Bearer``. + """ + scheme = "Bearer" if credential.auth_type == "bearer" else "token" + return f"{scheme} {credential.api_key}" def _handle_get(request, credential, api_host, org) -> dict: @@ -137,7 +149,7 @@ def _handle_get(request, credential, api_host, org) -> dict: return _ok( { "kind": "get", - "token": credential.api_key, + "token": _authorization_value(credential), # A Cloudsmith token is organisation-wide and not scoped to the # read/publish/yank/owners operation, so it is cacheable for the # session and across operations.