cloudsmith-cli 1.20.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cloudsmith_cli/__init__.py +10 -0
- cloudsmith_cli/__main__.py +8 -0
- cloudsmith_cli/cli/__init__.py +1 -0
- cloudsmith_cli/cli/command.py +160 -0
- cloudsmith_cli/cli/commands/__init__.py +33 -0
- cloudsmith_cli/cli/commands/auth.py +173 -0
- cloudsmith_cli/cli/commands/check.py +129 -0
- cloudsmith_cli/cli/commands/copy.py +98 -0
- cloudsmith_cli/cli/commands/credential_helper/__init__.py +39 -0
- cloudsmith_cli/cli/commands/credential_helper/docker.py +66 -0
- cloudsmith_cli/cli/commands/credential_helper/manage.py +299 -0
- cloudsmith_cli/cli/commands/delete.py +67 -0
- cloudsmith_cli/cli/commands/dependencies.py +108 -0
- cloudsmith_cli/cli/commands/docs.py +16 -0
- cloudsmith_cli/cli/commands/download.py +620 -0
- cloudsmith_cli/cli/commands/entitlements.py +819 -0
- cloudsmith_cli/cli/commands/help_.py +12 -0
- cloudsmith_cli/cli/commands/list_.py +317 -0
- cloudsmith_cli/cli/commands/login.py +99 -0
- cloudsmith_cli/cli/commands/logout.py +151 -0
- cloudsmith_cli/cli/commands/main.py +73 -0
- cloudsmith_cli/cli/commands/mcp.py +523 -0
- cloudsmith_cli/cli/commands/metadata.py +503 -0
- cloudsmith_cli/cli/commands/metrics/__init__.py +2 -0
- cloudsmith_cli/cli/commands/metrics/command.py +20 -0
- cloudsmith_cli/cli/commands/metrics/entitlements.py +148 -0
- cloudsmith_cli/cli/commands/metrics/packages.py +134 -0
- cloudsmith_cli/cli/commands/move.py +111 -0
- cloudsmith_cli/cli/commands/policy/__init__.py +3 -0
- cloudsmith_cli/cli/commands/policy/command.py +20 -0
- cloudsmith_cli/cli/commands/policy/deny.py +248 -0
- cloudsmith_cli/cli/commands/policy/license.py +335 -0
- cloudsmith_cli/cli/commands/policy/vulnerability.py +322 -0
- cloudsmith_cli/cli/commands/push.py +1323 -0
- cloudsmith_cli/cli/commands/quarantine.py +148 -0
- cloudsmith_cli/cli/commands/quota/__init__.py +2 -0
- cloudsmith_cli/cli/commands/quota/command.py +20 -0
- cloudsmith_cli/cli/commands/quota/history.py +122 -0
- cloudsmith_cli/cli/commands/quota/quota.py +107 -0
- cloudsmith_cli/cli/commands/repos.py +330 -0
- cloudsmith_cli/cli/commands/resync.py +90 -0
- cloudsmith_cli/cli/commands/status.py +92 -0
- cloudsmith_cli/cli/commands/tags.py +375 -0
- cloudsmith_cli/cli/commands/tokens.py +318 -0
- cloudsmith_cli/cli/commands/upstream.py +479 -0
- cloudsmith_cli/cli/commands/vulnerabilities.py +141 -0
- cloudsmith_cli/cli/commands/whoami.py +188 -0
- cloudsmith_cli/cli/config.py +635 -0
- cloudsmith_cli/cli/decorators.py +624 -0
- cloudsmith_cli/cli/exceptions.py +215 -0
- cloudsmith_cli/cli/metadata_common.py +146 -0
- cloudsmith_cli/cli/saml.py +109 -0
- cloudsmith_cli/cli/table.py +59 -0
- cloudsmith_cli/cli/types.py +14 -0
- cloudsmith_cli/cli/utils.py +267 -0
- cloudsmith_cli/cli/validators.py +378 -0
- cloudsmith_cli/cli/webserver.py +263 -0
- cloudsmith_cli/core/__init__.py +1 -0
- cloudsmith_cli/core/api/__init__.py +1 -0
- cloudsmith_cli/core/api/distros.py +31 -0
- cloudsmith_cli/core/api/entitlements.py +130 -0
- cloudsmith_cli/core/api/exceptions.py +57 -0
- cloudsmith_cli/core/api/files.py +131 -0
- cloudsmith_cli/core/api/init.py +109 -0
- cloudsmith_cli/core/api/metadata.py +217 -0
- cloudsmith_cli/core/api/metrics.py +78 -0
- cloudsmith_cli/core/api/orgs.py +201 -0
- cloudsmith_cli/core/api/packages.py +309 -0
- cloudsmith_cli/core/api/quota.py +64 -0
- cloudsmith_cli/core/api/rates.py +28 -0
- cloudsmith_cli/core/api/repos.py +81 -0
- cloudsmith_cli/core/api/status.py +27 -0
- cloudsmith_cli/core/api/upstreams.py +72 -0
- cloudsmith_cli/core/api/user.py +109 -0
- cloudsmith_cli/core/api/version.py +15 -0
- cloudsmith_cli/core/api/vulnerabilities.py +230 -0
- cloudsmith_cli/core/cache_utils.py +160 -0
- cloudsmith_cli/core/config.py +140 -0
- cloudsmith_cli/core/credentials/__init__.py +0 -0
- cloudsmith_cli/core/credentials/chain.py +69 -0
- cloudsmith_cli/core/credentials/models.py +44 -0
- cloudsmith_cli/core/credentials/oidc/__init__.py +6 -0
- cloudsmith_cli/core/credentials/oidc/cache.py +220 -0
- cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +122 -0
- cloudsmith_cli/core/credentials/oidc/detectors/aws.py +85 -0
- cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py +70 -0
- cloudsmith_cli/core/credentials/oidc/detectors/base.py +26 -0
- cloudsmith_cli/core/credentials/oidc/detectors/bitbucket_pipelines.py +35 -0
- cloudsmith_cli/core/credentials/oidc/detectors/circleci.py +40 -0
- cloudsmith_cli/core/credentials/oidc/detectors/generic.py +42 -0
- cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py +64 -0
- cloudsmith_cli/core/credentials/oidc/detectors/gitlab_ci.py +49 -0
- cloudsmith_cli/core/credentials/oidc/exchange.py +87 -0
- cloudsmith_cli/core/credentials/provider.py +17 -0
- cloudsmith_cli/core/credentials/providers/__init__.py +15 -0
- cloudsmith_cli/core/credentials/providers/cli_flag.py +24 -0
- cloudsmith_cli/core/credentials/providers/credentials_file.py +24 -0
- cloudsmith_cli/core/credentials/providers/env_var.py +24 -0
- cloudsmith_cli/core/credentials/providers/keyring_provider.py +59 -0
- cloudsmith_cli/core/credentials/providers/oidc_provider.py +115 -0
- cloudsmith_cli/core/download.py +594 -0
- cloudsmith_cli/core/keyring.py +171 -0
- cloudsmith_cli/core/mcp/__init__.py +0 -0
- cloudsmith_cli/core/mcp/data.py +17 -0
- cloudsmith_cli/core/mcp/server.py +786 -0
- cloudsmith_cli/core/pagination.py +131 -0
- cloudsmith_cli/core/ratelimits.py +88 -0
- cloudsmith_cli/core/rest.py +255 -0
- cloudsmith_cli/core/utils.py +95 -0
- cloudsmith_cli/core/version.py +20 -0
- cloudsmith_cli/credential_helpers/__init__.py +7 -0
- cloudsmith_cli/credential_helpers/backends.py +41 -0
- cloudsmith_cli/credential_helpers/common.py +111 -0
- cloudsmith_cli/credential_helpers/custom_domains.py +281 -0
- cloudsmith_cli/credential_helpers/docker/__init__.py +4 -0
- cloudsmith_cli/credential_helpers/docker/installer.py +349 -0
- cloudsmith_cli/credential_helpers/docker/runtime.py +117 -0
- cloudsmith_cli/credential_helpers/launchers.py +175 -0
- cloudsmith_cli/data/VERSION +1 -0
- cloudsmith_cli/data/config.ini +23 -0
- cloudsmith_cli/data/credentials.ini +14 -0
- cloudsmith_cli/templates/__init__.py +3 -0
- cloudsmith_cli/templates/auth_error.html +45 -0
- cloudsmith_cli/templates/auth_success.html +37 -0
- cloudsmith_cli-1.20.0.dist-info/METADATA +610 -0
- cloudsmith_cli-1.20.0.dist-info/RECORD +130 -0
- cloudsmith_cli-1.20.0.dist-info/WHEEL +5 -0
- cloudsmith_cli-1.20.0.dist-info/entry_points.txt +2 -0
- cloudsmith_cli-1.20.0.dist-info/licenses/LICENSE +201 -0
- cloudsmith_cli-1.20.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
"""OIDC token cache.
|
|
2
|
+
|
|
3
|
+
Caches Cloudsmith API tokens obtained via OIDC exchange to avoid unnecessary
|
|
4
|
+
re-exchanges. Uses system keyring when available (respecting CLOUDSMITH_NO_KEYRING),
|
|
5
|
+
with automatic fallback to filesystem storage when keyring is unavailable.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import json
|
|
12
|
+
import logging
|
|
13
|
+
import os
|
|
14
|
+
import time
|
|
15
|
+
|
|
16
|
+
from ...cache_utils import atomic_write_json
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
EXPIRY_MARGIN_SECONDS = 60
|
|
21
|
+
|
|
22
|
+
_CACHE_DIR_NAME = "oidc_token_cache"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _get_cache_dir() -> str:
|
|
26
|
+
"""Return the cache directory path, creating it if needed."""
|
|
27
|
+
from ....cli.config import get_default_config_path
|
|
28
|
+
|
|
29
|
+
base = get_default_config_path()
|
|
30
|
+
cache_dir = os.path.join(base, _CACHE_DIR_NAME)
|
|
31
|
+
if not os.path.isdir(cache_dir):
|
|
32
|
+
os.makedirs(cache_dir, mode=0o700, exist_ok=True)
|
|
33
|
+
return cache_dir
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def _cache_key(api_host: str, org: str, service_slug: str) -> str:
|
|
37
|
+
"""Compute a deterministic cache filename from the exchange parameters."""
|
|
38
|
+
raw = f"{api_host}|{org}|{service_slug}"
|
|
39
|
+
digest = hashlib.sha256(raw.encode()).hexdigest()[:32]
|
|
40
|
+
return f"oidc_{digest}.json"
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _decode_jwt_exp(token: str) -> float | None:
|
|
44
|
+
"""Read the exp claim from a JWT payload.
|
|
45
|
+
|
|
46
|
+
The token is only inspected to determine a cache TTL; it is never used to
|
|
47
|
+
make an authorization decision, so the signature is deliberately not
|
|
48
|
+
verified (the API rejects tampered tokens regardless).
|
|
49
|
+
"""
|
|
50
|
+
try:
|
|
51
|
+
import jwt
|
|
52
|
+
|
|
53
|
+
payload = jwt.decode(token, options={"verify_signature": False})
|
|
54
|
+
exp = payload.get("exp")
|
|
55
|
+
if exp is not None:
|
|
56
|
+
return float(exp)
|
|
57
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
58
|
+
logger.debug("Failed to decode JWT expiry", exc_info=True)
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_cached_token(api_host: str, org: str, service_slug: str) -> str | None:
|
|
63
|
+
"""Return a cached token if it exists and is not expired."""
|
|
64
|
+
token = _get_from_keyring(api_host, org, service_slug)
|
|
65
|
+
if token:
|
|
66
|
+
return token
|
|
67
|
+
return _get_from_disk(api_host, org, service_slug)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _get_from_keyring(api_host: str, org: str, service_slug: str) -> str | None:
|
|
71
|
+
"""Try to get token from keyring."""
|
|
72
|
+
try:
|
|
73
|
+
from ...keyring import get_oidc_token
|
|
74
|
+
|
|
75
|
+
token_data = get_oidc_token(api_host, org, service_slug)
|
|
76
|
+
if not token_data:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
data = json.loads(token_data)
|
|
80
|
+
token = data.get("token")
|
|
81
|
+
expires_at = data.get("expires_at")
|
|
82
|
+
|
|
83
|
+
if not token:
|
|
84
|
+
return None
|
|
85
|
+
|
|
86
|
+
if expires_at is not None:
|
|
87
|
+
remaining = expires_at - time.time()
|
|
88
|
+
if remaining < EXPIRY_MARGIN_SECONDS:
|
|
89
|
+
logger.debug(
|
|
90
|
+
"Keyring OIDC token expired or expiring soon "
|
|
91
|
+
"(%.0fs remaining, margin=%ds)",
|
|
92
|
+
remaining,
|
|
93
|
+
EXPIRY_MARGIN_SECONDS,
|
|
94
|
+
)
|
|
95
|
+
from ...keyring import delete_oidc_token
|
|
96
|
+
|
|
97
|
+
delete_oidc_token(api_host, org, service_slug)
|
|
98
|
+
return None
|
|
99
|
+
logger.debug("Using keyring OIDC token (expires in %.0fs)", remaining)
|
|
100
|
+
else:
|
|
101
|
+
logger.debug("Using keyring OIDC token (no expiry information)")
|
|
102
|
+
|
|
103
|
+
return token
|
|
104
|
+
|
|
105
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
106
|
+
logger.debug("Failed to read OIDC token from keyring", exc_info=True)
|
|
107
|
+
return None
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _get_from_disk(api_host: str, org: str, service_slug: str) -> str | None:
|
|
111
|
+
"""Try to get token from disk cache."""
|
|
112
|
+
cache_dir = _get_cache_dir()
|
|
113
|
+
cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug))
|
|
114
|
+
|
|
115
|
+
if not os.path.isfile(cache_file):
|
|
116
|
+
return None
|
|
117
|
+
|
|
118
|
+
try:
|
|
119
|
+
with open(cache_file) as f:
|
|
120
|
+
data = json.load(f)
|
|
121
|
+
|
|
122
|
+
token = data.get("token")
|
|
123
|
+
expires_at = data.get("expires_at")
|
|
124
|
+
|
|
125
|
+
if not token:
|
|
126
|
+
return None
|
|
127
|
+
|
|
128
|
+
if expires_at is not None:
|
|
129
|
+
remaining = expires_at - time.time()
|
|
130
|
+
if remaining < EXPIRY_MARGIN_SECONDS:
|
|
131
|
+
logger.debug(
|
|
132
|
+
"Disk cached OIDC token expired or expiring soon "
|
|
133
|
+
"(%.0fs remaining, margin=%ds)",
|
|
134
|
+
remaining,
|
|
135
|
+
EXPIRY_MARGIN_SECONDS,
|
|
136
|
+
)
|
|
137
|
+
_remove_cache_file(cache_file)
|
|
138
|
+
return None
|
|
139
|
+
logger.debug("Using disk cached OIDC token (expires in %.0fs)", remaining)
|
|
140
|
+
else:
|
|
141
|
+
logger.debug("Using disk cached OIDC token (no expiry information)")
|
|
142
|
+
|
|
143
|
+
return token
|
|
144
|
+
|
|
145
|
+
except (json.JSONDecodeError, OSError, KeyError):
|
|
146
|
+
logger.debug("Failed to read OIDC token from disk cache", exc_info=True)
|
|
147
|
+
_remove_cache_file(cache_file)
|
|
148
|
+
return None
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def store_cached_token(api_host: str, org: str, service_slug: str, token: str) -> None:
|
|
152
|
+
"""Cache a token in keyring (if available) or filesystem."""
|
|
153
|
+
expires_at = _decode_jwt_exp(token)
|
|
154
|
+
|
|
155
|
+
data = {
|
|
156
|
+
"token": token,
|
|
157
|
+
"expires_at": expires_at,
|
|
158
|
+
"api_host": api_host,
|
|
159
|
+
"org": org,
|
|
160
|
+
"service_slug": service_slug,
|
|
161
|
+
"cached_at": time.time(),
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if _store_in_keyring(api_host, org, service_slug, data):
|
|
165
|
+
return
|
|
166
|
+
|
|
167
|
+
_store_on_disk(api_host, org, service_slug, data)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _store_in_keyring(api_host: str, org: str, service_slug: str, data: dict) -> bool:
|
|
171
|
+
"""Try to store token in keyring."""
|
|
172
|
+
try:
|
|
173
|
+
from ...keyring import store_oidc_token
|
|
174
|
+
|
|
175
|
+
token_data = json.dumps(data)
|
|
176
|
+
success = store_oidc_token(api_host, org, service_slug, token_data)
|
|
177
|
+
if success:
|
|
178
|
+
logger.debug(
|
|
179
|
+
"Stored OIDC token in keyring (expires_at=%s)", data.get("expires_at")
|
|
180
|
+
)
|
|
181
|
+
return success
|
|
182
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
183
|
+
logger.debug("Failed to store OIDC token in keyring", exc_info=True)
|
|
184
|
+
return False
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _store_on_disk(api_host: str, org: str, service_slug: str, data: dict) -> None:
|
|
188
|
+
"""Store token on disk."""
|
|
189
|
+
cache_dir = _get_cache_dir()
|
|
190
|
+
cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug))
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
atomic_write_json(cache_file, data)
|
|
194
|
+
logger.debug(
|
|
195
|
+
"Stored OIDC token on disk (expires_at=%s)", data.get("expires_at")
|
|
196
|
+
)
|
|
197
|
+
except OSError:
|
|
198
|
+
logger.debug("Failed to write OIDC token to disk cache", exc_info=True)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def invalidate_cached_token(api_host: str, org: str, service_slug: str) -> None:
|
|
202
|
+
"""Remove a cached token from both keyring and disk."""
|
|
203
|
+
try:
|
|
204
|
+
from ...keyring import delete_oidc_token
|
|
205
|
+
|
|
206
|
+
delete_oidc_token(api_host, org, service_slug)
|
|
207
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
208
|
+
logger.debug("Failed to delete OIDC token from keyring", exc_info=True)
|
|
209
|
+
|
|
210
|
+
cache_dir = _get_cache_dir()
|
|
211
|
+
cache_file = os.path.join(cache_dir, _cache_key(api_host, org, service_slug))
|
|
212
|
+
_remove_cache_file(cache_file)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _remove_cache_file(path: str) -> None:
|
|
216
|
+
"""Safely remove a cache file."""
|
|
217
|
+
try:
|
|
218
|
+
os.unlink(path)
|
|
219
|
+
except (OSError, TypeError):
|
|
220
|
+
pass
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"""Environment detectors for OIDC token retrieval."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
from .aws import AWSDetector
|
|
9
|
+
from .azure_devops import AzureDevOpsDetector
|
|
10
|
+
from .base import EnvironmentDetector
|
|
11
|
+
from .bitbucket_pipelines import BitbucketPipelinesDetector
|
|
12
|
+
from .circleci import CircleCIDetector
|
|
13
|
+
from .generic import GenericDetector
|
|
14
|
+
from .github_actions import GitHubActionsDetector
|
|
15
|
+
from .gitlab_ci import GitLabCIDetector
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Mapping
|
|
19
|
+
|
|
20
|
+
from ... import CredentialContext
|
|
21
|
+
|
|
22
|
+
logger = logging.getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
_DETECTORS: list[type[EnvironmentDetector]] = [
|
|
25
|
+
CircleCIDetector,
|
|
26
|
+
AzureDevOpsDetector,
|
|
27
|
+
GitHubActionsDetector,
|
|
28
|
+
BitbucketPipelinesDetector,
|
|
29
|
+
GitLabCIDetector,
|
|
30
|
+
AWSDetector,
|
|
31
|
+
GenericDetector,
|
|
32
|
+
]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def registered_detectors() -> list[type[EnvironmentDetector]]:
|
|
36
|
+
"""Return the registered OIDC detectors in their default priority order."""
|
|
37
|
+
return list(_DETECTORS)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def disable_env_var(identifier: str) -> str:
|
|
41
|
+
"""The environment variable that disables the detector with this id."""
|
|
42
|
+
return f"CLOUDSMITH_OIDC_{identifier.upper()}_DISABLED"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _is_disabled_value(value: str | None) -> bool:
|
|
46
|
+
"""Only the literal string ``true`` (case-insensitive) disables a detector."""
|
|
47
|
+
return (value or "").strip().lower() == "true"
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def disabled_detectors_from_env(environ: Mapping[str, str]) -> frozenset[str]:
|
|
51
|
+
"""Detector ids disabled via their CLOUDSMITH_OIDC_<ID>_DISABLED variable."""
|
|
52
|
+
return frozenset(
|
|
53
|
+
detector_cls.id
|
|
54
|
+
for detector_cls in _DETECTORS
|
|
55
|
+
if _is_disabled_value(environ.get(disable_env_var(detector_cls.id)))
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _ordered_detectors(order: str | None) -> list[type[EnvironmentDetector]]:
|
|
60
|
+
"""Detectors in evaluation order, honouring an explicit order string.
|
|
61
|
+
|
|
62
|
+
When ``order`` is unset/empty the default registration order is used.
|
|
63
|
+
Otherwise only the listed ids are considered, in the listed order;
|
|
64
|
+
unknown ids are logged and skipped, and duplicate ids keep their first
|
|
65
|
+
position so each detector is evaluated at most once.
|
|
66
|
+
"""
|
|
67
|
+
raw_order = (order or "").strip()
|
|
68
|
+
if not raw_order:
|
|
69
|
+
return list(_DETECTORS)
|
|
70
|
+
|
|
71
|
+
detectors_by_id = {detector_cls.id: detector_cls for detector_cls in _DETECTORS}
|
|
72
|
+
ordered: dict[str, type[EnvironmentDetector]] = {}
|
|
73
|
+
for token in raw_order.split(","):
|
|
74
|
+
identifier = token.strip().lower()
|
|
75
|
+
if not identifier or identifier in ordered:
|
|
76
|
+
continue
|
|
77
|
+
detector_cls = detectors_by_id.get(identifier)
|
|
78
|
+
if detector_cls is None:
|
|
79
|
+
logger.debug("Ignoring unknown OIDC detector id: %s", identifier)
|
|
80
|
+
continue
|
|
81
|
+
ordered[identifier] = detector_cls
|
|
82
|
+
return list(ordered.values())
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _enabled_detectors(
|
|
86
|
+
order: str | None, disabled: frozenset[str]
|
|
87
|
+
) -> list[type[EnvironmentDetector]]:
|
|
88
|
+
"""Ordered detectors with disabled ones removed (disable always wins)."""
|
|
89
|
+
return [
|
|
90
|
+
detector_cls
|
|
91
|
+
for detector_cls in _ordered_detectors(order)
|
|
92
|
+
if detector_cls.id not in disabled
|
|
93
|
+
]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def detect_environment(
|
|
97
|
+
context: CredentialContext,
|
|
98
|
+
) -> EnvironmentDetector | None:
|
|
99
|
+
"""Try each detector in order, returning the first that matches."""
|
|
100
|
+
enabled = _enabled_detectors(
|
|
101
|
+
context.oidc_detector_order, context.oidc_disabled_detectors
|
|
102
|
+
)
|
|
103
|
+
if not enabled:
|
|
104
|
+
logger.debug("No OIDC detectors enabled after applying order/disable controls")
|
|
105
|
+
for detector_cls in enabled:
|
|
106
|
+
detector = detector_cls(context=context)
|
|
107
|
+
try:
|
|
108
|
+
if detector.detect():
|
|
109
|
+
if context.debug:
|
|
110
|
+
logger.debug("Detected OIDC environment: %s", detector.name)
|
|
111
|
+
return detector
|
|
112
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
113
|
+
logger.debug(
|
|
114
|
+
"Detector %s raised an exception during detection",
|
|
115
|
+
detector.name,
|
|
116
|
+
exc_info=True,
|
|
117
|
+
)
|
|
118
|
+
continue
|
|
119
|
+
|
|
120
|
+
if context.debug:
|
|
121
|
+
logger.debug("No supported OIDC environment detected")
|
|
122
|
+
return None
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""AWS OIDC detector.
|
|
2
|
+
|
|
3
|
+
Uses boto3 to auto-discover AWS credentials and calls STS GetWebIdentityToken
|
|
4
|
+
to obtain a signed JWT for Cloudsmith.
|
|
5
|
+
|
|
6
|
+
Requires boto3 (optional dependency): pip install cloudsmith-cli[aws]
|
|
7
|
+
|
|
8
|
+
References:
|
|
9
|
+
https://cloudsmith.com/blog/authenticate-to-cloudsmith-with-your-aws-identity
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
|
|
16
|
+
from .base import EnvironmentDetector
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
DEFAULT_AUDIENCE = "cloudsmith"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class AWSDetector(EnvironmentDetector):
|
|
24
|
+
"""Detects AWS environments and obtains a JWT via STS GetWebIdentityToken."""
|
|
25
|
+
|
|
26
|
+
name = "AWS"
|
|
27
|
+
id = "aws"
|
|
28
|
+
|
|
29
|
+
def __init__(self, context):
|
|
30
|
+
super().__init__(context)
|
|
31
|
+
self._session = None
|
|
32
|
+
|
|
33
|
+
def detect(self) -> bool:
|
|
34
|
+
try:
|
|
35
|
+
import boto3
|
|
36
|
+
from botocore.exceptions import (
|
|
37
|
+
BotoCoreError,
|
|
38
|
+
ClientError,
|
|
39
|
+
MissingDependencyException,
|
|
40
|
+
NoCredentialsError,
|
|
41
|
+
)
|
|
42
|
+
except ImportError:
|
|
43
|
+
logger.debug("AWSDetector: boto3 not installed, skipping")
|
|
44
|
+
return False
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
self._session = boto3.Session()
|
|
48
|
+
credentials = self._session.get_credentials()
|
|
49
|
+
if credentials is None:
|
|
50
|
+
return False
|
|
51
|
+
# Resolve to verify credentials are usable
|
|
52
|
+
credentials = credentials.get_frozen_credentials()
|
|
53
|
+
return bool(credentials.access_key)
|
|
54
|
+
except MissingDependencyException as e:
|
|
55
|
+
logger.debug(
|
|
56
|
+
"AWSDetector: Missing boto3 dependency for SSO credentials: %s. "
|
|
57
|
+
"Install with: pip install 'botocore[crt]' or 'boto3[crt]'",
|
|
58
|
+
e,
|
|
59
|
+
)
|
|
60
|
+
return False
|
|
61
|
+
except (BotoCoreError, NoCredentialsError, ClientError):
|
|
62
|
+
return False
|
|
63
|
+
except Exception: # pylint: disable=broad-exception-caught
|
|
64
|
+
logger.debug(
|
|
65
|
+
"AWSDetector: unexpected error during detection", exc_info=True
|
|
66
|
+
)
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
def get_token(self) -> str:
|
|
70
|
+
import boto3 # pylint: disable=import-error
|
|
71
|
+
|
|
72
|
+
audience = self.context.oidc_audience or DEFAULT_AUDIENCE
|
|
73
|
+
session = self._session or boto3.Session()
|
|
74
|
+
sts = session.client("sts")
|
|
75
|
+
response = sts.get_web_identity_token(
|
|
76
|
+
Audience=[audience],
|
|
77
|
+
SigningAlgorithm="RS256",
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
token = response.get("WebIdentityToken")
|
|
81
|
+
if not token:
|
|
82
|
+
raise ValueError(
|
|
83
|
+
"AWS STS GetWebIdentityToken did not return a WebIdentityToken"
|
|
84
|
+
)
|
|
85
|
+
return token
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# Copyright 2026 Cloudsmith Ltd
|
|
2
|
+
"""Azure DevOps OIDC detector.
|
|
3
|
+
|
|
4
|
+
Fetches an OIDC token via the ``SYSTEM_OIDCREQUESTURI`` HTTP endpoint using
|
|
5
|
+
the pipeline's ``SYSTEM_ACCESSTOKEN`` for authorization.
|
|
6
|
+
|
|
7
|
+
The audience is not caller-configurable: Azure DevOps always mints the token
|
|
8
|
+
with a fixed audience (``api://AzureADTokenExchange``) and ignores any audience
|
|
9
|
+
supplied in the request, so the request is an empty POST (matching the Azure
|
|
10
|
+
SDK's AzurePipelinesCredential).
|
|
11
|
+
|
|
12
|
+
References:
|
|
13
|
+
https://learn.microsoft.com/en-us/azure/devops/release-notes/2024/sprint-240-update#pipelines-and-tasks-populate-variables-to-customize-workload-identity-federation-authentication
|
|
14
|
+
https://github.com/Azure/azure-sdk-for-go/blob/main/sdk/azidentity/azure_pipelines_credential.go
|
|
15
|
+
https://docs.cloudsmith.com/integrations/integrating-with-azure-devops
|
|
16
|
+
https://cloudsmith.com/changelog/native-oidc-authentication-for-azure-devops
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import os
|
|
22
|
+
|
|
23
|
+
from ....rest import create_requests_session as create_session
|
|
24
|
+
from .base import EnvironmentDetector
|
|
25
|
+
|
|
26
|
+
API_VERSION = "7.1"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class AzureDevOpsDetector(EnvironmentDetector):
|
|
30
|
+
"""Detects Azure DevOps and fetches an OIDC token via HTTP POST."""
|
|
31
|
+
|
|
32
|
+
name = "Azure DevOps"
|
|
33
|
+
id = "azure_devops"
|
|
34
|
+
|
|
35
|
+
def detect(self) -> bool:
|
|
36
|
+
return bool(os.environ.get("SYSTEM_OIDCREQUESTURI")) and bool(
|
|
37
|
+
os.environ.get("SYSTEM_ACCESSTOKEN")
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
def get_token(self) -> str:
|
|
41
|
+
request_uri = os.environ["SYSTEM_OIDCREQUESTURI"]
|
|
42
|
+
access_token = os.environ["SYSTEM_ACCESSTOKEN"]
|
|
43
|
+
|
|
44
|
+
# The Azure DevOps OIDC endpoint rejects requests without an explicit
|
|
45
|
+
# api-version (HTTP 400), so it must always be supplied.
|
|
46
|
+
separator = "&" if "?" in request_uri else "?"
|
|
47
|
+
url = f"{request_uri}{separator}api-version={API_VERSION}"
|
|
48
|
+
|
|
49
|
+
session = self.context.session or create_session()
|
|
50
|
+
try:
|
|
51
|
+
response = session.post(
|
|
52
|
+
url,
|
|
53
|
+
headers={
|
|
54
|
+
"Authorization": f"Bearer {access_token}",
|
|
55
|
+
"X-TFS-FedAuthRedirect": "Suppress",
|
|
56
|
+
},
|
|
57
|
+
timeout=30,
|
|
58
|
+
)
|
|
59
|
+
response.raise_for_status()
|
|
60
|
+
|
|
61
|
+
data = response.json()
|
|
62
|
+
token = data.get("oidcToken")
|
|
63
|
+
if not token:
|
|
64
|
+
raise ValueError(
|
|
65
|
+
"Azure DevOps OIDC response did not contain an oidcToken"
|
|
66
|
+
)
|
|
67
|
+
return token
|
|
68
|
+
finally:
|
|
69
|
+
if not self.context.session:
|
|
70
|
+
session.close()
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Base class and utilities for OIDC environment detectors."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from ... import CredentialContext
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class EnvironmentDetector:
|
|
12
|
+
"""Base class for OIDC environment detectors."""
|
|
13
|
+
|
|
14
|
+
name: str = "base"
|
|
15
|
+
id: str = "base"
|
|
16
|
+
|
|
17
|
+
def __init__(self, context: CredentialContext):
|
|
18
|
+
self.context = context
|
|
19
|
+
|
|
20
|
+
def detect(self) -> bool:
|
|
21
|
+
"""Return True if running in a supported OIDC environment."""
|
|
22
|
+
raise NotImplementedError
|
|
23
|
+
|
|
24
|
+
def get_token(self) -> str:
|
|
25
|
+
"""Retrieve the OIDC JWT from this environment. Raises on failure."""
|
|
26
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# Copyright 2026 Cloudsmith Ltd
|
|
2
|
+
"""Bitbucket Pipelines OIDC detector.
|
|
3
|
+
|
|
4
|
+
Reads an OIDC token from the ``BITBUCKET_STEP_OIDC_TOKEN`` environment variable,
|
|
5
|
+
which Bitbucket populates when ``oidc: true`` is set on a pipeline step.
|
|
6
|
+
|
|
7
|
+
References:
|
|
8
|
+
https://support.atlassian.com/bitbucket-cloud/docs/integrate-pipelines-with-resource-servers-using-oidc/
|
|
9
|
+
https://support.atlassian.com/bitbucket-cloud/docs/variables-and-secrets/
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
from .base import EnvironmentDetector
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class BitbucketPipelinesDetector(EnvironmentDetector):
|
|
20
|
+
"""Detects Bitbucket Pipelines and reads its OIDC token from environment."""
|
|
21
|
+
|
|
22
|
+
name = "Bitbucket Pipelines"
|
|
23
|
+
id = "bitbucket"
|
|
24
|
+
|
|
25
|
+
def detect(self) -> bool:
|
|
26
|
+
return bool(os.environ.get("BITBUCKET_STEP_OIDC_TOKEN"))
|
|
27
|
+
|
|
28
|
+
def get_token(self) -> str:
|
|
29
|
+
token = os.environ.get("BITBUCKET_STEP_OIDC_TOKEN")
|
|
30
|
+
if not token:
|
|
31
|
+
raise ValueError(
|
|
32
|
+
"BITBUCKET_STEP_OIDC_TOKEN is not set. Enable OIDC on the "
|
|
33
|
+
"pipeline step with 'oidc: true'."
|
|
34
|
+
)
|
|
35
|
+
return token
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Copyright 2026 Cloudsmith Ltd
|
|
2
|
+
"""CircleCI OIDC detector.
|
|
3
|
+
|
|
4
|
+
Reads OIDC token from the ``CIRCLE_OIDC_TOKEN_V2`` or ``CIRCLE_OIDC_TOKEN``
|
|
5
|
+
environment variables set by CircleCI's OIDC support.
|
|
6
|
+
|
|
7
|
+
References:
|
|
8
|
+
https://circleci.com/docs/guides/permissions-authentication/openid-connect-tokens/
|
|
9
|
+
https://docs.cloudsmith.com/integrations/integrating-with-circleci
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
|
|
16
|
+
from .base import EnvironmentDetector
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class CircleCIDetector(EnvironmentDetector):
|
|
20
|
+
"""Detects CircleCI and reads OIDC token from environment variable."""
|
|
21
|
+
|
|
22
|
+
name = "CircleCI"
|
|
23
|
+
id = "circleci"
|
|
24
|
+
|
|
25
|
+
def detect(self) -> bool:
|
|
26
|
+
return os.environ.get("CIRCLECI") == "true" and bool(
|
|
27
|
+
os.environ.get("CIRCLE_OIDC_TOKEN_V2")
|
|
28
|
+
or os.environ.get("CIRCLE_OIDC_TOKEN")
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
def get_token(self) -> str:
|
|
32
|
+
token = os.environ.get("CIRCLE_OIDC_TOKEN_V2") or os.environ.get(
|
|
33
|
+
"CIRCLE_OIDC_TOKEN"
|
|
34
|
+
)
|
|
35
|
+
if not token:
|
|
36
|
+
raise ValueError(
|
|
37
|
+
"CircleCI detected but neither CIRCLE_OIDC_TOKEN_V2 nor "
|
|
38
|
+
"CIRCLE_OIDC_TOKEN is set"
|
|
39
|
+
)
|
|
40
|
+
return token
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# Copyright 2026 Cloudsmith Ltd
|
|
2
|
+
"""Generic fallback OIDC detector.
|
|
3
|
+
|
|
4
|
+
Reads an OIDC token from the ``CLOUDSMITH_OIDC_TOKEN`` environment variable.
|
|
5
|
+
Works for Jenkins (with the credentials binding plugin), or any custom CI/CD
|
|
6
|
+
system that can inject an OIDC token via an environment variable.
|
|
7
|
+
|
|
8
|
+
References:
|
|
9
|
+
https://docs.cloudsmith.com/authentication/setup-jenkins-to-authenticate-to-cloudsmith-using-oidc
|
|
10
|
+
https://plugins.jenkins.io/credentials-binding/
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import os
|
|
16
|
+
|
|
17
|
+
from .base import EnvironmentDetector
|
|
18
|
+
|
|
19
|
+
TOKEN_ENV_VAR = "CLOUDSMITH_OIDC_TOKEN"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class GenericDetector(EnvironmentDetector):
|
|
23
|
+
"""Generic fallback: reads the OIDC token from CLOUDSMITH_OIDC_TOKEN.
|
|
24
|
+
|
|
25
|
+
Works for Jenkins (with the credentials binding plugin), or any custom
|
|
26
|
+
CI/CD system that can inject an OIDC token via an environment variable.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
name = "Generic"
|
|
30
|
+
id = "generic"
|
|
31
|
+
|
|
32
|
+
def detect(self) -> bool:
|
|
33
|
+
return bool((os.environ.get(TOKEN_ENV_VAR) or "").strip())
|
|
34
|
+
|
|
35
|
+
def get_token(self) -> str:
|
|
36
|
+
token = (os.environ.get(TOKEN_ENV_VAR) or "").strip()
|
|
37
|
+
if not token:
|
|
38
|
+
raise ValueError(
|
|
39
|
+
f"Generic OIDC detector selected but {TOKEN_ENV_VAR} is not "
|
|
40
|
+
"set. Set it to the OIDC JWT to exchange for a Cloudsmith token."
|
|
41
|
+
)
|
|
42
|
+
return token
|