From b902b0e6798e182b2ae20384284969e930d86f20 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:39:00 +0000 Subject: [PATCH 1/6] Scope OIDC cache by exchange context Co-authored-by: cloudsmith-iduffy <178375997+cloudsmith-iduffy@users.noreply.github.com> --- cloudsmith_cli/core/credentials/oidc/cache.py | 90 ++++++++++++++----- .../credentials/providers/oidc_provider.py | 15 +++- cloudsmith_cli/core/keyring.py | 31 +++++-- .../core/tests/test_oidc_provider.py | 86 +++++++++++++++++- 4 files changed, 191 insertions(+), 31 deletions(-) diff --git a/cloudsmith_cli/core/credentials/oidc/cache.py b/cloudsmith_cli/core/credentials/oidc/cache.py index 2888e004..4954cbca 100644 --- a/cloudsmith_cli/core/credentials/oidc/cache.py +++ b/cloudsmith_cli/core/credentials/oidc/cache.py @@ -33,9 +33,14 @@ def _get_cache_dir() -> str: return cache_dir -def _cache_key(api_host: str, org: str, service_slug: str) -> str: +def _cache_key( + api_host: str, + org: str, + service_slug: str, + audience: str | None = None, +) -> str: """Compute a deterministic cache filename from the exchange parameters.""" - raw = f"{api_host}|{org}|{service_slug}" + raw = f"{api_host}|{org}|{service_slug}|{audience or ''}" digest = hashlib.sha256(raw.encode()).hexdigest()[:32] return f"oidc_{digest}.json" @@ -59,20 +64,30 @@ def _decode_jwt_exp(token: str) -> float | None: return None -def get_cached_token(api_host: str, org: str, service_slug: str) -> str | None: +def get_cached_token( + api_host: str, + org: str, + service_slug: str, + audience: str | None = None, +) -> str | None: """Return a cached token if it exists and is not expired.""" - token = _get_from_keyring(api_host, org, service_slug) + token = _get_from_keyring(api_host, org, service_slug, audience) if token: return token - return _get_from_disk(api_host, org, service_slug) + return _get_from_disk(api_host, org, service_slug, audience) -def _get_from_keyring(api_host: str, org: str, service_slug: str) -> str | None: +def _get_from_keyring( + api_host: str, + org: str, + service_slug: str, + audience: str | None, +) -> str | None: """Try to get token from keyring.""" try: from ...keyring import get_oidc_token - token_data = get_oidc_token(api_host, org, service_slug) + token_data = get_oidc_token(api_host, org, service_slug, audience) if not token_data: return None @@ -94,7 +109,7 @@ def _get_from_keyring(api_host: str, org: str, service_slug: str) -> str | None: ) from ...keyring import delete_oidc_token - delete_oidc_token(api_host, org, service_slug) + delete_oidc_token(api_host, org, service_slug, audience) return None logger.debug("Using keyring OIDC token (expires in %.0fs)", remaining) else: @@ -107,10 +122,17 @@ def _get_from_keyring(api_host: str, org: str, service_slug: str) -> str | None: return None -def _get_from_disk(api_host: str, org: str, service_slug: str) -> str | None: +def _get_from_disk( + api_host: str, + org: str, + service_slug: str, + audience: str | None, +) -> str | None: """Try to get token from disk cache.""" cache_dir = _get_cache_dir() - cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug)) + cache_file = os.path.join( + cache_dir, _cache_key(api_host, org, service_slug, audience) + ) if not os.path.isfile(cache_file): return None @@ -148,7 +170,13 @@ def _get_from_disk(api_host: str, org: str, service_slug: str) -> str | None: return None -def store_cached_token(api_host: str, org: str, service_slug: str, token: str) -> None: +def store_cached_token( + api_host: str, + org: str, + service_slug: str, + token: str, + audience: str | None = None, +) -> None: """Cache a token in keyring (if available) or filesystem.""" expires_at = _decode_jwt_exp(token) @@ -158,22 +186,29 @@ def store_cached_token(api_host: str, org: str, service_slug: str, token: str) - "api_host": api_host, "org": org, "service_slug": service_slug, + "audience": audience, "cached_at": time.time(), } - if _store_in_keyring(api_host, org, service_slug, data): + if _store_in_keyring(api_host, org, service_slug, audience, data): return - _store_on_disk(api_host, org, service_slug, data) + _store_on_disk(api_host, org, service_slug, audience, data) -def _store_in_keyring(api_host: str, org: str, service_slug: str, data: dict) -> bool: +def _store_in_keyring( + api_host: str, + org: str, + service_slug: str, + audience: str | None, + data: dict, +) -> bool: """Try to store token in keyring.""" try: from ...keyring import store_oidc_token token_data = json.dumps(data) - success = store_oidc_token(api_host, org, service_slug, token_data) + success = store_oidc_token(api_host, org, service_slug, audience, token_data) if success: logger.debug( "Stored OIDC token in keyring (expires_at=%s)", data.get("expires_at") @@ -184,10 +219,18 @@ def _store_in_keyring(api_host: str, org: str, service_slug: str, data: dict) -> return False -def _store_on_disk(api_host: str, org: str, service_slug: str, data: dict) -> None: +def _store_on_disk( + api_host: str, + org: str, + service_slug: str, + audience: str | None, + data: dict, +) -> None: """Store token on disk.""" cache_dir = _get_cache_dir() - cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug)) + cache_file = os.path.join( + cache_dir, _cache_key(api_host, org, service_slug, audience) + ) try: atomic_write_json(cache_file, data) @@ -198,17 +241,24 @@ def _store_on_disk(api_host: str, org: str, service_slug: str, data: dict) -> No logger.debug("Failed to write OIDC token to disk cache", exc_info=True) -def invalidate_cached_token(api_host: str, org: str, service_slug: str) -> None: +def invalidate_cached_token( + api_host: str, + org: str, + service_slug: str, + audience: str | None = None, +) -> None: """Remove a cached token from both keyring and disk.""" try: from ...keyring import delete_oidc_token - delete_oidc_token(api_host, org, service_slug) + delete_oidc_token(api_host, org, service_slug, audience) except Exception: # pylint: disable=broad-exception-caught logger.debug("Failed to delete OIDC token from keyring", exc_info=True) cache_dir = _get_cache_dir() - cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug)) + cache_file = os.path.join( + cache_dir, _cache_key(api_host, org, service_slug, audience) + ) _remove_cache_file(cache_file) diff --git a/cloudsmith_cli/core/credentials/providers/oidc_provider.py b/cloudsmith_cli/core/credentials/providers/oidc_provider.py index e8373169..093ff304 100644 --- a/cloudsmith_cli/core/credentials/providers/oidc_provider.py +++ b/cloudsmith_cli/core/credentials/providers/oidc_provider.py @@ -104,7 +104,12 @@ def resolve( # pylint: disable=too-many-return-statements # Check cache BEFORE environment detection — detection can be expensive # (e.g. boto3 credential resolution, IMDS calls) and is unnecessary when # we already hold a valid exchanged token. - cached = get_cached_token(context.api_host, org, service_slug) + cached = get_cached_token( + context.api_host, + org, + service_slug, + context.oidc_audience, + ) if cached: logger.debug("OidcProvider: Using cached OIDC token") return CredentialResult( @@ -184,7 +189,13 @@ def resolve( # pylint: disable=too-many-return-statements if not cloudsmith_token: return None - store_cached_token(context.api_host, org, service_slug, cloudsmith_token) + store_cached_token( + context.api_host, + org, + service_slug, + cloudsmith_token, + context.oidc_audience, + ) return CredentialResult( api_key=cloudsmith_token, diff --git a/cloudsmith_cli/core/keyring.py b/cloudsmith_cli/core/keyring.py index 7b247a66..8f17da2b 100644 --- a/cloudsmith_cli/core/keyring.py +++ b/cloudsmith_cli/core/keyring.py @@ -320,17 +320,24 @@ def delete_sso_tokens(api_host, profile=None, include_legacy=True): return any(results) -OIDC_TOKEN_KEY = "cloudsmith_cli-oidc_token-{api_host}-{org}-{service_slug}" +OIDC_TOKEN_KEY = ( + "cloudsmith_cli-oidc_token-{api_host}-{org}-{service_slug}-{audience}" +) -def store_oidc_token(api_host, org, service_slug, token_data): +def store_oidc_token(api_host, org, service_slug, audience, token_data): """Store OIDC token in keyring if enabled.""" from keyring.errors import KeyringError if not should_use_keyring(): return False - key = OIDC_TOKEN_KEY.format(api_host=api_host, org=org, service_slug=service_slug) + key = OIDC_TOKEN_KEY.format( + api_host=api_host, + org=org, + service_slug=service_slug, + audience=audience or "", + ) try: _set_value(key, token_data) return True @@ -338,16 +345,26 @@ def store_oidc_token(api_host, org, service_slug, token_data): return False -def get_oidc_token(api_host, org, service_slug): +def get_oidc_token(api_host, org, service_slug, audience=None): """Retrieve OIDC token from keyring.""" if not should_use_keyring(): return None - key = OIDC_TOKEN_KEY.format(api_host=api_host, org=org, service_slug=service_slug) + key = OIDC_TOKEN_KEY.format( + api_host=api_host, + org=org, + service_slug=service_slug, + audience=audience or "", + ) return _get_value(key) -def delete_oidc_token(api_host, org, service_slug): +def delete_oidc_token(api_host, org, service_slug, audience=None): """Delete OIDC token from keyring.""" - key = OIDC_TOKEN_KEY.format(api_host=api_host, org=org, service_slug=service_slug) + key = OIDC_TOKEN_KEY.format( + api_host=api_host, + org=org, + service_slug=service_slug, + audience=audience or "", + ) return _delete_value(key) diff --git a/cloudsmith_cli/core/tests/test_oidc_provider.py b/cloudsmith_cli/core/tests/test_oidc_provider.py index 36fc4a93..ac437b52 100644 --- a/cloudsmith_cli/core/tests/test_oidc_provider.py +++ b/cloudsmith_cli/core/tests/test_oidc_provider.py @@ -10,10 +10,14 @@ from cloudsmith_cli.core.credentials.providers.oidc_provider import OidcProvider -def _context() -> CredentialContext: +def _context( + service_slug: str = "github-actions", + audience: str | None = None, +) -> CredentialContext: return CredentialContext( org="cloudsmith", - oidc_service_slug="github-actions", + oidc_service_slug=service_slug, + oidc_audience=audience, ) @@ -58,6 +62,84 @@ def test_exchanged_oidc_token_is_a_bearer_credential(): assert credential.auth_type == "bearer" +def test_switching_service_accounts_mints_and_caches_separate_tokens(): + detector = Mock(name="github-actions") + detector.name = "github-actions" + detector.get_token.return_value = "vendor-token" + cache = {} + + def get_cached(*key): + return cache.get(key) + + def store_cached(api_host, org, service_slug, token, audience): + cache[(api_host, org, service_slug, audience)] = token + + with ( + patch( + "cloudsmith_cli.core.credentials.oidc.cache.get_cached_token", + side_effect=get_cached, + ), + patch( + "cloudsmith_cli.core.credentials.oidc.cache.store_cached_token", + side_effect=store_cached, + ), + patch( + "cloudsmith_cli.core.credentials.oidc.detectors.detect_environment", + return_value=detector, + ), + patch( + "cloudsmith_cli.core.credentials.oidc.exchange.exchange_oidc_token", + side_effect=["read-token", "push-token"], + ) as exchange, + ): + read_credential = OidcProvider().resolve(_context("read-service")) + push_credential = OidcProvider().resolve(_context("push-service")) + cached_read_credential = OidcProvider().resolve(_context("read-service")) + + assert read_credential.api_key == "read-token" + assert push_credential.api_key == "push-token" + assert cached_read_credential.api_key == "read-token" + assert exchange.call_count == 2 + + +def test_switching_oidc_audiences_mints_separate_tokens(): + detector = Mock(name="github-actions") + detector.name = "github-actions" + detector.get_token.return_value = "vendor-token" + cache = {} + + def get_cached(*key): + return cache.get(key) + + def store_cached(api_host, org, service_slug, token, audience): + cache[(api_host, org, service_slug, audience)] = token + + with ( + patch( + "cloudsmith_cli.core.credentials.oidc.cache.get_cached_token", + side_effect=get_cached, + ), + patch( + "cloudsmith_cli.core.credentials.oidc.cache.store_cached_token", + side_effect=store_cached, + ), + patch( + "cloudsmith_cli.core.credentials.oidc.detectors.detect_environment", + return_value=detector, + ), + patch( + "cloudsmith_cli.core.credentials.oidc.exchange.exchange_oidc_token", + side_effect=["first-token", "second-token"], + ) as exchange, + ): + first_credential = OidcProvider().resolve(_context(audience="first")) + second_credential = OidcProvider().resolve(_context(audience="second")) + + assert first_credential.api_key == "first-token" + assert second_credential.api_key == "second-token" + assert exchange.call_count == 2 + + def test_failed_exchange_logs_vendor_jwt_diagnostics(caplog): vendor_token = jwt.encode( { From 46dfd0bed1af52470006dc8ee4c15b78abf9fc92 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:40:50 +0000 Subject: [PATCH 2/6] Prefer requested OIDC identity over inherited token Co-authored-by: cloudsmith-iduffy <178375997+cloudsmith-iduffy@users.noreply.github.com> --- cloudsmith_cli/core/credentials/chain.py | 17 ++++++-- cloudsmith_cli/core/credentials/oidc/cache.py | 8 +++- cloudsmith_cli/core/keyring.py | 2 +- .../tests/test_credential_chain_priority.py | 43 ++++++++++++++++++- 4 files changed, 64 insertions(+), 6 deletions(-) diff --git a/cloudsmith_cli/core/credentials/chain.py b/cloudsmith_cli/core/credentials/chain.py index e4ae58ae..6b770f4e 100644 --- a/cloudsmith_cli/core/credentials/chain.py +++ b/cloudsmith_cli/core/credentials/chain.py @@ -19,13 +19,15 @@ class CredentialProviderChain: """Evaluates credential providers in order, returning the first valid result. - If no providers are given, uses the default chain: - CLIFlag → EnvVar → CredentialsFile → Keyring → OIDC. + If no providers are given, uses the default chain. Explicit OIDC + configuration takes precedence over ambient credentials so a changed + service slug cannot be masked by a token exported by an earlier command. """ def __init__(self, providers: list[CredentialProvider] | None = None): if providers is not None: self.providers = providers + self._uses_default_providers = False else: from .providers import ( CLIFlagProvider, @@ -42,10 +44,19 @@ def __init__(self, providers: list[CredentialProvider] | None = None): KeyringProvider(), OidcProvider(), ] + self._uses_default_providers = True def resolve(self, context: CredentialContext) -> CredentialResult | None: """Evaluate each provider in order. Return the first successful result.""" - for provider in self.providers: + providers = self.providers + if self._uses_default_providers and context.org and context.oidc_service_slug: + providers = [ + self.providers[0], + self.providers[4], + *self.providers[1:4], + ] + + for provider in providers: try: result = provider.resolve(context) if result is not None: diff --git a/cloudsmith_cli/core/credentials/oidc/cache.py b/cloudsmith_cli/core/credentials/oidc/cache.py index 4954cbca..edfe4411 100644 --- a/cloudsmith_cli/core/credentials/oidc/cache.py +++ b/cloudsmith_cli/core/credentials/oidc/cache.py @@ -208,7 +208,13 @@ def _store_in_keyring( from ...keyring import store_oidc_token token_data = json.dumps(data) - success = store_oidc_token(api_host, org, service_slug, audience, token_data) + success = store_oidc_token( + api_host, + org, + service_slug, + token_data, + audience, + ) if success: logger.debug( "Stored OIDC token in keyring (expires_at=%s)", data.get("expires_at") diff --git a/cloudsmith_cli/core/keyring.py b/cloudsmith_cli/core/keyring.py index 8f17da2b..be7468a8 100644 --- a/cloudsmith_cli/core/keyring.py +++ b/cloudsmith_cli/core/keyring.py @@ -325,7 +325,7 @@ def delete_sso_tokens(api_host, profile=None, include_legacy=True): ) -def store_oidc_token(api_host, org, service_slug, audience, token_data): +def store_oidc_token(api_host, org, service_slug, token_data, audience=None): """Store OIDC token in keyring if enabled.""" from keyring.errors import KeyringError diff --git a/cloudsmith_cli/core/tests/test_credential_chain_priority.py b/cloudsmith_cli/core/tests/test_credential_chain_priority.py index 55c82935..2fdcb407 100644 --- a/cloudsmith_cli/core/tests/test_credential_chain_priority.py +++ b/cloudsmith_cli/core/tests/test_credential_chain_priority.py @@ -1,7 +1,8 @@ """Integration tests proving correct credential resolution priority. Priority (highest → lowest): - --api-key CLI flag > CLOUDSMITH_API_KEY env var > credentials.ini > keyring SSO +--api-key CLI flag > configured OIDC > CLOUDSMITH_API_KEY env var +> credentials.ini > keyring SSO """ from unittest.mock import patch @@ -9,6 +10,7 @@ from cloudsmith_cli.core import keyring from cloudsmith_cli.core.credentials.chain import CredentialProviderChain from cloudsmith_cli.core.credentials.models import CredentialContext +from cloudsmith_cli.core.credentials.models import CredentialResult class TestCredentialChainPriority: @@ -50,6 +52,45 @@ def test_env_var_beats_keyring(self): assert result.api_key == "env-key" assert result.source_name == "env_var" + def test_configured_oidc_beats_inherited_env_token(self): + """A previous action's exported token must not mask a requested service.""" + context = self._context( + api_key_from_env="previous-oidc-token", + org="cloudsmith", + oidc_service_slug="push-service", + ) + + with patch( + "cloudsmith_cli.core.credentials.providers.oidc_provider.OidcProvider.resolve", + return_value=CredentialResult( + api_key="push-token", + source_name="oidc", + auth_type="bearer", + ), + ): + result = CredentialProviderChain().resolve(context) + + assert result is not None + assert result.api_key == "push-token" + assert result.source_name == "oidc" + + def test_failed_configured_oidc_falls_back_to_env_token(self): + context = self._context( + api_key_from_env="fallback-token", + org="cloudsmith", + oidc_service_slug="push-service", + ) + + with patch( + "cloudsmith_cli.core.credentials.providers.oidc_provider.OidcProvider.resolve", + return_value=None, + ): + result = CredentialProviderChain().resolve(context) + + assert result is not None + assert result.api_key == "fallback-token" + assert result.source_name == "env_var" + def test_credentials_file_beats_keyring(self): """credentials.ini must win over keyring SSO.""" context = self._context(api_key_from_file="file-key") From d9ba7c9f429faf4eb23d46e78266c4fad8181329 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 07:45:50 +0000 Subject: [PATCH 3/6] Harden configured OIDC provider ordering Co-authored-by: cloudsmith-iduffy <178375997+cloudsmith-iduffy@users.noreply.github.com> --- cloudsmith_cli/core/credentials/chain.py | 13 ++++++++----- .../core/tests/test_credential_chain_priority.py | 3 +-- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/cloudsmith_cli/core/credentials/chain.py b/cloudsmith_cli/core/credentials/chain.py index 6b770f4e..bd59d659 100644 --- a/cloudsmith_cli/core/credentials/chain.py +++ b/cloudsmith_cli/core/credentials/chain.py @@ -50,11 +50,14 @@ def resolve(self, context: CredentialContext) -> CredentialResult | None: """Evaluate each provider in order. Return the first successful result.""" providers = self.providers if self._uses_default_providers and context.org and context.oidc_service_slug: - providers = [ - self.providers[0], - self.providers[4], - *self.providers[1:4], - ] + providers = self.providers.copy() + oidc_index = next( + index + for index, provider in enumerate(providers) + if provider.name == "oidc" + ) + oidc_provider = providers.pop(oidc_index) + providers.insert(1, oidc_provider) for provider in providers: try: diff --git a/cloudsmith_cli/core/tests/test_credential_chain_priority.py b/cloudsmith_cli/core/tests/test_credential_chain_priority.py index 2fdcb407..de6ce8bf 100644 --- a/cloudsmith_cli/core/tests/test_credential_chain_priority.py +++ b/cloudsmith_cli/core/tests/test_credential_chain_priority.py @@ -9,8 +9,7 @@ from cloudsmith_cli.core import keyring from cloudsmith_cli.core.credentials.chain import CredentialProviderChain -from cloudsmith_cli.core.credentials.models import CredentialContext -from cloudsmith_cli.core.credentials.models import CredentialResult +from cloudsmith_cli.core.credentials.models import CredentialContext, CredentialResult class TestCredentialChainPriority: From e17a9b765bd8150d9c4cb999b3146b7e868b3eff Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:04:19 +0000 Subject: [PATCH 4/6] Restore original credential provider precedence Co-authored-by: cloudsmith-iduffy <178375997+cloudsmith-iduffy@users.noreply.github.com> --- cloudsmith_cli/core/credentials/chain.py | 20 ++------- .../tests/test_credential_chain_priority.py | 44 +------------------ 2 files changed, 5 insertions(+), 59 deletions(-) diff --git a/cloudsmith_cli/core/credentials/chain.py b/cloudsmith_cli/core/credentials/chain.py index bd59d659..e4ae58ae 100644 --- a/cloudsmith_cli/core/credentials/chain.py +++ b/cloudsmith_cli/core/credentials/chain.py @@ -19,15 +19,13 @@ class CredentialProviderChain: """Evaluates credential providers in order, returning the first valid result. - If no providers are given, uses the default chain. Explicit OIDC - configuration takes precedence over ambient credentials so a changed - service slug cannot be masked by a token exported by an earlier command. + If no providers are given, uses the default chain: + CLIFlag → EnvVar → CredentialsFile → Keyring → OIDC. """ def __init__(self, providers: list[CredentialProvider] | None = None): if providers is not None: self.providers = providers - self._uses_default_providers = False else: from .providers import ( CLIFlagProvider, @@ -44,22 +42,10 @@ def __init__(self, providers: list[CredentialProvider] | None = None): KeyringProvider(), OidcProvider(), ] - self._uses_default_providers = True def resolve(self, context: CredentialContext) -> CredentialResult | None: """Evaluate each provider in order. Return the first successful result.""" - providers = self.providers - if self._uses_default_providers and context.org and context.oidc_service_slug: - providers = self.providers.copy() - oidc_index = next( - index - for index, provider in enumerate(providers) - if provider.name == "oidc" - ) - oidc_provider = providers.pop(oidc_index) - providers.insert(1, oidc_provider) - - for provider in providers: + for provider in self.providers: try: result = provider.resolve(context) if result is not None: diff --git a/cloudsmith_cli/core/tests/test_credential_chain_priority.py b/cloudsmith_cli/core/tests/test_credential_chain_priority.py index de6ce8bf..55c82935 100644 --- a/cloudsmith_cli/core/tests/test_credential_chain_priority.py +++ b/cloudsmith_cli/core/tests/test_credential_chain_priority.py @@ -1,15 +1,14 @@ """Integration tests proving correct credential resolution priority. Priority (highest → lowest): ---api-key CLI flag > configured OIDC > CLOUDSMITH_API_KEY env var -> credentials.ini > keyring SSO + --api-key CLI flag > CLOUDSMITH_API_KEY env var > credentials.ini > keyring SSO """ from unittest.mock import patch from cloudsmith_cli.core import keyring from cloudsmith_cli.core.credentials.chain import CredentialProviderChain -from cloudsmith_cli.core.credentials.models import CredentialContext, CredentialResult +from cloudsmith_cli.core.credentials.models import CredentialContext class TestCredentialChainPriority: @@ -51,45 +50,6 @@ def test_env_var_beats_keyring(self): assert result.api_key == "env-key" assert result.source_name == "env_var" - def test_configured_oidc_beats_inherited_env_token(self): - """A previous action's exported token must not mask a requested service.""" - context = self._context( - api_key_from_env="previous-oidc-token", - org="cloudsmith", - oidc_service_slug="push-service", - ) - - with patch( - "cloudsmith_cli.core.credentials.providers.oidc_provider.OidcProvider.resolve", - return_value=CredentialResult( - api_key="push-token", - source_name="oidc", - auth_type="bearer", - ), - ): - result = CredentialProviderChain().resolve(context) - - assert result is not None - assert result.api_key == "push-token" - assert result.source_name == "oidc" - - def test_failed_configured_oidc_falls_back_to_env_token(self): - context = self._context( - api_key_from_env="fallback-token", - org="cloudsmith", - oidc_service_slug="push-service", - ) - - with patch( - "cloudsmith_cli.core.credentials.providers.oidc_provider.OidcProvider.resolve", - return_value=None, - ): - result = CredentialProviderChain().resolve(context) - - assert result is not None - assert result.api_key == "fallback-token" - assert result.source_name == "env_var" - def test_credentials_file_beats_keyring(self): """credentials.ini must win over keyring SSO.""" context = self._context(api_key_from_file="file-key") From 525538ef5b88c5650d7e1d4ca73fc6a61180e2ad Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 08:16:47 +0000 Subject: [PATCH 5/6] Use service slug for OIDC cache identity Co-authored-by: cloudsmith-iduffy <178375997+cloudsmith-iduffy@users.noreply.github.com> --- cloudsmith_cli/core/credentials/oidc/cache.py | 75 +++++-------------- .../credentials/providers/oidc_provider.py | 8 +- cloudsmith_cli/core/keyring.py | 31 ++------ .../core/tests/test_oidc_provider.py | 50 +------------ 4 files changed, 28 insertions(+), 136 deletions(-) diff --git a/cloudsmith_cli/core/credentials/oidc/cache.py b/cloudsmith_cli/core/credentials/oidc/cache.py index edfe4411..db6080d9 100644 --- a/cloudsmith_cli/core/credentials/oidc/cache.py +++ b/cloudsmith_cli/core/credentials/oidc/cache.py @@ -33,14 +33,9 @@ def _get_cache_dir() -> str: return cache_dir -def _cache_key( - api_host: str, - org: str, - service_slug: str, - audience: str | None = None, -) -> str: +def _cache_key(api_host: str, org: str, service_slug: str) -> str: """Compute a deterministic cache filename from the exchange parameters.""" - raw = f"{api_host}|{org}|{service_slug}|{audience or ''}" + raw = f"{api_host}|{org}|{service_slug}" digest = hashlib.sha256(raw.encode()).hexdigest()[:32] return f"oidc_{digest}.json" @@ -64,30 +59,20 @@ def _decode_jwt_exp(token: str) -> float | None: return None -def get_cached_token( - api_host: str, - org: str, - service_slug: str, - audience: str | None = None, -) -> str | None: +def get_cached_token(api_host: str, org: str, service_slug: str) -> str | None: """Return a cached token if it exists and is not expired.""" - token = _get_from_keyring(api_host, org, service_slug, audience) + token = _get_from_keyring(api_host, org, service_slug) if token: return token - return _get_from_disk(api_host, org, service_slug, audience) + return _get_from_disk(api_host, org, service_slug) -def _get_from_keyring( - api_host: str, - org: str, - service_slug: str, - audience: str | None, -) -> str | None: +def _get_from_keyring(api_host: str, org: str, service_slug: str) -> str | None: """Try to get token from keyring.""" try: from ...keyring import get_oidc_token - token_data = get_oidc_token(api_host, org, service_slug, audience) + token_data = get_oidc_token(api_host, org, service_slug) if not token_data: return None @@ -109,7 +94,7 @@ def _get_from_keyring( ) from ...keyring import delete_oidc_token - delete_oidc_token(api_host, org, service_slug, audience) + delete_oidc_token(api_host, org, service_slug) return None logger.debug("Using keyring OIDC token (expires in %.0fs)", remaining) else: @@ -122,17 +107,10 @@ def _get_from_keyring( return None -def _get_from_disk( - api_host: str, - org: str, - service_slug: str, - audience: str | None, -) -> str | None: +def _get_from_disk(api_host: str, org: str, service_slug: str) -> str | None: """Try to get token from disk cache.""" cache_dir = _get_cache_dir() - cache_file = os.path.join( - cache_dir, _cache_key(api_host, org, service_slug, audience) - ) + cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug)) if not os.path.isfile(cache_file): return None @@ -175,7 +153,6 @@ def store_cached_token( org: str, service_slug: str, token: str, - audience: str | None = None, ) -> None: """Cache a token in keyring (if available) or filesystem.""" expires_at = _decode_jwt_exp(token) @@ -186,21 +163,19 @@ def store_cached_token( "api_host": api_host, "org": org, "service_slug": service_slug, - "audience": audience, "cached_at": time.time(), } - if _store_in_keyring(api_host, org, service_slug, audience, data): + if _store_in_keyring(api_host, org, service_slug, data): return - _store_on_disk(api_host, org, service_slug, audience, data) + _store_on_disk(api_host, org, service_slug, data) def _store_in_keyring( api_host: str, org: str, service_slug: str, - audience: str | None, data: dict, ) -> bool: """Try to store token in keyring.""" @@ -208,13 +183,7 @@ def _store_in_keyring( from ...keyring import store_oidc_token token_data = json.dumps(data) - success = store_oidc_token( - api_host, - org, - service_slug, - token_data, - audience, - ) + success = store_oidc_token(api_host, org, service_slug, token_data) if success: logger.debug( "Stored OIDC token in keyring (expires_at=%s)", data.get("expires_at") @@ -229,14 +198,11 @@ def _store_on_disk( api_host: str, org: str, service_slug: str, - audience: str | None, data: dict, ) -> None: """Store token on disk.""" cache_dir = _get_cache_dir() - cache_file = os.path.join( - cache_dir, _cache_key(api_host, org, service_slug, audience) - ) + cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug)) try: atomic_write_json(cache_file, data) @@ -247,24 +213,17 @@ def _store_on_disk( logger.debug("Failed to write OIDC token to disk cache", exc_info=True) -def invalidate_cached_token( - api_host: str, - org: str, - service_slug: str, - audience: str | None = None, -) -> None: +def invalidate_cached_token(api_host: str, org: str, service_slug: str) -> None: """Remove a cached token from both keyring and disk.""" try: from ...keyring import delete_oidc_token - delete_oidc_token(api_host, org, service_slug, audience) + delete_oidc_token(api_host, org, service_slug) except Exception: # pylint: disable=broad-exception-caught logger.debug("Failed to delete OIDC token from keyring", exc_info=True) cache_dir = _get_cache_dir() - cache_file = os.path.join( - cache_dir, _cache_key(api_host, org, service_slug, audience) - ) + cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug)) _remove_cache_file(cache_file) diff --git a/cloudsmith_cli/core/credentials/providers/oidc_provider.py b/cloudsmith_cli/core/credentials/providers/oidc_provider.py index 093ff304..f916f4ed 100644 --- a/cloudsmith_cli/core/credentials/providers/oidc_provider.py +++ b/cloudsmith_cli/core/credentials/providers/oidc_provider.py @@ -104,12 +104,7 @@ def resolve( # pylint: disable=too-many-return-statements # Check cache BEFORE environment detection — detection can be expensive # (e.g. boto3 credential resolution, IMDS calls) and is unnecessary when # we already hold a valid exchanged token. - cached = get_cached_token( - context.api_host, - org, - service_slug, - context.oidc_audience, - ) + cached = get_cached_token(context.api_host, org, service_slug) if cached: logger.debug("OidcProvider: Using cached OIDC token") return CredentialResult( @@ -194,7 +189,6 @@ def resolve( # pylint: disable=too-many-return-statements org, service_slug, cloudsmith_token, - context.oidc_audience, ) return CredentialResult( diff --git a/cloudsmith_cli/core/keyring.py b/cloudsmith_cli/core/keyring.py index be7468a8..7b247a66 100644 --- a/cloudsmith_cli/core/keyring.py +++ b/cloudsmith_cli/core/keyring.py @@ -320,24 +320,17 @@ def delete_sso_tokens(api_host, profile=None, include_legacy=True): return any(results) -OIDC_TOKEN_KEY = ( - "cloudsmith_cli-oidc_token-{api_host}-{org}-{service_slug}-{audience}" -) +OIDC_TOKEN_KEY = "cloudsmith_cli-oidc_token-{api_host}-{org}-{service_slug}" -def store_oidc_token(api_host, org, service_slug, token_data, audience=None): +def store_oidc_token(api_host, org, service_slug, token_data): """Store OIDC token in keyring if enabled.""" from keyring.errors import KeyringError if not should_use_keyring(): return False - key = OIDC_TOKEN_KEY.format( - api_host=api_host, - org=org, - service_slug=service_slug, - audience=audience or "", - ) + key = OIDC_TOKEN_KEY.format(api_host=api_host, org=org, service_slug=service_slug) try: _set_value(key, token_data) return True @@ -345,26 +338,16 @@ def store_oidc_token(api_host, org, service_slug, token_data, audience=None): return False -def get_oidc_token(api_host, org, service_slug, audience=None): +def get_oidc_token(api_host, org, service_slug): """Retrieve OIDC token from keyring.""" if not should_use_keyring(): return None - key = OIDC_TOKEN_KEY.format( - api_host=api_host, - org=org, - service_slug=service_slug, - audience=audience or "", - ) + key = OIDC_TOKEN_KEY.format(api_host=api_host, org=org, service_slug=service_slug) return _get_value(key) -def delete_oidc_token(api_host, org, service_slug, audience=None): +def delete_oidc_token(api_host, org, service_slug): """Delete OIDC token from keyring.""" - key = OIDC_TOKEN_KEY.format( - api_host=api_host, - org=org, - service_slug=service_slug, - audience=audience or "", - ) + key = OIDC_TOKEN_KEY.format(api_host=api_host, org=org, service_slug=service_slug) return _delete_value(key) diff --git a/cloudsmith_cli/core/tests/test_oidc_provider.py b/cloudsmith_cli/core/tests/test_oidc_provider.py index ac437b52..d6ff5691 100644 --- a/cloudsmith_cli/core/tests/test_oidc_provider.py +++ b/cloudsmith_cli/core/tests/test_oidc_provider.py @@ -10,14 +10,10 @@ from cloudsmith_cli.core.credentials.providers.oidc_provider import OidcProvider -def _context( - service_slug: str = "github-actions", - audience: str | None = None, -) -> CredentialContext: +def _context(service_slug: str = "github-actions") -> CredentialContext: return CredentialContext( org="cloudsmith", oidc_service_slug=service_slug, - oidc_audience=audience, ) @@ -71,8 +67,8 @@ def test_switching_service_accounts_mints_and_caches_separate_tokens(): def get_cached(*key): return cache.get(key) - def store_cached(api_host, org, service_slug, token, audience): - cache[(api_host, org, service_slug, audience)] = token + def store_cached(api_host, org, service_slug, token): + cache[(api_host, org, service_slug)] = token with ( patch( @@ -100,46 +96,6 @@ def store_cached(api_host, org, service_slug, token, audience): assert push_credential.api_key == "push-token" assert cached_read_credential.api_key == "read-token" assert exchange.call_count == 2 - - -def test_switching_oidc_audiences_mints_separate_tokens(): - detector = Mock(name="github-actions") - detector.name = "github-actions" - detector.get_token.return_value = "vendor-token" - cache = {} - - def get_cached(*key): - return cache.get(key) - - def store_cached(api_host, org, service_slug, token, audience): - cache[(api_host, org, service_slug, audience)] = token - - with ( - patch( - "cloudsmith_cli.core.credentials.oidc.cache.get_cached_token", - side_effect=get_cached, - ), - patch( - "cloudsmith_cli.core.credentials.oidc.cache.store_cached_token", - side_effect=store_cached, - ), - patch( - "cloudsmith_cli.core.credentials.oidc.detectors.detect_environment", - return_value=detector, - ), - patch( - "cloudsmith_cli.core.credentials.oidc.exchange.exchange_oidc_token", - side_effect=["first-token", "second-token"], - ) as exchange, - ): - first_credential = OidcProvider().resolve(_context(audience="first")) - second_credential = OidcProvider().resolve(_context(audience="second")) - - assert first_credential.api_key == "first-token" - assert second_credential.api_key == "second-token" - assert exchange.call_count == 2 - - def test_failed_exchange_logs_vendor_jwt_diagnostics(caplog): vendor_token = jwt.encode( { From 466155e416c5862173ef2c412164bd940b6be18b Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 10 Sep 2026 10:13:21 +0000 Subject: [PATCH 6/6] Remove unnecessary CLI cache changes Co-authored-by: cloudsmith-iduffy <178375997+cloudsmith-iduffy@users.noreply.github.com> --- cloudsmith_cli/core/credentials/oidc/cache.py | 21 ++-------- .../credentials/providers/oidc_provider.py | 7 +--- .../core/tests/test_oidc_provider.py | 42 +------------------ 3 files changed, 6 insertions(+), 64 deletions(-) diff --git a/cloudsmith_cli/core/credentials/oidc/cache.py b/cloudsmith_cli/core/credentials/oidc/cache.py index db6080d9..2888e004 100644 --- a/cloudsmith_cli/core/credentials/oidc/cache.py +++ b/cloudsmith_cli/core/credentials/oidc/cache.py @@ -148,12 +148,7 @@ def _get_from_disk(api_host: str, org: str, service_slug: str) -> str | None: return None -def store_cached_token( - api_host: str, - org: str, - service_slug: str, - token: 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) @@ -172,12 +167,7 @@ def store_cached_token( _store_on_disk(api_host, org, service_slug, data) -def _store_in_keyring( - api_host: str, - org: str, - service_slug: str, - data: dict, -) -> bool: +def _store_in_keyring(api_host: str, org: str, service_slug: str, data: dict) -> bool: """Try to store token in keyring.""" try: from ...keyring import store_oidc_token @@ -194,12 +184,7 @@ def _store_in_keyring( return False -def _store_on_disk( - api_host: str, - org: str, - service_slug: str, - data: dict, -) -> None: +def _store_on_disk(api_host: str, org: str, service_slug: str, data: dict) -> None: """Store token on disk.""" cache_dir = _get_cache_dir() cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug)) diff --git a/cloudsmith_cli/core/credentials/providers/oidc_provider.py b/cloudsmith_cli/core/credentials/providers/oidc_provider.py index f916f4ed..e8373169 100644 --- a/cloudsmith_cli/core/credentials/providers/oidc_provider.py +++ b/cloudsmith_cli/core/credentials/providers/oidc_provider.py @@ -184,12 +184,7 @@ def resolve( # pylint: disable=too-many-return-statements if not cloudsmith_token: return None - store_cached_token( - context.api_host, - org, - service_slug, - cloudsmith_token, - ) + store_cached_token(context.api_host, org, service_slug, cloudsmith_token) return CredentialResult( api_key=cloudsmith_token, diff --git a/cloudsmith_cli/core/tests/test_oidc_provider.py b/cloudsmith_cli/core/tests/test_oidc_provider.py index d6ff5691..36fc4a93 100644 --- a/cloudsmith_cli/core/tests/test_oidc_provider.py +++ b/cloudsmith_cli/core/tests/test_oidc_provider.py @@ -10,10 +10,10 @@ from cloudsmith_cli.core.credentials.providers.oidc_provider import OidcProvider -def _context(service_slug: str = "github-actions") -> CredentialContext: +def _context() -> CredentialContext: return CredentialContext( org="cloudsmith", - oidc_service_slug=service_slug, + oidc_service_slug="github-actions", ) @@ -58,44 +58,6 @@ def test_exchanged_oidc_token_is_a_bearer_credential(): assert credential.auth_type == "bearer" -def test_switching_service_accounts_mints_and_caches_separate_tokens(): - detector = Mock(name="github-actions") - detector.name = "github-actions" - detector.get_token.return_value = "vendor-token" - cache = {} - - def get_cached(*key): - return cache.get(key) - - def store_cached(api_host, org, service_slug, token): - cache[(api_host, org, service_slug)] = token - - with ( - patch( - "cloudsmith_cli.core.credentials.oidc.cache.get_cached_token", - side_effect=get_cached, - ), - patch( - "cloudsmith_cli.core.credentials.oidc.cache.store_cached_token", - side_effect=store_cached, - ), - patch( - "cloudsmith_cli.core.credentials.oidc.detectors.detect_environment", - return_value=detector, - ), - patch( - "cloudsmith_cli.core.credentials.oidc.exchange.exchange_oidc_token", - side_effect=["read-token", "push-token"], - ) as exchange, - ): - read_credential = OidcProvider().resolve(_context("read-service")) - push_credential = OidcProvider().resolve(_context("push-service")) - cached_read_credential = OidcProvider().resolve(_context("read-service")) - - assert read_credential.api_key == "read-token" - assert push_credential.api_key == "push-token" - assert cached_read_credential.api_key == "read-token" - assert exchange.call_count == 2 def test_failed_exchange_logs_vendor_jwt_diagnostics(caplog): vendor_token = jwt.encode( {