Skip to content

Commit 19b865b

Browse files
cloudsmith-iduffyclaudeBartoszBlizniak
authored
feat: add Azure DevOps OIDC detector (#301)
Add Azure DevOps to OIDC credential auto-discovery. When running in an Azure DevOps pipeline, the CLI fetches an OIDC token from the SYSTEM_OIDCREQUESTURI endpoint using the pipeline's SYSTEM_ACCESSTOKEN and exchanges it for a Cloudsmith access token. The api-version query parameter is always supplied (the endpoint returns HTTP 400 without it), and no request body is sent because Azure DevOps mints a fixed audience (api://AzureADTokenExchange) and ignores any requested audience. Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Co-authored-by: BB <55028730+BartoszBlizniak@users.noreply.github.com>
1 parent 5eb32e4 commit 19b865b

5 files changed

Lines changed: 201 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
1010

1111
### Added
1212

13+
- Added Azure DevOps to OIDC credential auto-discovery. When running in an Azure DevOps pipeline, the CLI fetches an OIDC token from the `SYSTEM_OIDCREQUESTURI` endpoint using the pipeline's `SYSTEM_ACCESSTOKEN` and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies.
1314
- Added GitHub Actions to OIDC credential auto-discovery. When running in GitHub Actions (with `id-token: write` permission), the CLI fetches an OIDC token from the Actions runtime endpoint and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies.
1415

1516
## [1.18.0] - 2026-06-09

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,10 @@ pip install cloudsmith-cli[all]
165165

166166
**Note:** If you don't install the AWS extra, the AWS OIDC detector will gracefully skip itself with no errors.
167167

168+
#### Azure DevOps OIDC Support
169+
170+
In Azure DevOps Pipelines, OIDC credential discovery works out of the box with no extra dependencies — the CLI fetches an OIDC token from the `SYSTEM_OIDCREQUESTURI` endpoint using the pipeline's `SYSTEM_ACCESSTOKEN`. Make sure `SYSTEM_ACCESSTOKEN` is mapped into the step's environment. The Cloudsmith OIDC provider must expect the audience `api://AzureADTokenExchange`, which Azure DevOps always mints (any requested audience is ignored). See the [Cloudsmith Azure DevOps integration guide](https://docs.cloudsmith.com/integrations/integrating-with-azure-devops).
171+
168172
#### GitHub Actions OIDC Support
169173

170174
In GitHub Actions, OIDC credential discovery works out of the box with no extra dependencies — the CLI fetches an OIDC token from the Actions runtime when the workflow requests `id-token: write` permission. See the [Cloudsmith GitHub Actions OIDC guide](https://docs.cloudsmith.com/authentication/setup-cloudsmith-to-authenticate-with-oidc-in-github-actions).

cloudsmith_cli/core/credentials/oidc/detectors/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from typing import TYPE_CHECKING
77

88
from .aws import AWSDetector
9+
from .azure_devops import AzureDevOpsDetector
910
from .base import EnvironmentDetector
1011
from .github_actions import GitHubActionsDetector
1112

@@ -15,6 +16,7 @@
1516
logger = logging.getLogger(__name__)
1617

1718
_DETECTORS: list[type[EnvironmentDetector]] = [
19+
AzureDevOpsDetector,
1820
GitHubActionsDetector,
1921
AWSDetector,
2022
]
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
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+
34+
def detect(self) -> bool:
35+
return bool(os.environ.get("SYSTEM_OIDCREQUESTURI")) and bool(
36+
os.environ.get("SYSTEM_ACCESSTOKEN")
37+
)
38+
39+
def get_token(self) -> str:
40+
request_uri = os.environ["SYSTEM_OIDCREQUESTURI"]
41+
access_token = os.environ["SYSTEM_ACCESSTOKEN"]
42+
43+
# The Azure DevOps OIDC endpoint rejects requests without an explicit
44+
# api-version (HTTP 400), so it must always be supplied.
45+
separator = "&" if "?" in request_uri else "?"
46+
url = f"{request_uri}{separator}api-version={API_VERSION}"
47+
48+
session = self.context.session or create_session()
49+
try:
50+
response = session.post(
51+
url,
52+
headers={
53+
"Authorization": f"Bearer {access_token}",
54+
"X-TFS-FedAuthRedirect": "Suppress",
55+
},
56+
timeout=30,
57+
)
58+
response.raise_for_status()
59+
60+
data = response.json()
61+
token = data.get("oidcToken")
62+
if not token:
63+
raise ValueError(
64+
"Azure DevOps OIDC response did not contain an oidcToken"
65+
)
66+
return token
67+
finally:
68+
if not self.context.session:
69+
session.close()
Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
"""Tests for the Azure DevOps OIDC detector."""
2+
3+
from unittest import mock
4+
5+
import pytest
6+
7+
from cloudsmith_cli.core.credentials.models import CredentialContext
8+
from cloudsmith_cli.core.credentials.oidc.detectors import detect_environment
9+
from cloudsmith_cli.core.credentials.oidc.detectors.azure_devops import (
10+
AzureDevOpsDetector,
11+
)
12+
13+
14+
@pytest.fixture
15+
def azure_env():
16+
env = {
17+
"SYSTEM_OIDCREQUESTURI": "https://dev.azure.example/oidc/req",
18+
"SYSTEM_ACCESSTOKEN": "access-token",
19+
}
20+
with mock.patch.dict("os.environ", env, clear=True):
21+
yield env
22+
23+
24+
class TestDetect:
25+
def test_detects_when_all_env_vars_present(self, azure_env):
26+
detector = AzureDevOpsDetector(context=CredentialContext())
27+
assert detector.detect() is True
28+
29+
def test_not_detected_when_unset(self):
30+
with mock.patch.dict("os.environ", {}, clear=True):
31+
detector = AzureDevOpsDetector(context=CredentialContext())
32+
assert detector.detect() is False
33+
34+
def test_not_detected_without_request_uri(self, azure_env):
35+
del azure_env["SYSTEM_OIDCREQUESTURI"]
36+
with mock.patch.dict("os.environ", azure_env, clear=True):
37+
detector = AzureDevOpsDetector(context=CredentialContext())
38+
assert detector.detect() is False
39+
40+
def test_not_detected_without_access_token(self, azure_env):
41+
del azure_env["SYSTEM_ACCESSTOKEN"]
42+
with mock.patch.dict("os.environ", azure_env, clear=True):
43+
detector = AzureDevOpsDetector(context=CredentialContext())
44+
assert detector.detect() is False
45+
46+
47+
class TestGetToken:
48+
def _mock_session(self, json_data):
49+
response = mock.Mock()
50+
response.json.return_value = json_data
51+
response.raise_for_status = mock.Mock()
52+
session = mock.Mock()
53+
session.post.return_value = response
54+
return session, response
55+
56+
def test_returns_token_from_response(self, azure_env):
57+
session, _ = self._mock_session({"oidcToken": "the-jwt"})
58+
context = CredentialContext(session=session)
59+
detector = AzureDevOpsDetector(context=context)
60+
61+
token = detector.get_token()
62+
63+
assert token == "the-jwt"
64+
65+
def test_posts_empty_body_with_api_version_and_auth_header(self, azure_env):
66+
session, response = self._mock_session({"oidcToken": "the-jwt"})
67+
context = CredentialContext(session=session)
68+
detector = AzureDevOpsDetector(context=context)
69+
70+
detector.get_token()
71+
72+
called_url = session.post.call_args[0][0]
73+
assert called_url == "https://dev.azure.example/oidc/req?api-version=7.1"
74+
kwargs = session.post.call_args[1]
75+
# Azure DevOps ignores any requested audience and mints a token with a
76+
# fixed audience, so no body is sent (matching the Azure SDK).
77+
assert kwargs.get("json") is None
78+
assert kwargs.get("data") is None
79+
assert kwargs["headers"]["Authorization"] == "Bearer access-token"
80+
assert kwargs["headers"]["X-TFS-FedAuthRedirect"] == "Suppress"
81+
response.raise_for_status.assert_called_once()
82+
83+
def test_appends_api_version_with_ampersand_when_query_present(self, azure_env):
84+
azure_env["SYSTEM_OIDCREQUESTURI"] = (
85+
"https://dev.azure.example/oidc/req?foo=bar"
86+
)
87+
with mock.patch.dict("os.environ", azure_env, clear=True):
88+
session, _ = self._mock_session({"oidcToken": "the-jwt"})
89+
context = CredentialContext(session=session)
90+
detector = AzureDevOpsDetector(context=context)
91+
92+
detector.get_token()
93+
94+
called_url = session.post.call_args[0][0]
95+
assert (
96+
called_url
97+
== "https://dev.azure.example/oidc/req?foo=bar&api-version=7.1"
98+
)
99+
100+
def test_custom_audience_is_ignored(self, azure_env):
101+
# Azure DevOps does not support a caller-supplied audience, so even a
102+
# custom oidc_audience must not add a request body.
103+
session, _ = self._mock_session({"oidcToken": "the-jwt"})
104+
context = CredentialContext(session=session, oidc_audience="my-aud")
105+
detector = AzureDevOpsDetector(context=context)
106+
107+
detector.get_token()
108+
109+
kwargs = session.post.call_args[1]
110+
assert kwargs.get("json") is None
111+
assert kwargs.get("data") is None
112+
113+
def test_raises_when_token_missing(self, azure_env):
114+
session, _ = self._mock_session({})
115+
context = CredentialContext(session=session)
116+
detector = AzureDevOpsDetector(context=context)
117+
118+
with pytest.raises(ValueError):
119+
detector.get_token()
120+
121+
122+
class TestIntegration:
123+
def test_detect_environment_selects_azure_devops(self, azure_env):
124+
detector = detect_environment(CredentialContext())
125+
assert isinstance(detector, AzureDevOpsDetector)

0 commit comments

Comments
 (0)