Skip to content

Commit fcd9f7e

Browse files
feat: add GitLab CI OIDC detector
Add a GitLab CI environment detector to the OIDC credential auto-discovery chain. When running in a GitLab pipeline, the CLI reads an OIDC JWT from an environment variable populated by GitLab's id_tokens configuration and exchanges it for a Cloudsmith token. - Detects via GITLAB_CI=true plus a token env var - Reads the token from CLOUDSMITH_OIDC_TOKEN, falling back to the legacy CI_JOB_JWT_V2 / CI_JOB_JWT variables - Ordered before the AWS detector so a GitLab pipeline with incidental AWS credentials still authenticates as GitLab CI - Needs no extra dependencies (token is already in the environment) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f9422d7 commit fcd9f7e

4 files changed

Lines changed: 142 additions & 0 deletions

File tree

README.md

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,21 @@ 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+
#### GitLab CI OIDC Support
169+
170+
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 `cloudsmith` and expose it as `CLOUDSMITH_OIDC_TOKEN`, and the CLI will pick it up automatically:
171+
172+
```yaml
173+
job:
174+
id_tokens:
175+
CLOUDSMITH_OIDC_TOKEN:
176+
aud: cloudsmith
177+
script:
178+
- cloudsmith push ...
179+
```
180+
181+
See the [Cloudsmith GitLab CI/CD integration guide](https://docs.cloudsmith.com/integrations/integrating-with-gitlab-cicd).
182+
168183
## Configuration
169184
170185
There are two configuration files used by the CLI:

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@
77

88
from .aws import AWSDetector
99
from .base import EnvironmentDetector
10+
from .gitlab_ci import GitLabCIDetector
1011

1112
if TYPE_CHECKING:
1213
from ... import CredentialContext
1314

1415
logger = logging.getLogger(__name__)
1516

1617
_DETECTORS: list[type[EnvironmentDetector]] = [
18+
GitLabCIDetector,
1719
AWSDetector,
1820
]
1921

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
"""GitLab CI OIDC detector.
2+
3+
Reads an OIDC token from environment variables populated by GitLab's
4+
``id_tokens`` configuration in ``.gitlab-ci.yml``.
5+
6+
References:
7+
https://docs.gitlab.com/ci/cloud_services/
8+
https://docs.cloudsmith.com/integrations/integrating-with-gitlab-cicd
9+
"""
10+
11+
from __future__ import annotations
12+
13+
import os
14+
15+
from .base import EnvironmentDetector
16+
17+
18+
class GitLabCIDetector(EnvironmentDetector):
19+
"""Detects GitLab CI and reads an OIDC token from an environment variable.
20+
21+
GitLab requires users to configure ``id_tokens`` in ``.gitlab-ci.yml``.
22+
The token is exposed as an environment variable with a user-chosen name.
23+
We check common conventions in priority order: ``CLOUDSMITH_OIDC_TOKEN``,
24+
``CI_JOB_JWT_V2``, ``CI_JOB_JWT``.
25+
"""
26+
27+
name = "GitLab CI"
28+
29+
TOKEN_ENV_VARS = ["CLOUDSMITH_OIDC_TOKEN", "CI_JOB_JWT_V2", "CI_JOB_JWT"]
30+
31+
def detect(self) -> bool:
32+
if os.environ.get("GITLAB_CI") != "true":
33+
return False
34+
return any(os.environ.get(var) for var in self.TOKEN_ENV_VARS)
35+
36+
def get_token(self) -> str:
37+
for var in self.TOKEN_ENV_VARS:
38+
token = os.environ.get(var)
39+
if token:
40+
return token
41+
raise ValueError(
42+
"GitLab CI detected but no OIDC token found. "
43+
"Configure id_tokens in .gitlab-ci.yml and set one of: "
44+
+ ", ".join(self.TOKEN_ENV_VARS)
45+
)
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
"""Tests for the GitLab CI 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.gitlab_ci import GitLabCIDetector
9+
10+
11+
@pytest.fixture
12+
def gitlab_env():
13+
env = {
14+
"GITLAB_CI": "true",
15+
"CLOUDSMITH_OIDC_TOKEN": "the-jwt",
16+
}
17+
with mock.patch.dict("os.environ", env, clear=True):
18+
yield env
19+
20+
21+
class TestDetect:
22+
def test_detects_when_gitlab_ci_and_token_present(self, gitlab_env):
23+
detector = GitLabCIDetector(context=CredentialContext())
24+
assert detector.detect() is True
25+
26+
def test_not_detected_when_unset(self):
27+
with mock.patch.dict("os.environ", {}, clear=True):
28+
detector = GitLabCIDetector(context=CredentialContext())
29+
assert detector.detect() is False
30+
31+
def test_not_detected_without_gitlab_ci_flag(self, gitlab_env):
32+
del gitlab_env["GITLAB_CI"]
33+
with mock.patch.dict("os.environ", gitlab_env, clear=True):
34+
detector = GitLabCIDetector(context=CredentialContext())
35+
assert detector.detect() is False
36+
37+
def test_not_detected_when_gitlab_ci_not_true(self, gitlab_env):
38+
gitlab_env["GITLAB_CI"] = "false"
39+
with mock.patch.dict("os.environ", gitlab_env, clear=True):
40+
detector = GitLabCIDetector(context=CredentialContext())
41+
assert detector.detect() is False
42+
43+
def test_not_detected_without_any_token(self, gitlab_env):
44+
del gitlab_env["CLOUDSMITH_OIDC_TOKEN"]
45+
with mock.patch.dict("os.environ", gitlab_env, clear=True):
46+
detector = GitLabCIDetector(context=CredentialContext())
47+
assert detector.detect() is False
48+
49+
def test_detects_with_legacy_ci_job_jwt_v2(self, gitlab_env):
50+
del gitlab_env["CLOUDSMITH_OIDC_TOKEN"]
51+
gitlab_env["CI_JOB_JWT_V2"] = "legacy-jwt"
52+
with mock.patch.dict("os.environ", gitlab_env, clear=True):
53+
detector = GitLabCIDetector(context=CredentialContext())
54+
assert detector.detect() is True
55+
56+
57+
class TestGetToken:
58+
def test_returns_preferred_token(self, gitlab_env):
59+
detector = GitLabCIDetector(context=CredentialContext())
60+
assert detector.get_token() == "the-jwt"
61+
62+
def test_prefers_cloudsmith_token_over_legacy(self, gitlab_env):
63+
gitlab_env["CI_JOB_JWT_V2"] = "legacy-jwt"
64+
with mock.patch.dict("os.environ", gitlab_env, clear=True):
65+
detector = GitLabCIDetector(context=CredentialContext())
66+
assert detector.get_token() == "the-jwt"
67+
68+
def test_falls_back_to_legacy_token(self, gitlab_env):
69+
del gitlab_env["CLOUDSMITH_OIDC_TOKEN"]
70+
gitlab_env["CI_JOB_JWT_V2"] = "legacy-jwt"
71+
with mock.patch.dict("os.environ", gitlab_env, clear=True):
72+
detector = GitLabCIDetector(context=CredentialContext())
73+
assert detector.get_token() == "legacy-jwt"
74+
75+
def test_raises_when_no_token(self, gitlab_env):
76+
del gitlab_env["CLOUDSMITH_OIDC_TOKEN"]
77+
with mock.patch.dict("os.environ", gitlab_env, clear=True):
78+
detector = GitLabCIDetector(context=CredentialContext())
79+
with pytest.raises(ValueError):
80+
detector.get_token()

0 commit comments

Comments
 (0)