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.
Files changed (130) hide show
  1. cloudsmith_cli/__init__.py +10 -0
  2. cloudsmith_cli/__main__.py +8 -0
  3. cloudsmith_cli/cli/__init__.py +1 -0
  4. cloudsmith_cli/cli/command.py +160 -0
  5. cloudsmith_cli/cli/commands/__init__.py +33 -0
  6. cloudsmith_cli/cli/commands/auth.py +173 -0
  7. cloudsmith_cli/cli/commands/check.py +129 -0
  8. cloudsmith_cli/cli/commands/copy.py +98 -0
  9. cloudsmith_cli/cli/commands/credential_helper/__init__.py +39 -0
  10. cloudsmith_cli/cli/commands/credential_helper/docker.py +66 -0
  11. cloudsmith_cli/cli/commands/credential_helper/manage.py +299 -0
  12. cloudsmith_cli/cli/commands/delete.py +67 -0
  13. cloudsmith_cli/cli/commands/dependencies.py +108 -0
  14. cloudsmith_cli/cli/commands/docs.py +16 -0
  15. cloudsmith_cli/cli/commands/download.py +620 -0
  16. cloudsmith_cli/cli/commands/entitlements.py +819 -0
  17. cloudsmith_cli/cli/commands/help_.py +12 -0
  18. cloudsmith_cli/cli/commands/list_.py +317 -0
  19. cloudsmith_cli/cli/commands/login.py +99 -0
  20. cloudsmith_cli/cli/commands/logout.py +151 -0
  21. cloudsmith_cli/cli/commands/main.py +73 -0
  22. cloudsmith_cli/cli/commands/mcp.py +523 -0
  23. cloudsmith_cli/cli/commands/metadata.py +503 -0
  24. cloudsmith_cli/cli/commands/metrics/__init__.py +2 -0
  25. cloudsmith_cli/cli/commands/metrics/command.py +20 -0
  26. cloudsmith_cli/cli/commands/metrics/entitlements.py +148 -0
  27. cloudsmith_cli/cli/commands/metrics/packages.py +134 -0
  28. cloudsmith_cli/cli/commands/move.py +111 -0
  29. cloudsmith_cli/cli/commands/policy/__init__.py +3 -0
  30. cloudsmith_cli/cli/commands/policy/command.py +20 -0
  31. cloudsmith_cli/cli/commands/policy/deny.py +248 -0
  32. cloudsmith_cli/cli/commands/policy/license.py +335 -0
  33. cloudsmith_cli/cli/commands/policy/vulnerability.py +322 -0
  34. cloudsmith_cli/cli/commands/push.py +1323 -0
  35. cloudsmith_cli/cli/commands/quarantine.py +148 -0
  36. cloudsmith_cli/cli/commands/quota/__init__.py +2 -0
  37. cloudsmith_cli/cli/commands/quota/command.py +20 -0
  38. cloudsmith_cli/cli/commands/quota/history.py +122 -0
  39. cloudsmith_cli/cli/commands/quota/quota.py +107 -0
  40. cloudsmith_cli/cli/commands/repos.py +330 -0
  41. cloudsmith_cli/cli/commands/resync.py +90 -0
  42. cloudsmith_cli/cli/commands/status.py +92 -0
  43. cloudsmith_cli/cli/commands/tags.py +375 -0
  44. cloudsmith_cli/cli/commands/tokens.py +318 -0
  45. cloudsmith_cli/cli/commands/upstream.py +479 -0
  46. cloudsmith_cli/cli/commands/vulnerabilities.py +141 -0
  47. cloudsmith_cli/cli/commands/whoami.py +188 -0
  48. cloudsmith_cli/cli/config.py +635 -0
  49. cloudsmith_cli/cli/decorators.py +624 -0
  50. cloudsmith_cli/cli/exceptions.py +215 -0
  51. cloudsmith_cli/cli/metadata_common.py +146 -0
  52. cloudsmith_cli/cli/saml.py +109 -0
  53. cloudsmith_cli/cli/table.py +59 -0
  54. cloudsmith_cli/cli/types.py +14 -0
  55. cloudsmith_cli/cli/utils.py +267 -0
  56. cloudsmith_cli/cli/validators.py +378 -0
  57. cloudsmith_cli/cli/webserver.py +263 -0
  58. cloudsmith_cli/core/__init__.py +1 -0
  59. cloudsmith_cli/core/api/__init__.py +1 -0
  60. cloudsmith_cli/core/api/distros.py +31 -0
  61. cloudsmith_cli/core/api/entitlements.py +130 -0
  62. cloudsmith_cli/core/api/exceptions.py +57 -0
  63. cloudsmith_cli/core/api/files.py +131 -0
  64. cloudsmith_cli/core/api/init.py +109 -0
  65. cloudsmith_cli/core/api/metadata.py +217 -0
  66. cloudsmith_cli/core/api/metrics.py +78 -0
  67. cloudsmith_cli/core/api/orgs.py +201 -0
  68. cloudsmith_cli/core/api/packages.py +309 -0
  69. cloudsmith_cli/core/api/quota.py +64 -0
  70. cloudsmith_cli/core/api/rates.py +28 -0
  71. cloudsmith_cli/core/api/repos.py +81 -0
  72. cloudsmith_cli/core/api/status.py +27 -0
  73. cloudsmith_cli/core/api/upstreams.py +72 -0
  74. cloudsmith_cli/core/api/user.py +109 -0
  75. cloudsmith_cli/core/api/version.py +15 -0
  76. cloudsmith_cli/core/api/vulnerabilities.py +230 -0
  77. cloudsmith_cli/core/cache_utils.py +160 -0
  78. cloudsmith_cli/core/config.py +140 -0
  79. cloudsmith_cli/core/credentials/__init__.py +0 -0
  80. cloudsmith_cli/core/credentials/chain.py +69 -0
  81. cloudsmith_cli/core/credentials/models.py +44 -0
  82. cloudsmith_cli/core/credentials/oidc/__init__.py +6 -0
  83. cloudsmith_cli/core/credentials/oidc/cache.py +220 -0
  84. cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +122 -0
  85. cloudsmith_cli/core/credentials/oidc/detectors/aws.py +85 -0
  86. cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py +70 -0
  87. cloudsmith_cli/core/credentials/oidc/detectors/base.py +26 -0
  88. cloudsmith_cli/core/credentials/oidc/detectors/bitbucket_pipelines.py +35 -0
  89. cloudsmith_cli/core/credentials/oidc/detectors/circleci.py +40 -0
  90. cloudsmith_cli/core/credentials/oidc/detectors/generic.py +42 -0
  91. cloudsmith_cli/core/credentials/oidc/detectors/github_actions.py +64 -0
  92. cloudsmith_cli/core/credentials/oidc/detectors/gitlab_ci.py +49 -0
  93. cloudsmith_cli/core/credentials/oidc/exchange.py +87 -0
  94. cloudsmith_cli/core/credentials/provider.py +17 -0
  95. cloudsmith_cli/core/credentials/providers/__init__.py +15 -0
  96. cloudsmith_cli/core/credentials/providers/cli_flag.py +24 -0
  97. cloudsmith_cli/core/credentials/providers/credentials_file.py +24 -0
  98. cloudsmith_cli/core/credentials/providers/env_var.py +24 -0
  99. cloudsmith_cli/core/credentials/providers/keyring_provider.py +59 -0
  100. cloudsmith_cli/core/credentials/providers/oidc_provider.py +115 -0
  101. cloudsmith_cli/core/download.py +594 -0
  102. cloudsmith_cli/core/keyring.py +171 -0
  103. cloudsmith_cli/core/mcp/__init__.py +0 -0
  104. cloudsmith_cli/core/mcp/data.py +17 -0
  105. cloudsmith_cli/core/mcp/server.py +786 -0
  106. cloudsmith_cli/core/pagination.py +131 -0
  107. cloudsmith_cli/core/ratelimits.py +88 -0
  108. cloudsmith_cli/core/rest.py +255 -0
  109. cloudsmith_cli/core/utils.py +95 -0
  110. cloudsmith_cli/core/version.py +20 -0
  111. cloudsmith_cli/credential_helpers/__init__.py +7 -0
  112. cloudsmith_cli/credential_helpers/backends.py +41 -0
  113. cloudsmith_cli/credential_helpers/common.py +111 -0
  114. cloudsmith_cli/credential_helpers/custom_domains.py +281 -0
  115. cloudsmith_cli/credential_helpers/docker/__init__.py +4 -0
  116. cloudsmith_cli/credential_helpers/docker/installer.py +349 -0
  117. cloudsmith_cli/credential_helpers/docker/runtime.py +117 -0
  118. cloudsmith_cli/credential_helpers/launchers.py +175 -0
  119. cloudsmith_cli/data/VERSION +1 -0
  120. cloudsmith_cli/data/config.ini +23 -0
  121. cloudsmith_cli/data/credentials.ini +14 -0
  122. cloudsmith_cli/templates/__init__.py +3 -0
  123. cloudsmith_cli/templates/auth_error.html +45 -0
  124. cloudsmith_cli/templates/auth_success.html +37 -0
  125. cloudsmith_cli-1.20.0.dist-info/METADATA +610 -0
  126. cloudsmith_cli-1.20.0.dist-info/RECORD +130 -0
  127. cloudsmith_cli-1.20.0.dist-info/WHEEL +5 -0
  128. cloudsmith_cli-1.20.0.dist-info/entry_points.txt +2 -0
  129. cloudsmith_cli-1.20.0.dist-info/licenses/LICENSE +201 -0
  130. 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