Skip to content

Commit c80922f

Browse files
feat: add Google Cloud OIDC detector
Adds a Google Cloud OIDC environment detector to the credential auto-discovery chain, mirroring the existing AWS detector. When CLOUDSMITH_ORG and CLOUDSMITH_SERVICE_SLUG are set, the CLI obtains a Google-signed ID token from the ambient GCP identity via the google-auth SDK and exchanges it for a short-lived Cloudsmith token. get_token() tries three strategies in order: 1. Metadata server (GCE/Cloud Run/GKE/Cloud Functions/App Engine/Cloud Build) via a direct request with format=full, required for the email/email_verified claims Cloudsmith pins (the SDK metadata path cannot request format=full). 2. Service-account key file (GOOGLE_APPLICATION_CREDENTIALS) via google-auth. 3. Application Default Credentials (e.g. local `gcloud auth application-default login`) via google-auth. detect() activates only when google-auth is importable and either ADC resolves or the metadata server is reachable; it never raises into the chain, and environment probing is side-effect-free (no throwaway token). google-auth is an optional dependency under a new [gcp] extra. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 65f985a commit c80922f

4 files changed

Lines changed: 321 additions & 0 deletions

File tree

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

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

88
from .aws import AWSDetector
99
from .base import EnvironmentDetector
10+
from .gcp import GCPDetector
1011

1112
if TYPE_CHECKING:
1213
from ... import CredentialContext
@@ -15,6 +16,7 @@
1516

1617
_DETECTORS: list[type[EnvironmentDetector]] = [
1718
AWSDetector,
19+
GCPDetector,
1820
]
1921

2022

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
"""Google Cloud OIDC detector.
2+
3+
Uses the google-auth SDK to auto-discover Google Cloud credentials and obtain a
4+
signed ID token (JWT) for Cloudsmith. get_token() tries three strategies in
5+
order: the metadata server (GCE/Cloud Run/GKE/Cloud Functions/App Engine/Cloud
6+
Build), a service-account key file, and local Application Default Credentials.
7+
8+
Requires google-auth (optional dependency): pip install cloudsmith-cli[gcp]
9+
10+
References:
11+
https://cloudsmith.com/blog/authenticate-to-cloudsmith-with-your-google-cloud-identity
12+
"""
13+
14+
from __future__ import annotations
15+
16+
import logging
17+
import os
18+
19+
import requests
20+
21+
from .base import EnvironmentDetector
22+
23+
logger = logging.getLogger(__name__)
24+
25+
DEFAULT_AUDIENCE = "cloudsmith"
26+
27+
METADATA_IDENTITY_URL = (
28+
"http://metadata.google.internal/computeMetadata/v1/"
29+
"instance/service-accounts/default/identity"
30+
)
31+
METADATA_PROBE_URL = (
32+
"http://metadata.google.internal/computeMetadata/v1/"
33+
"instance/service-accounts/default/"
34+
)
35+
METADATA_FLAVOR_HEADER = {"Metadata-Flavor": "Google"}
36+
METADATA_TIMEOUT = 5
37+
38+
39+
class GCPDetector(EnvironmentDetector):
40+
"""Detects Google Cloud environments and obtains an OIDC ID token."""
41+
42+
name = "Google Cloud"
43+
44+
def detect(self) -> bool:
45+
try:
46+
import google.auth
47+
from google.auth.exceptions import DefaultCredentialsError
48+
except ImportError:
49+
logger.debug("GCPDetector: google-auth not installed, skipping")
50+
return False
51+
52+
try:
53+
try:
54+
google.auth.default()
55+
return True
56+
except DefaultCredentialsError:
57+
return self._metadata_available()
58+
except Exception: # pylint: disable=broad-exception-caught
59+
logger.debug(
60+
"GCPDetector: unexpected error during detection", exc_info=True
61+
)
62+
return False
63+
64+
def _metadata_available(self) -> bool:
65+
try:
66+
response = requests.get(
67+
METADATA_PROBE_URL,
68+
headers=METADATA_FLAVOR_HEADER,
69+
timeout=METADATA_TIMEOUT,
70+
)
71+
return response.status_code == 200
72+
except requests.exceptions.RequestException:
73+
return False
74+
75+
def get_token(self) -> str:
76+
audience = self.context.oidc_audience or DEFAULT_AUDIENCE
77+
for strategy in (
78+
self._token_from_metadata,
79+
self._token_from_service_account_file,
80+
self._token_from_adc,
81+
):
82+
token = strategy(audience)
83+
if token:
84+
return token
85+
raise ValueError(
86+
"Google Cloud detector could not obtain an OIDC token via the "
87+
"metadata server, a service-account key file, or Application "
88+
"Default Credentials. Set CLOUDSMITH_OIDC_TOKEN to provide a token "
89+
"directly."
90+
)
91+
92+
def _token_from_metadata(self, audience: str) -> str | None:
93+
try:
94+
response = requests.get(
95+
METADATA_IDENTITY_URL,
96+
headers=METADATA_FLAVOR_HEADER,
97+
params={"audience": audience, "format": "full"},
98+
timeout=METADATA_TIMEOUT,
99+
)
100+
except requests.exceptions.RequestException:
101+
return None
102+
if response.status_code != 200:
103+
return None
104+
return response.text.strip() or None
105+
106+
def _token_from_service_account_file(self, audience: str) -> str | None:
107+
key_path = os.environ.get("GOOGLE_APPLICATION_CREDENTIALS")
108+
if not key_path:
109+
return None
110+
try:
111+
from google.auth.transport.requests import ( # pylint: disable=import-error
112+
Request,
113+
)
114+
from google.oauth2 import service_account # pylint: disable=import-error
115+
116+
credentials = service_account.IDTokenCredentials.from_service_account_file(
117+
key_path,
118+
target_audience=audience,
119+
)
120+
credentials.refresh(Request())
121+
return credentials.token or None
122+
except Exception: # pylint: disable=broad-exception-caught
123+
logger.debug(
124+
"GCPDetector: service-account key file token fetch failed",
125+
exc_info=True,
126+
)
127+
return None
128+
129+
def _token_from_adc(self, audience: str) -> str | None:
130+
# audience is unused here: ADC user credentials mint an id_token whose
131+
# aud is gcloud's OAuth client id, not a custom audience. The signature
132+
# stays uniform so get_token can call every strategy as strategy(audience).
133+
try:
134+
import google.auth # pylint: disable=import-error
135+
from google.auth.transport.requests import ( # pylint: disable=import-error
136+
Request,
137+
)
138+
139+
credentials, _ = google.auth.default()
140+
credentials.refresh(Request())
141+
return getattr(credentials, "id_token", None) or None
142+
except Exception: # pylint: disable=broad-exception-caught
143+
logger.debug("GCPDetector: ADC id_token fetch failed", exc_info=True)
144+
return None
Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
"""Tests for the Google Cloud OIDC detector."""
2+
3+
import sys
4+
from unittest import mock
5+
6+
import pytest
7+
import requests
8+
9+
from cloudsmith_cli.core.credentials.models import CredentialContext
10+
from cloudsmith_cli.core.credentials.oidc.detectors.gcp import GCPDetector
11+
12+
GCP_MODULE = "cloudsmith_cli.core.credentials.oidc.detectors.gcp"
13+
14+
15+
def make_detector(**ctx):
16+
return GCPDetector(context=CredentialContext(**ctx))
17+
18+
19+
class TestDetect:
20+
def test_not_detected_when_google_auth_missing(self):
21+
with mock.patch.dict(sys.modules, {"google.auth": None}):
22+
assert make_detector().detect() is False
23+
24+
def test_detected_when_adc_resolves(self):
25+
with mock.patch("google.auth.default", return_value=(mock.MagicMock(), "proj")):
26+
assert make_detector().detect() is True
27+
28+
def test_detected_via_metadata_when_adc_unavailable(self):
29+
from google.auth.exceptions import ( # pylint: disable=import-error
30+
DefaultCredentialsError,
31+
)
32+
33+
with mock.patch(
34+
"google.auth.default", side_effect=DefaultCredentialsError("no adc")
35+
), mock.patch(f"{GCP_MODULE}.requests.get") as get:
36+
get.return_value = mock.Mock(status_code=200)
37+
assert make_detector().detect() is True
38+
39+
def test_not_detected_when_nothing_available(self):
40+
from google.auth.exceptions import ( # pylint: disable=import-error
41+
DefaultCredentialsError,
42+
)
43+
44+
with mock.patch(
45+
"google.auth.default", side_effect=DefaultCredentialsError("no adc")
46+
), mock.patch(f"{GCP_MODULE}.requests.get") as get:
47+
get.side_effect = requests.exceptions.ConnectionError()
48+
assert make_detector().detect() is False
49+
50+
def test_detect_never_raises(self):
51+
with mock.patch("google.auth.default", side_effect=RuntimeError("boom")):
52+
assert make_detector().detect() is False
53+
54+
55+
class TestGetTokenMetadata:
56+
def test_returns_metadata_token_with_format_full(self):
57+
with mock.patch(f"{GCP_MODULE}.requests.get") as get:
58+
get.return_value = mock.Mock(status_code=200, text="meta-jwt\n")
59+
token = make_detector().get_token()
60+
assert token == "meta-jwt"
61+
_, kwargs = get.call_args
62+
assert kwargs["params"] == {"audience": "cloudsmith", "format": "full"}
63+
assert kwargs["headers"] == {"Metadata-Flavor": "Google"}
64+
65+
def test_uses_configured_audience(self):
66+
with mock.patch(f"{GCP_MODULE}.requests.get") as get:
67+
get.return_value = mock.Mock(status_code=200, text="meta-jwt")
68+
make_detector(oidc_audience="custom-aud").get_token()
69+
_, kwargs = get.call_args
70+
assert kwargs["params"]["audience"] == "custom-aud"
71+
72+
def test_metadata_skipped_when_non_200(self):
73+
from google.auth.exceptions import ( # pylint: disable=import-error
74+
DefaultCredentialsError,
75+
)
76+
77+
with mock.patch(f"{GCP_MODULE}.requests.get") as get, mock.patch(
78+
"google.auth.default", side_effect=DefaultCredentialsError("no adc")
79+
), mock.patch.dict("os.environ", {}, clear=True):
80+
get.return_value = mock.Mock(status_code=404, text="")
81+
with pytest.raises(ValueError):
82+
make_detector().get_token()
83+
84+
def test_metadata_returns_none_on_request_exception(self):
85+
with mock.patch(
86+
f"{GCP_MODULE}.requests.get",
87+
side_effect=requests.exceptions.ConnectionError(),
88+
):
89+
assert (
90+
make_detector()._token_from_metadata( # pylint: disable=protected-access
91+
"cloudsmith"
92+
)
93+
is None
94+
)
95+
96+
def test_metadata_returns_none_on_blank_body(self):
97+
with mock.patch(f"{GCP_MODULE}.requests.get") as get:
98+
get.return_value = mock.Mock(status_code=200, text=" ")
99+
assert (
100+
make_detector()._token_from_metadata( # pylint: disable=protected-access
101+
"cloudsmith"
102+
)
103+
is None
104+
)
105+
106+
107+
class TestGetTokenServiceAccountFile:
108+
def test_returns_token_from_key_file(self):
109+
from google.oauth2 import service_account # pylint: disable=import-error
110+
111+
fake_creds = mock.Mock(token="sa-jwt")
112+
with mock.patch(f"{GCP_MODULE}.requests.get") as get, mock.patch.dict(
113+
"os.environ",
114+
{"GOOGLE_APPLICATION_CREDENTIALS": "/tmp/key.json"},
115+
clear=True,
116+
), mock.patch.object(
117+
service_account.IDTokenCredentials,
118+
"from_service_account_file",
119+
return_value=fake_creds,
120+
) as from_file:
121+
# Metadata unavailable so we fall through to the key-file strategy.
122+
get.side_effect = requests.exceptions.ConnectionError()
123+
token = make_detector().get_token()
124+
assert token == "sa-jwt"
125+
args, kwargs = from_file.call_args
126+
assert args[0] == "/tmp/key.json"
127+
assert kwargs["target_audience"] == "cloudsmith"
128+
fake_creds.refresh.assert_called_once()
129+
130+
def test_skipped_when_env_unset(self):
131+
from google.auth.exceptions import ( # pylint: disable=import-error
132+
DefaultCredentialsError,
133+
)
134+
135+
with mock.patch(f"{GCP_MODULE}.requests.get") as get, mock.patch(
136+
"google.auth.default", side_effect=DefaultCredentialsError("no adc")
137+
), mock.patch.dict("os.environ", {}, clear=True):
138+
get.side_effect = requests.exceptions.ConnectionError()
139+
with pytest.raises(ValueError):
140+
make_detector().get_token()
141+
142+
143+
class TestGetTokenADC:
144+
def test_returns_id_token_from_adc(self):
145+
fake_creds = mock.Mock(id_token="adc-jwt")
146+
with mock.patch(f"{GCP_MODULE}.requests.get") as get, mock.patch.dict(
147+
"os.environ", {}, clear=True
148+
), mock.patch("google.auth.default", return_value=(fake_creds, "proj")):
149+
get.side_effect = requests.exceptions.ConnectionError()
150+
token = make_detector().get_token()
151+
assert token == "adc-jwt"
152+
fake_creds.refresh.assert_called_once()
153+
154+
def test_raises_when_adc_has_no_id_token(self):
155+
fake_creds = mock.Mock(spec=["refresh"]) # no id_token attribute
156+
with mock.patch(f"{GCP_MODULE}.requests.get") as get, mock.patch.dict(
157+
"os.environ", {}, clear=True
158+
), mock.patch("google.auth.default", return_value=(fake_creds, "proj")):
159+
get.side_effect = requests.exceptions.ConnectionError()
160+
with pytest.raises(ValueError):
161+
make_detector().get_token()
162+
163+
164+
class TestRegistration:
165+
def test_registered_after_aws(self):
166+
from cloudsmith_cli.core.credentials.oidc.detectors import (
167+
_DETECTORS,
168+
AWSDetector,
169+
)
170+
171+
assert _DETECTORS == [AWSDetector, GCPDetector]

setup.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,8 +69,12 @@ def get_long_description():
6969
"aws": [
7070
"boto3[crt]>=1.26.0",
7171
],
72+
"gcp": [
73+
"google-auth>=2.0.0",
74+
],
7275
"all": [
7376
"boto3[crt]>=1.26.0",
77+
"google-auth>=2.0.0",
7478
],
7579
},
7680
entry_points={

0 commit comments

Comments
 (0)