diff --git a/CHANGELOG.md b/CHANGELOG.md index edcc2cab..da2d90b4 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 Bitbucket Pipelines to OIDC credential auto-discovery. When a pipeline step sets `oidc: true`, the CLI reads the OIDC token from the `BITBUCKET_STEP_OIDC_TOKEN` environment variable and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies. - Added CircleCI to OIDC credential auto-discovery. When running in CircleCI, the CLI reads the OIDC token from the `CIRCLE_OIDC_TOKEN_V2` (preferred) or `CIRCLE_OIDC_TOKEN` environment variable and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies. - 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. diff --git a/README.md b/README.md index 1b144696..80c56cb4 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,21 @@ 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. +#### Bitbucket Pipelines OIDC Support + +In Bitbucket Pipelines, OIDC credential discovery works out of the box with no extra dependencies. Set `oidc: true` on the pipeline step and the CLI reads the token from the `BITBUCKET_STEP_OIDC_TOKEN` variable that Bitbucket populates. The Cloudsmith OIDC provider must expect the workspace audience that Bitbucket mints (`ari:cloud:bitbucket::workspace/`): + +```yaml +pipelines: + default: + - step: + oidc: true + script: + - cloudsmith push ... +``` + +See the [Bitbucket Pipelines OIDC documentation](https://support.atlassian.com/bitbucket-cloud/docs/integrate-pipelines-with-resource-servers-using-oidc/). + #### CircleCI OIDC Support In CircleCI, OIDC credential discovery works out of the box with no extra dependencies — the CLI reads the token from the `CIRCLE_OIDC_TOKEN_V2` (preferred) or `CIRCLE_OIDC_TOKEN` environment variable that CircleCI injects into every job. The Cloudsmith OIDC provider must expect the audience CircleCI mints, which is your CircleCI organization UUID. See the [Cloudsmith CircleCI integration guide](https://docs.cloudsmith.com/integrations/integrating-with-circleci). diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py index d0794648..bdc106cc 100644 --- a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +++ b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py @@ -8,6 +8,7 @@ from .aws import AWSDetector from .azure_devops import AzureDevOpsDetector from .base import EnvironmentDetector +from .bitbucket_pipelines import BitbucketPipelinesDetector from .circleci import CircleCIDetector from .generic import GenericDetector from .github_actions import GitHubActionsDetector @@ -21,6 +22,7 @@ CircleCIDetector, AzureDevOpsDetector, GitHubActionsDetector, + BitbucketPipelinesDetector, AWSDetector, GenericDetector, ] diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/bitbucket_pipelines.py b/cloudsmith_cli/core/credentials/oidc/detectors/bitbucket_pipelines.py new file mode 100644 index 00000000..fec58f27 --- /dev/null +++ b/cloudsmith_cli/core/credentials/oidc/detectors/bitbucket_pipelines.py @@ -0,0 +1,34 @@ +# Copyright 2026 Cloudsmith Ltd +"""Bitbucket Pipelines OIDC detector. + +Reads an OIDC token from the ``BITBUCKET_STEP_OIDC_TOKEN`` environment variable, +which Bitbucket populates when ``oidc: true`` is set on a pipeline step. + +References: + https://support.atlassian.com/bitbucket-cloud/docs/integrate-pipelines-with-resource-servers-using-oidc/ + https://support.atlassian.com/bitbucket-cloud/docs/variables-and-secrets/ +""" + +from __future__ import annotations + +import os + +from .base import EnvironmentDetector + + +class BitbucketPipelinesDetector(EnvironmentDetector): + """Detects Bitbucket Pipelines and reads its OIDC token from environment.""" + + name = "Bitbucket Pipelines" + + def detect(self) -> bool: + return bool(os.environ.get("BITBUCKET_STEP_OIDC_TOKEN")) + + def get_token(self) -> str: + token = os.environ.get("BITBUCKET_STEP_OIDC_TOKEN") + if not token: + raise ValueError( + "BITBUCKET_STEP_OIDC_TOKEN is not set. Enable OIDC on the " + "pipeline step with 'oidc: true'." + ) + return token diff --git a/cloudsmith_cli/core/tests/test_bitbucket_pipelines_detector.py b/cloudsmith_cli/core/tests/test_bitbucket_pipelines_detector.py new file mode 100644 index 00000000..2920b3ab --- /dev/null +++ b/cloudsmith_cli/core/tests/test_bitbucket_pipelines_detector.py @@ -0,0 +1,55 @@ +"""Tests for the Bitbucket Pipelines 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.bitbucket_pipelines import ( + BitbucketPipelinesDetector, +) + + +@pytest.fixture +def bitbucket_env(): + env = { + "BITBUCKET_STEP_OIDC_TOKEN": "the-jwt", + } + with mock.patch.dict("os.environ", env, clear=True): + yield env + + +class TestDetect: + def test_detects_when_token_present(self, bitbucket_env): + detector = BitbucketPipelinesDetector(context=CredentialContext()) + assert detector.detect() is True + + def test_not_detected_when_unset(self): + with mock.patch.dict("os.environ", {}, clear=True): + detector = BitbucketPipelinesDetector(context=CredentialContext()) + assert detector.detect() is False + + def test_not_detected_when_token_empty(self, bitbucket_env): + bitbucket_env["BITBUCKET_STEP_OIDC_TOKEN"] = "" + with mock.patch.dict("os.environ", bitbucket_env, clear=True): + detector = BitbucketPipelinesDetector(context=CredentialContext()) + assert detector.detect() is False + + +class TestGetToken: + def test_returns_token(self, bitbucket_env): + detector = BitbucketPipelinesDetector(context=CredentialContext()) + assert detector.get_token() == "the-jwt" + + def test_raises_when_token_missing(self): + with mock.patch.dict("os.environ", {}, clear=True): + detector = BitbucketPipelinesDetector(context=CredentialContext()) + with pytest.raises(ValueError): + detector.get_token() + + +class TestIntegration: + def test_detect_environment_selects_bitbucket_pipelines(self, bitbucket_env): + detector = detect_environment(CredentialContext()) + assert isinstance(detector, BitbucketPipelinesDetector)