Skip to content

Commit 01eb20d

Browse files
committed
Agentic Identities for Cloud Run.
1 parent 2071071 commit 01eb20d

File tree

4 files changed

+545
-1
lines changed

4 files changed

+545
-1
lines changed
Lines changed: 288 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,288 @@
1+
/*
2+
* Copyright 2025 Google LLC
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
*
15+
* * Neither the name of Google LLC nor the names of its
16+
* contributors may be used to endorse or promote products derived from
17+
* this software without specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30+
*/
31+
32+
package com.google.auth.oauth2;
33+
34+
import com.google.api.client.json.GenericJson;
35+
import com.google.api.client.json.JsonObjectParser;
36+
import com.google.common.annotations.VisibleForTesting;
37+
import com.google.common.base.Strings;
38+
import com.google.common.collect.ImmutableList;
39+
import com.google.common.io.BaseEncoding;
40+
import java.io.ByteArrayInputStream;
41+
import java.io.IOException;
42+
import java.io.InputStream;
43+
import java.net.URI;
44+
import java.net.URISyntaxException;
45+
import java.nio.charset.StandardCharsets;
46+
import java.nio.file.Files;
47+
import java.nio.file.Paths;
48+
import java.security.GeneralSecurityException;
49+
import java.security.MessageDigest;
50+
import java.security.cert.CertificateException;
51+
import java.security.cert.CertificateFactory;
52+
import java.security.cert.X509Certificate;
53+
import java.util.Collection;
54+
import java.util.List;
55+
import java.util.Map;
56+
import java.util.logging.Level;
57+
import java.util.logging.Logger;
58+
import java.util.regex.Pattern;
59+
import javax.annotation.Nullable;
60+
61+
/**
62+
* Internal utility class for handling Agent Identity certificates.
63+
*
64+
* <p>This class is responsible for detecting Agent Identity certificates, calculating their
65+
* fingerprints, and managing the configuration loading with retries to handle startup timing
66+
* issues.
67+
*/
68+
final class AgentIdentityUtils {
69+
70+
private static final Logger LOGGER = Logger.getLogger(AgentIdentityUtils.class.getName());
71+
72+
static final String GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG";
73+
static final String GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES =
74+
"GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES";
75+
76+
private static final List<Pattern> AGENT_IDENTITY_SPIFFE_TRUST_DOMAIN_PATTERNS =
77+
ImmutableList.of(
78+
Pattern.compile("^agents\\.global\\.org-\\d+\\.system\\.id\\.goog$"),
79+
Pattern.compile("^agents\\.global\\.proj-\\d+\\.system\\.id\\.goog$"));
80+
81+
// Polling configuration
82+
private static final int TOTAL_TIMEOUT_MS = 30000;
83+
private static final int FAST_POLL_DURATION_MS = 5000;
84+
private static final int FAST_POLL_INTERVAL_MS = 100;
85+
private static final int SLOW_POLL_INTERVAL_MS = 500;
86+
87+
private static final int SAN_URI_TYPE = 6;
88+
89+
// Dependencies for testing
90+
private static FileIO fileIO = new FileIO();
91+
private static Environment env = new Environment();
92+
private static Sleeper sleeper = new Sleeper();
93+
94+
private AgentIdentityUtils() {}
95+
96+
/**
97+
* Gets the certificate fingerprint to bind to the access token if an Agent Identity is detected
98+
* and not opted out.
99+
*
100+
* @return The base64url encoded SHA-256 fingerprint of the certificate, or null if binding should
101+
* not occur.
102+
* @throws IOException If there is an error reading the configuration or certificate after
103+
* retries.
104+
*/
105+
@Nullable
106+
static String getBindCertificateFingerprint() throws IOException {
107+
if (isOptOutEnabled()) {
108+
return null;
109+
}
110+
111+
String configPath = env.get(GOOGLE_API_CERTIFICATE_CONFIG);
112+
if (Strings.isNullOrEmpty(configPath)) {
113+
return null;
114+
}
115+
116+
X509Certificate cert = loadAgentIdentityCertificate(configPath);
117+
118+
if (cert != null && isAgentIdentityCertificate(cert)) {
119+
return calculateCertificateFingerprint(cert);
120+
}
121+
122+
return null;
123+
}
124+
125+
private static boolean isOptOutEnabled() {
126+
String optOut = env.get(GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES);
127+
return "false".equalsIgnoreCase(optOut);
128+
}
129+
130+
/**
131+
* Loads the Agent Identity certificate from the path specified in the config file. Implements a
132+
* blocking retry mechanism to handle startup timing issues.
133+
*/
134+
private static X509Certificate loadAgentIdentityCertificate(String configPath)
135+
throws IOException {
136+
long startTime = System.currentTimeMillis();
137+
boolean warned = false;
138+
139+
while (true) {
140+
try {
141+
String certPath = getCertPathFromConfig(configPath);
142+
if (certPath != null) {
143+
// Attempt to read the certificate file
144+
byte[] certBytes = fileIO.readAllBytes(certPath);
145+
return parseCertificate(certBytes);
146+
}
147+
} catch (IOException | GeneralSecurityException e) {
148+
// Log warning only once on the first failure, but keep retrying until timeout
149+
if (!warned) {
150+
LOGGER.log(
151+
Level.WARNING,
152+
"Certificate config file or certificate not found/valid at {0} (from {1} environment variable). "
153+
+ "Retrying for up to {2} seconds. Error: {3}",
154+
new Object[] {
155+
configPath, GOOGLE_API_CERTIFICATE_CONFIG, TOTAL_TIMEOUT_MS / 1000, e.getMessage()
156+
});
157+
warned = true;
158+
}
159+
}
160+
161+
long elapsedTime = System.currentTimeMillis() - startTime;
162+
if (elapsedTime >= TOTAL_TIMEOUT_MS) {
163+
throw new IOException(
164+
"Certificate config or certificate file not found after multiple retries. "
165+
+ "Token binding protection is failing. You can turn off this protection by setting "
166+
+ GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES
167+
+ " to false to fall back to unbound tokens.");
168+
}
169+
170+
try {
171+
long sleepTime =
172+
(elapsedTime < FAST_POLL_DURATION_MS) ? FAST_POLL_INTERVAL_MS : SLOW_POLL_INTERVAL_MS;
173+
sleeper.sleep(sleepTime);
174+
} catch (InterruptedException e) {
175+
Thread.currentThread().interrupt();
176+
throw new IOException("Interrupted while waiting for certificate config", e);
177+
}
178+
}
179+
}
180+
181+
@VisibleForTesting
182+
@SuppressWarnings("unchecked")
183+
static String getCertPathFromConfig(String configPath) throws IOException {
184+
try (InputStream stream = new ByteArrayInputStream(fileIO.readAllBytes(configPath))) {
185+
JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY);
186+
GenericJson config = parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class);
187+
188+
Map<String, Object> certConfigs = (Map<String, Object>) config.get("cert_configs");
189+
if (certConfigs != null) {
190+
Map<String, Object> workload = (Map<String, Object>) certConfigs.get("workload");
191+
if (workload != null) {
192+
return (String) workload.get("cert_path");
193+
}
194+
}
195+
}
196+
return null;
197+
}
198+
199+
@VisibleForTesting
200+
static X509Certificate parseCertificate(byte[] certBytes) throws GeneralSecurityException {
201+
CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
202+
return (X509Certificate) certFactory.generateCertificate(new ByteArrayInputStream(certBytes));
203+
}
204+
205+
@VisibleForTesting
206+
static boolean isAgentIdentityCertificate(X509Certificate cert) {
207+
try {
208+
Collection<List<?>> sanEntries = cert.getSubjectAlternativeNames();
209+
if (sanEntries == null) {
210+
return false;
211+
}
212+
213+
for (List<?> entry : sanEntries) {
214+
// entry matches [type, value]. Type 6 is URI.
215+
if (entry.size() == 2 && (Integer) entry.get(0) == SAN_URI_TYPE) {
216+
String uriString = (String) entry.get(1);
217+
if (uriString.startsWith("spiffe://")) {
218+
if (matchesAgentIdentityTrustDomain(uriString)) {
219+
return true;
220+
}
221+
}
222+
}
223+
}
224+
} catch (CertificateException | URISyntaxException e) {
225+
LOGGER.log(Level.FINE, "Error parsing Subject Alternative Name for Agent Identity check", e);
226+
}
227+
return false;
228+
}
229+
230+
private static boolean matchesAgentIdentityTrustDomain(String spiffeUri)
231+
throws URISyntaxException {
232+
URI uri = new URI(spiffeUri);
233+
String trustDomain = uri.getHost();
234+
if (trustDomain != null) {
235+
for (Pattern pattern : AGENT_IDENTITY_SPIFFE_TRUST_DOMAIN_PATTERNS) {
236+
if (pattern.matcher(trustDomain).matches()) {
237+
return true;
238+
}
239+
}
240+
}
241+
return false;
242+
}
243+
244+
@VisibleForTesting
245+
static String calculateCertificateFingerprint(X509Certificate cert) {
246+
try {
247+
MessageDigest digest = MessageDigest.getInstance("SHA-256");
248+
byte[] hash = digest.digest(cert.getEncoded());
249+
return BaseEncoding.base64Url().omitPadding().encode(hash);
250+
} catch (GeneralSecurityException e) {
251+
throw new RuntimeException("Failed to calculate certificate fingerprint", e);
252+
}
253+
}
254+
255+
// Injectable dependencies for testing
256+
static class FileIO {
257+
byte[] readAllBytes(String path) throws IOException {
258+
return Files.readAllBytes(Paths.get(path));
259+
}
260+
}
261+
262+
static class Environment {
263+
String get(String name) {
264+
return System.getenv(name);
265+
}
266+
}
267+
268+
static class Sleeper {
269+
void sleep(long millis) throws InterruptedException {
270+
Thread.sleep(millis);
271+
}
272+
}
273+
274+
@VisibleForTesting
275+
static void setFileIO(FileIO io) {
276+
fileIO = io;
277+
}
278+
279+
@VisibleForTesting
280+
static void setEnv(Environment e) {
281+
env = e;
282+
}
283+
284+
@VisibleForTesting
285+
static void setSleeper(Sleeper s) {
286+
sleeper = s;
287+
}
288+
}

oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -350,8 +350,16 @@ public AccessToken refreshAccessToken() throws IOException {
350350
principal = getDefaultServiceAccount();
351351
}
352352

353+
String tokenUrlString = createTokenUrlWithScopes();
354+
String fingerprint = AgentIdentityUtils.getBindCertificateFingerprint();
355+
if (fingerprint != null) {
356+
GenericUrl url = new GenericUrl(tokenUrlString);
357+
url.set("bindCertificateFingerprint", fingerprint);
358+
tokenUrlString = url.build();
359+
}
360+
353361
HttpResponse response =
354-
getMetadataResponse(createTokenUrlWithScopes(), RequestType.ACCESS_TOKEN_REQUEST, true);
362+
getMetadataResponse(tokenUrlString, RequestType.ACCESS_TOKEN_REQUEST, true);
355363
int statusCode = response.getStatusCode();
356364
if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) {
357365
throw new IOException(

0 commit comments

Comments
 (0)