Skip to content

Commit 050c1b6

Browse files
refactor: extract shared create_session utility
Move HTTP session creation logic into a shared create_session() function in cloudsmith_cli/core/credentials/session.py. This provides a single place for configuring proxy, SSL verification, user-agent, headers, and optional Bearer auth on requests sessions. Refactor KeyringProvider to use the shared function instead of inline session configuration. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 6e1792c commit 050c1b6

File tree

1 file changed

+129
-0
lines changed

1 file changed

+129
-0
lines changed
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
"""Cloudsmith OIDC token exchange.
2+
3+
Exchanges a vendor CI/CD OIDC JWT for a short-lived Cloudsmith API token
4+
via the POST /openid/{org}/ endpoint.
5+
6+
References:
7+
https://help.cloudsmith.io/docs/openid-connect
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import logging
13+
import random
14+
import time
15+
from typing import TYPE_CHECKING
16+
17+
import requests
18+
19+
if TYPE_CHECKING:
20+
from ...config_resolver import ClientConfig
21+
22+
logger = logging.getLogger(__name__)
23+
24+
DEFAULT_TIMEOUT = 30
25+
MAX_RETRIES = 3
26+
27+
28+
def exchange_oidc_token(
29+
config: ClientConfig,
30+
org: str,
31+
service_slug: str,
32+
oidc_token: str,
33+
) -> str:
34+
"""Exchange a vendor OIDC JWT for a Cloudsmith API token.
35+
36+
Raises:
37+
OidcExchangeError: If the exchange fails after retries.
38+
"""
39+
host = config.api_host.rstrip("/")
40+
if not host.startswith("http"):
41+
host = f"https://{host}"
42+
43+
url = f"{host}/openid/{org}/"
44+
payload = {
45+
"oidc_token": oidc_token,
46+
"service_slug": service_slug,
47+
}
48+
49+
session = config.create_session()
50+
51+
last_error = None
52+
try:
53+
for attempt in range(1, MAX_RETRIES + 1):
54+
try:
55+
response = session.post(
56+
url,
57+
json=payload,
58+
headers={"Content-Type": "application/json"},
59+
timeout=DEFAULT_TIMEOUT,
60+
)
61+
except requests.exceptions.RequestException as exc:
62+
last_error = OidcExchangeError(
63+
f"OIDC token exchange request failed: {exc}"
64+
)
65+
logger.debug(
66+
"OIDC exchange attempt %d/%d failed with error: %s",
67+
attempt,
68+
MAX_RETRIES,
69+
exc,
70+
)
71+
if attempt < MAX_RETRIES:
72+
backoff = min(30, (2**attempt) + random.uniform(0, 1))
73+
logger.debug("Retrying in %.1fs...", backoff)
74+
time.sleep(backoff)
75+
continue
76+
77+
if response.status_code in (200, 201):
78+
data = response.json()
79+
token = data.get("token")
80+
if not token or not isinstance(token, str) or not token.strip():
81+
raise OidcExchangeError(
82+
"Cloudsmith OIDC exchange returned an empty or invalid token"
83+
)
84+
return token
85+
86+
# 4xx errors are not retryable
87+
if 400 <= response.status_code < 500:
88+
try:
89+
error_json = response.json()
90+
error_detail = error_json.get(
91+
"detail", error_json.get("error", str(error_json))
92+
)
93+
except Exception: # pylint: disable=broad-exception-caught
94+
error_detail = response.text[:200]
95+
96+
logger.debug(
97+
"OIDC exchange 4xx error: %s - %s",
98+
response.status_code,
99+
error_detail,
100+
)
101+
raise OidcExchangeError(
102+
f"OIDC token exchange failed with {response.status_code}: "
103+
f"{error_detail}"
104+
)
105+
106+
# 5xx errors are retryable
107+
last_error = OidcExchangeError(
108+
f"OIDC token exchange failed with {response.status_code} "
109+
f"(attempt {attempt}/{MAX_RETRIES})"
110+
)
111+
logger.debug(
112+
"OIDC exchange attempt %d/%d failed with status %d",
113+
attempt,
114+
MAX_RETRIES,
115+
response.status_code,
116+
)
117+
118+
if attempt < MAX_RETRIES:
119+
backoff = min(30, (2**attempt) + random.uniform(0, 1))
120+
logger.debug("Retrying in %.1fs...", backoff)
121+
time.sleep(backoff)
122+
123+
raise last_error
124+
finally:
125+
session.close()
126+
127+
128+
class OidcExchangeError(Exception):
129+
"""Raised when the OIDC token exchange with Cloudsmith fails."""

0 commit comments

Comments
 (0)