diff --git a/cloudsmith_cli/core/api/init.py b/cloudsmith_cli/core/api/init.py index 7789e658..b7f1290f 100644 --- a/cloudsmith_cli/core/api/init.py +++ b/cloudsmith_cli/core/api/init.py @@ -26,6 +26,10 @@ def _try_oidc_credential(config): context = CredentialContext( api_host=config.host, debug=config.debug, + proxy=config.proxy, + ssl_verify=config.verify_ssl, + user_agent=config.user_agent, + headers=config.headers.copy() if config.headers else None, ) provider = OidcProvider() diff --git a/cloudsmith_cli/core/credentials/__init__.py b/cloudsmith_cli/core/credentials/__init__.py index 417638a8..9e3c7831 100644 --- a/cloudsmith_cli/core/credentials/__init__.py +++ b/cloudsmith_cli/core/credentials/__init__.py @@ -24,6 +24,11 @@ class CredentialContext: debug: bool = False # Pre-resolved values from CLI flags (highest priority) cli_api_key: str | None = None + # API networking configuration + proxy: str | None = None + ssl_verify: bool = True + user_agent: str | None = None + headers: dict | None = None @dataclass diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py index e0d139f7..dfee1aca 100644 --- a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +++ b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py @@ -32,10 +32,32 @@ ] -def detect_environment(debug: bool = False) -> EnvironmentDetector | None: - """Try each detector in order, returning the first that matches.""" +def detect_environment( + debug: bool = False, + proxy: str | None = None, + ssl_verify: bool = True, + user_agent: str | None = None, + headers: dict | None = None, +) -> EnvironmentDetector | None: + """Try each detector in order, returning the first that matches. + + Args: + debug: Enable debug logging. + proxy: HTTP/HTTPS proxy URL (optional). + ssl_verify: Whether to verify SSL certificates (default: True). + user_agent: Custom user-agent string (optional). + headers: Additional headers to include (optional). + + Returns: + The first matching detector instance, or None. + """ for detector_cls in _DETECTORS: - detector = detector_cls() + detector = detector_cls( + proxy=proxy, + ssl_verify=ssl_verify, + user_agent=user_agent, + headers=headers, + ) try: if detector.detect(): if debug: diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py b/cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py index 33bf42be..2e55215b 100644 --- a/cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py +++ b/cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py @@ -33,7 +33,8 @@ def get_token(self) -> str: access_token = os.environ["SYSTEM_ACCESSTOKEN"] audience = get_oidc_audience() - response = requests.post( + session = self._create_session() + response = session.post( request_uri, json={"audience": audience}, headers={ diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/base.py b/cloudsmith_cli/core/credentials/oidc/detectors/base.py index 1eaa7a7a..1b4c3747 100644 --- a/cloudsmith_cli/core/credentials/oidc/detectors/base.py +++ b/cloudsmith_cli/core/credentials/oidc/detectors/base.py @@ -4,6 +4,8 @@ import os +import requests + DEFAULT_OIDC_AUDIENCE = "cloudsmith" OIDC_AUDIENCE_ENV_VAR = "CLOUDSMITH_OIDC_AUDIENCE" @@ -18,6 +20,26 @@ class EnvironmentDetector: name: str = "base" + def __init__( + self, + proxy: str | None = None, + ssl_verify: bool = True, + user_agent: str | None = None, + headers: dict | None = None, + ): + """Initialize detector with optional networking configuration. + + Args: + proxy: HTTP/HTTPS proxy URL (optional). + ssl_verify: Whether to verify SSL certificates (default: True). + user_agent: Custom user-agent string (optional). + headers: Additional headers to include (optional). + """ + self.proxy = proxy + self.ssl_verify = ssl_verify + self.user_agent = user_agent + self.headers = headers + def detect(self) -> bool: """Return True if running in this CI/CD environment.""" raise NotImplementedError @@ -25,3 +47,24 @@ def detect(self) -> bool: def get_token(self) -> str: """Retrieve the OIDC JWT from this environment. Raises on failure.""" raise NotImplementedError + + def _create_session(self) -> requests.Session: + """Create a requests session configured with networking settings. + + Returns: + Configured requests.Session instance. + """ + session = requests.Session() + + if self.proxy: + session.proxies = {"http": self.proxy, "https": self.proxy} + + session.verify = self.ssl_verify + + if self.user_agent: + session.headers.update({"User-Agent": self.user_agent}) + + if self.headers: + session.headers.update(self.headers) + + return session diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py b/cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py index dd3a0b0a..09270e7a 100644 --- a/cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py +++ b/cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py @@ -38,7 +38,8 @@ def get_token(self) -> str: separator = "&" if "?" in request_url else "?" url = f"{request_url}{separator}audience={quote(audience, safe='')}" - response = requests.get( + session = self._create_session() + response = session.get( url, headers={ "Authorization": f"Bearer {request_token}", diff --git a/cloudsmith_cli/core/credentials/oidc/exchange.py b/cloudsmith_cli/core/credentials/oidc/exchange.py index 65f00459..2e2b8b2f 100644 --- a/cloudsmith_cli/core/credentials/oidc/exchange.py +++ b/cloudsmith_cli/core/credentials/oidc/exchange.py @@ -21,11 +21,48 @@ MAX_RETRIES = 3 +def create_exchange_session( + proxy: str | None = None, + ssl_verify: bool = True, + user_agent: str | None = None, + headers: dict | None = None, +) -> requests.Session: + """Create a requests session configured with networking settings. + + Args: + proxy: HTTP/HTTPS proxy URL. + ssl_verify: Whether to verify SSL certificates. + user_agent: Custom user-agent string. + headers: Additional headers to include. + + Returns: + Configured requests.Session instance. + """ + session = requests.Session() + + if proxy: + session.proxies = {"http": proxy, "https": proxy} + + session.verify = ssl_verify + + if user_agent: + session.headers.update({"User-Agent": user_agent}) + + if headers: + session.headers.update(headers) + + return session + + def exchange_oidc_token( api_host: str, org: str, service_slug: str, oidc_token: str, + proxy: str | None = None, + ssl_verify: bool = True, + user_agent: str | None = None, + headers: dict | None = None, ) -> str: """Exchange a vendor OIDC JWT for a Cloudsmith API token. @@ -34,6 +71,10 @@ def exchange_oidc_token( org: The Cloudsmith organization slug. service_slug: The Cloudsmith service account slug. oidc_token: The vendor OIDC JWT to exchange. + proxy: HTTP/HTTPS proxy URL (optional). + ssl_verify: Whether to verify SSL certificates (default: True). + user_agent: Custom user-agent string (optional). + headers: Additional headers to include (optional). Returns: The short-lived Cloudsmith API token. @@ -52,10 +93,18 @@ def exchange_oidc_token( "service_slug": service_slug, } + # Create configured session for the exchange + session = create_exchange_session( + proxy=proxy, + ssl_verify=ssl_verify, + user_agent=user_agent, + headers=headers, + ) + last_error = None for attempt in range(1, MAX_RETRIES + 1): try: - response = requests.post( + response = session.post( url, json=payload, headers={"Content-Type": "application/json"}, diff --git a/cloudsmith_cli/core/credentials/providers.py b/cloudsmith_cli/core/credentials/providers.py index 76f82b94..6d9f907a 100644 --- a/cloudsmith_cli/core/credentials/providers.py +++ b/cloudsmith_cli/core/credentials/providers.py @@ -163,7 +163,13 @@ def resolve( # pylint: disable=too-many-return-statements from .oidc.exchange import exchange_oidc_token # Detect CI/CD environment and get vendor JWT - detector = detect_environment(debug=context.debug) + detector = detect_environment( + debug=context.debug, + proxy=context.proxy, + ssl_verify=context.ssl_verify, + user_agent=context.user_agent, + headers=context.headers, + ) if detector is None: if context.debug: logger.debug("OidcProvider: No CI/CD environment detected, skipping") @@ -205,6 +211,10 @@ def resolve( # pylint: disable=too-many-return-statements org=org, service_slug=service_slug, oidc_token=vendor_token, + proxy=context.proxy, + ssl_verify=context.ssl_verify, + user_agent=context.user_agent, + headers=context.headers, ) except Exception: # pylint: disable=broad-exception-caught # Exchange can fail for various reasons (network, API errors, auth issues) diff --git a/cloudsmith_cli/core/tests/test_credentials.py b/cloudsmith_cli/core/tests/test_credentials.py index aa19b5eb..2606eb67 100644 --- a/cloudsmith_cli/core/tests/test_credentials.py +++ b/cloudsmith_cli/core/tests/test_credentials.py @@ -157,12 +157,15 @@ def test_not_detected_without_request_url(self): with patch.dict(os.environ, env, clear=True): assert detector.detect() is False - @patch("cloudsmith_cli.core.credentials.oidc.detectors.github_actions.requests.get") - def test_get_token(self, mock_get): + @patch("cloudsmith_cli.core.credentials.oidc.detectors.github_actions.requests.Session") + def test_get_token(self, mock_session_cls): mock_response = MagicMock() mock_response.json.return_value = {"value": "jwt-token-123"} mock_response.raise_for_status.return_value = None - mock_get.return_value = mock_response + + mock_session = MagicMock() + mock_session.get.return_value = mock_response + mock_session_cls.return_value = mock_session detector = GitHubActionsDetector() env = { @@ -173,17 +176,20 @@ def test_get_token(self, mock_get): token = detector.get_token() assert token == "jwt-token-123" - mock_get.assert_called_once() - call_args = mock_get.call_args + mock_session.get.assert_called_once() + call_args = mock_session.get.call_args assert "Bearer gha-token" in call_args.kwargs["headers"]["Authorization"] assert "audience=cloudsmith" in call_args.args[0] - @patch("cloudsmith_cli.core.credentials.oidc.detectors.github_actions.requests.get") - def test_get_token_custom_audience(self, mock_get): + @patch("cloudsmith_cli.core.credentials.oidc.detectors.github_actions.requests.Session") + def test_get_token_custom_audience(self, mock_session_cls): mock_response = MagicMock() mock_response.json.return_value = {"value": "jwt-token-123"} mock_response.raise_for_status.return_value = None - mock_get.return_value = mock_response + + mock_session = MagicMock() + mock_session.get.return_value = mock_response + mock_session_cls.return_value = mock_session detector = GitHubActionsDetector() env = { @@ -195,7 +201,7 @@ def test_get_token_custom_audience(self, mock_get): token = detector.get_token() assert token == "jwt-token-123" - assert "audience=custom-aud" in mock_get.call_args.args[0] + assert "audience=custom-aud" in mock_session.get.call_args.args[0] class TestGitLabCIDetector: @@ -255,12 +261,15 @@ def test_detects_azure(self): with patch.dict(os.environ, env, clear=True): assert detector.detect() is True - @patch("cloudsmith_cli.core.credentials.oidc.detectors.azure_devops.requests.post") - def test_get_token(self, mock_post): + @patch("cloudsmith_cli.core.credentials.oidc.detectors.azure_devops.requests.Session") + def test_get_token(self, mock_session_cls): mock_response = MagicMock() mock_response.json.return_value = {"oidcToken": "ado-jwt-123"} mock_response.raise_for_status.return_value = None - mock_post.return_value = mock_response + + mock_session = MagicMock() + mock_session.post.return_value = mock_response + mock_session_cls.return_value = mock_session detector = AzureDevOpsDetector() env = { @@ -271,8 +280,8 @@ def test_get_token(self, mock_post): token = detector.get_token() assert token == "ado-jwt-123" - mock_post.assert_called_once() - call_args = mock_post.call_args + mock_session.post.assert_called_once() + call_args = mock_session.post.call_args assert call_args.kwargs["json"]["audience"] == "cloudsmith" @@ -399,12 +408,15 @@ def test_github_takes_priority(self): class TestOidcExchange: - @patch("cloudsmith_cli.core.credentials.oidc.exchange.requests.post") - def test_successful_exchange(self, mock_post): + @patch("cloudsmith_cli.core.credentials.oidc.exchange.requests.Session") + def test_successful_exchange(self, mock_session_cls): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"token": "cloudsmith-jwt-abc"} - mock_post.return_value = mock_response + + mock_session = MagicMock() + mock_session.post.return_value = mock_response + mock_session_cls.return_value = mock_session token = exchange_oidc_token( api_host="https://api.cloudsmith.io", @@ -414,18 +426,21 @@ def test_successful_exchange(self, mock_post): ) assert token == "cloudsmith-jwt-abc" - mock_post.assert_called_once() - call_args = mock_post.call_args + mock_session.post.assert_called_once() + call_args = mock_session.post.call_args assert call_args.args[0] == "https://api.cloudsmith.io/openid/test-org/" assert call_args.kwargs["json"]["oidc_token"] == "vendor-jwt" assert call_args.kwargs["json"]["service_slug"] == "test-service" - @patch("cloudsmith_cli.core.credentials.oidc.exchange.requests.post") - def test_4xx_raises_immediately(self, mock_post): + @patch("cloudsmith_cli.core.credentials.oidc.exchange.requests.Session") + def test_4xx_raises_immediately(self, mock_session_cls): mock_response = MagicMock() mock_response.status_code = 401 mock_response.json.return_value = {"detail": "Invalid token"} - mock_post.return_value = mock_response + + mock_session = MagicMock() + mock_session.post.return_value = mock_response + mock_session_cls.return_value = mock_session with pytest.raises(OidcExchangeError, match="401"): exchange_oidc_token( @@ -435,14 +450,17 @@ def test_4xx_raises_immediately(self, mock_post): oidc_token="bad-jwt", ) # 4xx should NOT retry - assert mock_post.call_count == 1 + assert mock_session.post.call_count == 1 - @patch("cloudsmith_cli.core.credentials.oidc.exchange.requests.post") - def test_empty_token_raises(self, mock_post): + @patch("cloudsmith_cli.core.credentials.oidc.exchange.requests.Session") + def test_empty_token_raises(self, mock_session_cls): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"token": ""} - mock_post.return_value = mock_response + + mock_session = MagicMock() + mock_session.post.return_value = mock_response + mock_session_cls.return_value = mock_session with pytest.raises(OidcExchangeError, match="empty or invalid"): exchange_oidc_token( @@ -452,12 +470,15 @@ def test_empty_token_raises(self, mock_post): oidc_token="vendor-jwt", ) - @patch("cloudsmith_cli.core.credentials.oidc.exchange.requests.post") - def test_host_normalization(self, mock_post): + @patch("cloudsmith_cli.core.credentials.oidc.exchange.requests.Session") + def test_host_normalization(self, mock_session_cls): mock_response = MagicMock() mock_response.status_code = 200 mock_response.json.return_value = {"token": "jwt-123"} - mock_post.return_value = mock_response + + mock_session = MagicMock() + mock_session.post.return_value = mock_response + mock_session_cls.return_value = mock_session exchange_oidc_token( api_host="api.cloudsmith.io", @@ -465,7 +486,7 @@ def test_host_normalization(self, mock_post): service_slug="svc", oidc_token="jwt", ) - call_url = mock_post.call_args.args[0] + call_url = mock_session.post.call_args.args[0] assert call_url == "https://api.cloudsmith.io/openid/myorg/"