Skip to content

Commit cef6a2b

Browse files
authored
feat: Add support for Agent Identity bound tokens (#1821)
This change introduces support for requesting certificate-bound access tokens for Agent Identities on GKE and Cloud Run. The design doc: [go/sdk-agent-identity](http://goto.google.com/sdk-agent-identity)
1 parent 78de790 commit cef6a2b

16 files changed

+855
-12
lines changed
Lines changed: 262 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,262 @@
1+
# Copyright 2025 Google LLC
2+
#
3+
# Licensed under the Apache License, Version 2.0 (the "License");
4+
# you may not use this file except in compliance with the License.
5+
# You may obtain a copy of the License at
6+
#
7+
# http://www.apache.org/licenses/LICENSE-2.0
8+
#
9+
# Unless required by applicable law or agreed to in writing, software
10+
# distributed under the License is distributed on an "AS IS" BASIS,
11+
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
# See the License for the specific language governing permissions and
13+
# limitations under the License.
14+
15+
"""Helpers for Agent Identity credentials."""
16+
17+
import base64
18+
import hashlib
19+
import logging
20+
import os
21+
import re
22+
import time
23+
from urllib.parse import urlparse, quote
24+
25+
from google.auth import environment_vars
26+
from google.auth import exceptions
27+
28+
29+
_LOGGER = logging.getLogger(__name__)
30+
31+
CRYPTOGRAPHY_NOT_FOUND_ERROR = (
32+
"The cryptography library is required for certificate-based authentication."
33+
"Please install it with `pip install google-auth[cryptography]`."
34+
)
35+
36+
# SPIFFE trust domain patterns for Agent Identities.
37+
_AGENT_IDENTITY_SPIFFE_TRUST_DOMAIN_PATTERNS = [
38+
r"^agents\.global\.org-\d+\.system\.id\.goog$",
39+
r"^agents\.global\.proj-\d+\.system\.id\.goog$",
40+
]
41+
42+
_WELL_KNOWN_CERT_PATH = "/var/run/secrets/workload-spiffe-credentials/certificates.pem"
43+
44+
# Constants for polling the certificate file.
45+
_FAST_POLL_CYCLES = 50
46+
_FAST_POLL_INTERVAL = 0.1 # 100ms
47+
_SLOW_POLL_INTERVAL = 0.5 # 500ms
48+
_TOTAL_TIMEOUT = 30 # seconds
49+
50+
# Calculate the number of slow poll cycles based on the total timeout.
51+
_SLOW_POLL_CYCLES = int(
52+
(_TOTAL_TIMEOUT - (_FAST_POLL_CYCLES * _FAST_POLL_INTERVAL)) / _SLOW_POLL_INTERVAL
53+
)
54+
55+
_POLLING_INTERVALS = ([_FAST_POLL_INTERVAL] * _FAST_POLL_CYCLES) + (
56+
[_SLOW_POLL_INTERVAL] * _SLOW_POLL_CYCLES
57+
)
58+
59+
60+
def _is_certificate_file_ready(path):
61+
"""Checks if a file exists and is not empty."""
62+
return path and os.path.exists(path) and os.path.getsize(path) > 0
63+
64+
65+
def get_agent_identity_certificate_path():
66+
"""Gets the certificate path from the certificate config file.
67+
68+
The path to the certificate config file is read from the
69+
GOOGLE_API_CERTIFICATE_CONFIG environment variable. This function
70+
implements a retry mechanism to handle cases where the environment
71+
variable is set before the files are available on the filesystem.
72+
73+
Returns:
74+
str: The path to the leaf certificate file.
75+
76+
Raises:
77+
google.auth.exceptions.RefreshError: If the certificate config file
78+
or the certificate file cannot be found after retries.
79+
"""
80+
import json
81+
82+
cert_config_path = os.environ.get(environment_vars.GOOGLE_API_CERTIFICATE_CONFIG)
83+
if not cert_config_path:
84+
return None
85+
86+
has_logged_warning = False
87+
88+
for interval in _POLLING_INTERVALS:
89+
try:
90+
with open(cert_config_path, "r") as f:
91+
cert_config = json.load(f)
92+
cert_path = (
93+
cert_config.get("cert_configs", {})
94+
.get("workload", {})
95+
.get("cert_path")
96+
)
97+
if _is_certificate_file_ready(cert_path):
98+
return cert_path
99+
except (IOError, ValueError, KeyError):
100+
if not has_logged_warning:
101+
_LOGGER.warning(
102+
"Certificate config file not found at %s (from %s environment "
103+
"variable). Retrying for up to %s seconds.",
104+
cert_config_path,
105+
environment_vars.GOOGLE_API_CERTIFICATE_CONFIG,
106+
_TOTAL_TIMEOUT,
107+
)
108+
has_logged_warning = True
109+
pass
110+
111+
# As a fallback, check the well-known certificate path.
112+
if _is_certificate_file_ready(_WELL_KNOWN_CERT_PATH):
113+
return _WELL_KNOWN_CERT_PATH
114+
115+
# A sleep is required in two cases:
116+
# 1. The config file is not found (the except block).
117+
# 2. The config file is found, but the certificate is not yet available.
118+
# In both cases, we need to poll, so we sleep on every iteration
119+
# that doesn't return a certificate.
120+
time.sleep(interval)
121+
122+
raise exceptions.RefreshError(
123+
"Certificate config or certificate file not found after multiple retries. "
124+
f"Token binding protection is failing. You can turn off this protection by setting "
125+
f"{environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES} to false "
126+
"to fall back to unbound tokens."
127+
)
128+
129+
130+
def get_and_parse_agent_identity_certificate():
131+
"""Gets and parses the agent identity certificate if not opted out.
132+
133+
Checks if the user has opted out of certificate-bound tokens. If not,
134+
it gets the certificate path, reads the file, and parses it.
135+
136+
Returns:
137+
The parsed certificate object if found and not opted out, otherwise None.
138+
"""
139+
# If the user has opted out of cert bound tokens, there is no need to
140+
# look up the certificate.
141+
is_opted_out = (
142+
os.environ.get(
143+
environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES,
144+
"true",
145+
).lower()
146+
== "false"
147+
)
148+
if is_opted_out:
149+
return None
150+
151+
cert_path = get_agent_identity_certificate_path()
152+
if not cert_path:
153+
return None
154+
155+
with open(cert_path, "rb") as cert_file:
156+
cert_bytes = cert_file.read()
157+
158+
return parse_certificate(cert_bytes)
159+
160+
161+
def parse_certificate(cert_bytes):
162+
"""Parses a PEM-encoded certificate.
163+
164+
Args:
165+
cert_bytes (bytes): The PEM-encoded certificate bytes.
166+
167+
Returns:
168+
cryptography.x509.Certificate: The parsed certificate object.
169+
"""
170+
try:
171+
from cryptography import x509
172+
173+
return x509.load_pem_x509_certificate(cert_bytes)
174+
except ImportError as e:
175+
raise ImportError(CRYPTOGRAPHY_NOT_FOUND_ERROR) from e
176+
177+
178+
def _is_agent_identity_certificate(cert):
179+
"""Checks if a certificate is an Agent Identity certificate.
180+
181+
This is determined by checking the Subject Alternative Name (SAN) for a
182+
SPIFFE ID with a trust domain matching Agent Identity patterns.
183+
184+
Args:
185+
cert (cryptography.x509.Certificate): The parsed certificate object.
186+
187+
Returns:
188+
bool: True if the certificate is an Agent Identity certificate,
189+
False otherwise.
190+
"""
191+
try:
192+
from cryptography import x509
193+
from cryptography.x509.oid import ExtensionOID
194+
195+
try:
196+
ext = cert.extensions.get_extension_for_oid(
197+
ExtensionOID.SUBJECT_ALTERNATIVE_NAME
198+
)
199+
except x509.ExtensionNotFound:
200+
return False
201+
uris = ext.value.get_values_for_type(x509.UniformResourceIdentifier)
202+
203+
for uri in uris:
204+
parsed_uri = urlparse(uri)
205+
if parsed_uri.scheme == "spiffe":
206+
trust_domain = parsed_uri.netloc
207+
for pattern in _AGENT_IDENTITY_SPIFFE_TRUST_DOMAIN_PATTERNS:
208+
if re.match(pattern, trust_domain):
209+
return True
210+
return False
211+
except ImportError as e:
212+
raise ImportError(CRYPTOGRAPHY_NOT_FOUND_ERROR) from e
213+
214+
215+
def calculate_certificate_fingerprint(cert):
216+
"""Calculates the URL-encoded, unpadded, base64-encoded SHA256 hash of a
217+
DER-encoded certificate.
218+
219+
Args:
220+
cert (cryptography.x509.Certificate): The parsed certificate object.
221+
222+
Returns:
223+
str: The URL-encoded, unpadded, base64-encoded SHA256 fingerprint.
224+
"""
225+
try:
226+
from cryptography.hazmat.primitives import serialization
227+
228+
der_cert = cert.public_bytes(serialization.Encoding.DER)
229+
fingerprint = hashlib.sha256(der_cert).digest()
230+
# The certificate fingerprint is generated in two steps to align with GFE's
231+
# expectations and ensure proper URL transmission:
232+
# 1. Standard base64 encoding is applied, and padding ('=') is removed.
233+
# 2. The resulting string is then URL-encoded to handle special characters
234+
# ('+', '/') that would otherwise be misinterpreted in URL parameters.
235+
base64_fingerprint = base64.b64encode(fingerprint).decode("utf-8")
236+
unpadded_base64_fingerprint = base64_fingerprint.rstrip("=")
237+
return quote(unpadded_base64_fingerprint)
238+
except ImportError as e:
239+
raise ImportError(CRYPTOGRAPHY_NOT_FOUND_ERROR) from e
240+
241+
242+
def should_request_bound_token(cert):
243+
"""Determines if a bound token should be requested.
244+
245+
This is based on the GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES
246+
environment variable and whether the certificate is an agent identity cert.
247+
248+
Args:
249+
cert (cryptography.x509.Certificate): The parsed certificate object.
250+
251+
Returns:
252+
bool: True if a bound token should be requested, False otherwise.
253+
"""
254+
is_agent_cert = _is_agent_identity_certificate(cert)
255+
is_opted_in = (
256+
os.environ.get(
257+
environment_vars.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES,
258+
"true",
259+
).lower()
260+
== "true"
261+
)
262+
return is_agent_cert and is_opted_in

google/auth/_oauth2client.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -127,7 +127,7 @@ def _convert_appengine_app_assertion_credentials(credentials):
127127
oauth2client.contrib.gce.AppAssertionCredentials: _convert_gce_app_assertion_credentials,
128128
}
129129

130-
if _HAS_APPENGINE:
130+
if _HAS_APPENGINE: # pragma: no cover
131131
_CLASS_CONVERSION_MAP[
132132
oauth2client.contrib.appengine.AppAssertionCredentials
133133
] = _convert_appengine_app_assertion_credentials

google/auth/compute_engine/_metadata.py

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -451,12 +451,19 @@ def get_service_account_token(request, service_account="default", scopes=None):
451451
google.auth.exceptions.TransportError: if an error occurred while
452452
retrieving metadata.
453453
"""
454+
from google.auth import _agent_identity_utils
455+
456+
params = {}
454457
if scopes:
455458
if not isinstance(scopes, str):
456459
scopes = ",".join(scopes)
457-
params = {"scopes": scopes}
458-
else:
459-
params = None
460+
params["scopes"] = scopes
461+
462+
cert = _agent_identity_utils.get_and_parse_agent_identity_certificate()
463+
if cert:
464+
if _agent_identity_utils.should_request_bound_token(cert):
465+
fingerprint = _agent_identity_utils.calculate_certificate_fingerprint(cert)
466+
params["bindCertificateFingerprint"] = fingerprint
460467

461468
metrics_header = {
462469
metrics.API_CLIENT_HEADER: metrics.token_request_access_token_mds()

google/auth/compute_engine/credentials.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -135,9 +135,9 @@ def _refresh_token(self, request):
135135
service can't be reached if if the instance has not
136136
credentials.
137137
"""
138-
scopes = self._scopes if self._scopes is not None else self._default_scopes
139138
try:
140139
self._retrieve_info(request)
140+
scopes = self._scopes if self._scopes is not None else self._default_scopes
141141
# Always fetch token with default service account email.
142142
self.token, self.expiry = _metadata.get_service_account_token(
143143
request, service_account="default", scopes=scopes

google/auth/environment_vars.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,3 +92,12 @@
9292
GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED = "GOOGLE_AUTH_TRUST_BOUNDARY_ENABLED"
9393
"""Environment variable controlling whether to enable trust boundary feature.
9494
The default value is false. Users have to explicitly set this value to true."""
95+
96+
GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG"
97+
"""Environment variable defining the location of Google API certificate config
98+
file."""
99+
100+
GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES = (
101+
"GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES"
102+
)
103+
"""Environment variable to prevent agent token sharing for GCP services."""

google/auth/external_account.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -420,6 +420,9 @@ def refresh(self, request):
420420
credentials, it will refresh the access token and the trust boundary.
421421
"""
422422
self._refresh_token(request)
423+
self._handle_trust_boundary(request)
424+
425+
def _handle_trust_boundary(self, request):
423426
# If we are impersonating, the trust boundary is handled by the
424427
# impersonated credentials object. We need to get it from there.
425428
if self._service_account_impersonation_url:
@@ -428,7 +431,7 @@ def refresh(self, request):
428431
# Otherwise, refresh the trust boundary for the external account.
429432
self._refresh_trust_boundary(request)
430433

431-
def _refresh_token(self, request):
434+
def _refresh_token(self, request, cert_fingerprint=None):
432435
scopes = self._scopes if self._scopes is not None else self._default_scopes
433436

434437
# Inject client certificate into request.
@@ -446,11 +449,15 @@ def _refresh_token(self, request):
446449
self.expiry = self._impersonated_credentials.expiry
447450
else:
448451
now = _helpers.utcnow()
449-
additional_options = None
452+
additional_options = {}
450453
# Do not pass workforce_pool_user_project when client authentication
451454
# is used. The client ID is sufficient for determining the user project.
452455
if self._workforce_pool_user_project and not self._client_id:
453-
additional_options = {"userProject": self._workforce_pool_user_project}
456+
additional_options["userProject"] = self._workforce_pool_user_project
457+
458+
if cert_fingerprint:
459+
additional_options["bindCertFingerprint"] = cert_fingerprint
460+
454461
additional_headers = {
455462
metrics.API_CLIENT_HEADER: metrics.byoid_metrics_header(
456463
self._metrics_options
@@ -464,7 +471,7 @@ def _refresh_token(self, request):
464471
audience=self._audience,
465472
scopes=scopes,
466473
requested_token_type=_STS_REQUESTED_TOKEN_TYPE,
467-
additional_options=additional_options,
474+
additional_options=additional_options if additional_options else None,
468475
additional_headers=additional_headers,
469476
)
470477
self.token = response_data.get("access_token")

google/auth/identity_pool.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,3 +550,25 @@ def from_file(cls, filename, **kwargs):
550550
credentials.
551551
"""
552552
return super(Credentials, cls).from_file(filename, **kwargs)
553+
554+
def refresh(self, request):
555+
"""Refreshes the access token.
556+
557+
Args:
558+
request (google.auth.transport.Request): The object used to make
559+
HTTP requests.
560+
"""
561+
from google.auth import _agent_identity_utils
562+
563+
cert_fingerprint = None
564+
# Check if the credential is X.509 based.
565+
if self._credential_source_certificate is not None:
566+
cert_bytes = self._get_cert_bytes()
567+
cert = _agent_identity_utils.parse_certificate(cert_bytes)
568+
if _agent_identity_utils.should_request_bound_token(cert):
569+
cert_fingerprint = _agent_identity_utils.calculate_certificate_fingerprint(
570+
cert
571+
)
572+
573+
self._refresh_token(request, cert_fingerprint=cert_fingerprint)
574+
self._handle_trust_boundary(request)

0 commit comments

Comments
 (0)