From 83faa10dbb085eacf2caf6abc741df80b5cbf4ad Mon Sep 17 00:00:00 2001 From: Ian Duffy Date: Mon, 8 Jun 2026 23:40:28 +0100 Subject: [PATCH] feat: add GitLab CI OIDC detector Add GitLab CI to OIDC credential auto-discovery. When running in GitLab CI/CD, the CLI reads the OIDC token from CLOUDSMITH_OIDC_TOKEN (configured via id_tokens in .gitlab-ci.yml, with legacy fallbacks to CI_JOB_JWT_V2 and CI_JOB_JWT) and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 1 + README.md | 15 ++++ .../credentials/oidc/detectors/__init__.py | 2 + .../credentials/oidc/detectors/gitlab_ci.py | 48 ++++++++++++ .../core/tests/test_gitlab_ci_detector.py | 74 +++++++++++++++++++ 5 files changed, 140 insertions(+) create mode 100644 cloudsmith_cli/core/credentials/oidc/detectors/gitlab_ci.py create mode 100644 cloudsmith_cli/core/tests/test_gitlab_ci_detector.py diff --git a/CHANGELOG.md b/CHANGELOG.md index da2d90b4..22e5dc42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. - 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. - Added a generic fallback to OIDC credential auto-discovery. When no dedicated environment is detected, the CLI reads an OIDC token from the `CLOUDSMITH_OIDC_TOKEN` environment variable (useful for Jenkins or any custom CI/CD) and exchanges it for a Cloudsmith access token. Works out of the box with no extra dependencies. +- Added GitLab CI to OIDC credential auto-discovery. When running in GitLab CI/CD, the CLI reads the OIDC token from the `CLOUDSMITH_OIDC_TOKEN` environment variable (configured via `id_tokens` in `.gitlab-ci.yml`) 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 80c56cb4..34fd3ff1 100644 --- a/README.md +++ b/README.md @@ -192,6 +192,21 @@ In Azure DevOps Pipelines, OIDC credential discovery works out of the box with n 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). +#### GitLab CI OIDC Support + +In GitLab CI/CD, OIDC credential discovery works out of the box with no extra dependencies. Configure an [`id_tokens`](https://docs.gitlab.com/ci/cloud_services/) entry in your `.gitlab-ci.yml` with an `aud` of `https://api.cloudsmith.io/openid/` and expose it as `CLOUDSMITH_OIDC_TOKEN`, and the CLI will pick it up automatically: + +```yaml +job: + id_tokens: + CLOUDSMITH_OIDC_TOKEN: + aud: https://api.cloudsmith.io/openid/ + script: + - cloudsmith push ... +``` + +See the [Cloudsmith GitLab CI/CD integration guide](https://docs.cloudsmith.com/integrations/integrating-with-gitlab-cicd). + #### Generic OIDC Support (Jenkins, custom CI/CD) As a fallback for environments without a dedicated detector (for example Jenkins with the [credentials binding plugin](https://plugins.jenkins.io/credentials-binding/), or any custom CI/CD system), set the `CLOUDSMITH_OIDC_TOKEN` environment variable to an OIDC JWT and the CLI will exchange it for a Cloudsmith access token. This detector runs last, so a dedicated environment is always preferred when present. See the [Cloudsmith Jenkins OIDC guide](https://docs.cloudsmith.com/authentication/setup-jenkins-to-authenticate-to-cloudsmith-using-oidc). diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py index bdc106cc..0de3c445 100644 --- a/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py +++ b/cloudsmith_cli/core/credentials/oidc/detectors/__init__.py @@ -12,6 +12,7 @@ from .circleci import CircleCIDetector from .generic import GenericDetector from .github_actions import GitHubActionsDetector +from .gitlab_ci import GitLabCIDetector if TYPE_CHECKING: from ... import CredentialContext @@ -23,6 +24,7 @@ AzureDevOpsDetector, GitHubActionsDetector, BitbucketPipelinesDetector, + GitLabCIDetector, AWSDetector, GenericDetector, ] diff --git a/cloudsmith_cli/core/credentials/oidc/detectors/gitlab_ci.py b/cloudsmith_cli/core/credentials/oidc/detectors/gitlab_ci.py new file mode 100644 index 00000000..b5090f5e --- /dev/null +++ b/cloudsmith_cli/core/credentials/oidc/detectors/gitlab_ci.py @@ -0,0 +1,48 @@ +# Copyright 2026 Cloudsmith Ltd +"""GitLab CI OIDC detector. + +Reads an OIDC token from environment variables populated by GitLab's +``id_tokens`` configuration in ``.gitlab-ci.yml``. + +References: + https://docs.gitlab.com/ci/cloud_services/ + https://docs.cloudsmith.com/integrations/integrating-with-gitlab-cicd +""" + +from __future__ import annotations + +import os + +from .base import EnvironmentDetector + + +class GitLabCIDetector(EnvironmentDetector): + """Detects GitLab CI and reads an OIDC token from an environment variable. + + GitLab requires users to configure ``id_tokens`` in ``.gitlab-ci.yml``, + minting a token with ``aud`` set to the Cloudsmith OIDC endpoint and + exposing it as ``CLOUDSMITH_OIDC_TOKEN``. The legacy ``CI_JOB_JWT``/ + ``CI_JOB_JWT_V2`` variables are deliberately not consulted: they were + removed in GitLab 17.0, carry the GitLab instance URL as their audience + (not the Cloudsmith audience the token exchange validates), and were + auto-injected into every job on older instances. + """ + + name = "GitLab CI" + + TOKEN_ENV_VAR = "CLOUDSMITH_OIDC_TOKEN" + + def detect(self) -> bool: + if os.environ.get("GITLAB_CI") != "true": + return False + return bool(os.environ.get(self.TOKEN_ENV_VAR)) + + def get_token(self) -> str: + token = os.environ.get(self.TOKEN_ENV_VAR) + if token: + return token + raise ValueError( + "GitLab CI detected but no OIDC token found. " + "Configure id_tokens in .gitlab-ci.yml and expose it as " + + self.TOKEN_ENV_VAR + ) diff --git a/cloudsmith_cli/core/tests/test_gitlab_ci_detector.py b/cloudsmith_cli/core/tests/test_gitlab_ci_detector.py new file mode 100644 index 00000000..8d957e03 --- /dev/null +++ b/cloudsmith_cli/core/tests/test_gitlab_ci_detector.py @@ -0,0 +1,74 @@ +"""Tests for the GitLab CI 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.gitlab_ci import GitLabCIDetector + + +@pytest.fixture +def gitlab_env(): + env = { + "GITLAB_CI": "true", + "CLOUDSMITH_OIDC_TOKEN": "the-jwt", + } + with mock.patch.dict("os.environ", env, clear=True): + yield env + + +class TestDetect: + def test_detects_when_gitlab_ci_and_token_present(self, gitlab_env): + detector = GitLabCIDetector(context=CredentialContext()) + assert detector.detect() is True + + def test_not_detected_when_unset(self): + with mock.patch.dict("os.environ", {}, clear=True): + detector = GitLabCIDetector(context=CredentialContext()) + assert detector.detect() is False + + def test_not_detected_without_gitlab_ci_flag(self, gitlab_env): + del gitlab_env["GITLAB_CI"] + with mock.patch.dict("os.environ", gitlab_env, clear=True): + detector = GitLabCIDetector(context=CredentialContext()) + assert detector.detect() is False + + def test_not_detected_when_gitlab_ci_not_true(self, gitlab_env): + gitlab_env["GITLAB_CI"] = "false" + with mock.patch.dict("os.environ", gitlab_env, clear=True): + detector = GitLabCIDetector(context=CredentialContext()) + assert detector.detect() is False + + def test_not_detected_without_any_token(self, gitlab_env): + del gitlab_env["CLOUDSMITH_OIDC_TOKEN"] + with mock.patch.dict("os.environ", gitlab_env, clear=True): + detector = GitLabCIDetector(context=CredentialContext()) + assert detector.detect() is False + + def test_not_detected_with_legacy_ci_job_jwt(self, gitlab_env): + del gitlab_env["CLOUDSMITH_OIDC_TOKEN"] + gitlab_env["CI_JOB_JWT_V2"] = "legacy-jwt" + with mock.patch.dict("os.environ", gitlab_env, clear=True): + detector = GitLabCIDetector(context=CredentialContext()) + assert detector.detect() is False + + +class TestGetToken: + def test_returns_token(self, gitlab_env): + detector = GitLabCIDetector(context=CredentialContext()) + assert detector.get_token() == "the-jwt" + + def test_raises_when_no_token(self, gitlab_env): + del gitlab_env["CLOUDSMITH_OIDC_TOKEN"] + with mock.patch.dict("os.environ", gitlab_env, clear=True): + detector = GitLabCIDetector(context=CredentialContext()) + with pytest.raises(ValueError): + detector.get_token() + + +class TestIntegration: + def test_detect_environment_selects_gitlab_ci(self, gitlab_env): + detector = detect_environment(CredentialContext()) + assert isinstance(detector, GitLabCIDetector)