diff --git a/CHANGELOG.md b/CHANGELOG.md index aee2e807..b867321d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Added +- 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. - 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. ## [1.18.0] - 2026-06-09 diff --git a/README.md b/README.md index 466b029b..18ea9428 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,10 @@ pip install cloudsmith-cli[all] **Note:** If you don't install the AWS extra, the AWS OIDC detector will gracefully skip itself with no errors. +#### Azure DevOps OIDC Support + +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). + #### GitHub Actions OIDC Support 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). diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py index ae7ee7e4..60695bcc 100644 --- a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +++ b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py @@ -6,6 +6,7 @@ from typing import TYPE_CHECKING from .aws import AWSDetector +from .azure_devops import AzureDevOpsDetector from .base import EnvironmentDetector from .github_actions import GitHubActionsDetector @@ -15,6 +16,7 @@ logger = logging.getLogger(__name__) _DETECTORS: list[type[EnvironmentDetector]] = [ + AzureDevOpsDetector, GitHubActionsDetector, AWSDetector, ] diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py b/cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py new file mode 100644 index 00000000..bc080d89 --- /dev/null +++ b/cloudsmith_cli/core/credentials/oidc/detectors/azure_devops.py @@ -0,0 +1,69 @@ +# Copyright 2026 Cloudsmith Ltd +"""Azure DevOps OIDC detector. + +Fetches an OIDC token via the ``SYSTEM_OIDCREQUESTURI`` HTTP endpoint using +the pipeline's ``SYSTEM_ACCESSTOKEN`` for authorization. + +The audience is not caller-configurable: Azure DevOps always mints the token +with a fixed audience (``api://AzureADTokenExchange``) and ignores any audience +supplied in the request, so the request is an empty POST (matching the Azure +SDK's AzurePipelinesCredential). + +References: + 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 + https://github.com/Azure/azure-sdk-for-go/blob/main/sdk/azidentity/azure_pipelines_credential.go + https://docs.cloudsmith.com/integrations/integrating-with-azure-devops + https://cloudsmith.com/changelog/native-oidc-authentication-for-azure-devops +""" + +from __future__ import annotations + +import os + +from ....rest import create_requests_session as create_session +from .base import EnvironmentDetector + +API_VERSION = "7.1" + + +class AzureDevOpsDetector(EnvironmentDetector): + """Detects Azure DevOps and fetches an OIDC token via HTTP POST.""" + + name = "Azure DevOps" + + def detect(self) -> bool: + return bool(os.environ.get("SYSTEM_OIDCREQUESTURI")) and bool( + os.environ.get("SYSTEM_ACCESSTOKEN") + ) + + def get_token(self) -> str: + request_uri = os.environ["SYSTEM_OIDCREQUESTURI"] + access_token = os.environ["SYSTEM_ACCESSTOKEN"] + + # The Azure DevOps OIDC endpoint rejects requests without an explicit + # api-version (HTTP 400), so it must always be supplied. + separator = "&" if "?" in request_uri else "?" + url = f"{request_uri}{separator}api-version={API_VERSION}" + + session = self.context.session or create_session() + try: + response = session.post( + url, + headers={ + "Authorization": f"Bearer {access_token}", + "X-TFS-FedAuthRedirect": "Suppress", + }, + timeout=30, + ) + response.raise_for_status() + + data = response.json() + token = data.get("oidcToken") + if not token: + raise ValueError( + "Azure DevOps OIDC response did not contain an oidcToken" + ) + return token + finally: + if not self.context.session: + session.close() diff --git a/cloudsmith_cli/core/tests/test_azure_devops_detector.py b/cloudsmith_cli/core/tests/test_azure_devops_detector.py new file mode 100644 index 00000000..47236b94 --- /dev/null +++ b/cloudsmith_cli/core/tests/test_azure_devops_detector.py @@ -0,0 +1,125 @@ +"""Tests for the Azure DevOps OIDC detector.""" + +from unittest import mock + +import pytest + +from cloudsmith_cli.core.credentials.models import CredentialContext +from cloudsmith_cli.core.credentials.oidc.detectors import detect_environment +from cloudsmith_cli.core.credentials.oidc.detectors.azure_devops import ( + AzureDevOpsDetector, +) + + +@pytest.fixture +def azure_env(): + env = { + "SYSTEM_OIDCREQUESTURI": "https://dev.azure.example/oidc/req", + "SYSTEM_ACCESSTOKEN": "access-token", + } + with mock.patch.dict("os.environ", env, clear=True): + yield env + + +class TestDetect: + def test_detects_when_all_env_vars_present(self, azure_env): + detector = AzureDevOpsDetector(context=CredentialContext()) + assert detector.detect() is True + + def test_not_detected_when_unset(self): + with mock.patch.dict("os.environ", {}, clear=True): + detector = AzureDevOpsDetector(context=CredentialContext()) + assert detector.detect() is False + + def test_not_detected_without_request_uri(self, azure_env): + del azure_env["SYSTEM_OIDCREQUESTURI"] + with mock.patch.dict("os.environ", azure_env, clear=True): + detector = AzureDevOpsDetector(context=CredentialContext()) + assert detector.detect() is False + + def test_not_detected_without_access_token(self, azure_env): + del azure_env["SYSTEM_ACCESSTOKEN"] + with mock.patch.dict("os.environ", azure_env, clear=True): + detector = AzureDevOpsDetector(context=CredentialContext()) + assert detector.detect() is False + + +class TestGetToken: + def _mock_session(self, json_data): + response = mock.Mock() + response.json.return_value = json_data + response.raise_for_status = mock.Mock() + session = mock.Mock() + session.post.return_value = response + return session, response + + def test_returns_token_from_response(self, azure_env): + session, _ = self._mock_session({"oidcToken": "the-jwt"}) + context = CredentialContext(session=session) + detector = AzureDevOpsDetector(context=context) + + token = detector.get_token() + + assert token == "the-jwt" + + def test_posts_empty_body_with_api_version_and_auth_header(self, azure_env): + session, response = self._mock_session({"oidcToken": "the-jwt"}) + context = CredentialContext(session=session) + detector = AzureDevOpsDetector(context=context) + + detector.get_token() + + called_url = session.post.call_args[0][0] + assert called_url == "https://dev.azure.example/oidc/req?api-version=7.1" + kwargs = session.post.call_args[1] + # Azure DevOps ignores any requested audience and mints a token with a + # fixed audience, so no body is sent (matching the Azure SDK). + assert kwargs.get("json") is None + assert kwargs.get("data") is None + assert kwargs["headers"]["Authorization"] == "Bearer access-token" + assert kwargs["headers"]["X-TFS-FedAuthRedirect"] == "Suppress" + response.raise_for_status.assert_called_once() + + def test_appends_api_version_with_ampersand_when_query_present(self, azure_env): + azure_env["SYSTEM_OIDCREQUESTURI"] = ( + "https://dev.azure.example/oidc/req?foo=bar" + ) + with mock.patch.dict("os.environ", azure_env, clear=True): + session, _ = self._mock_session({"oidcToken": "the-jwt"}) + context = CredentialContext(session=session) + detector = AzureDevOpsDetector(context=context) + + detector.get_token() + + called_url = session.post.call_args[0][0] + assert ( + called_url + == "https://dev.azure.example/oidc/req?foo=bar&api-version=7.1" + ) + + def test_custom_audience_is_ignored(self, azure_env): + # Azure DevOps does not support a caller-supplied audience, so even a + # custom oidc_audience must not add a request body. + session, _ = self._mock_session({"oidcToken": "the-jwt"}) + context = CredentialContext(session=session, oidc_audience="my-aud") + detector = AzureDevOpsDetector(context=context) + + detector.get_token() + + kwargs = session.post.call_args[1] + assert kwargs.get("json") is None + assert kwargs.get("data") is None + + def test_raises_when_token_missing(self, azure_env): + session, _ = self._mock_session({}) + context = CredentialContext(session=session) + detector = AzureDevOpsDetector(context=context) + + with pytest.raises(ValueError): + detector.get_token() + + +class TestIntegration: + def test_detect_environment_selects_azure_devops(self, azure_env): + detector = detect_environment(CredentialContext()) + assert isinstance(detector, AzureDevOpsDetector)