Skip to content

Commit 302038f

Browse files
committed
Cloud run with tests.
1 parent a65c22d commit 302038f

File tree

6 files changed

+1801
-1078
lines changed

6 files changed

+1801
-1078
lines changed
Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
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.FileInputStream;
41+
import java.io.IOException;
42+
import java.io.InputStream;
43+
import java.nio.charset.StandardCharsets;
44+
import java.nio.file.Files;
45+
import java.nio.file.Paths;
46+
import java.security.GeneralSecurityException;
47+
import java.security.MessageDigest;
48+
import java.security.cert.CertificateFactory;
49+
import java.security.cert.CertificateParsingException;
50+
import java.security.cert.X509Certificate;
51+
import java.util.Collection;
52+
import java.util.List;
53+
import java.util.Map;
54+
import java.util.logging.Level;
55+
import java.util.logging.Logger;
56+
import java.util.regex.Pattern;
57+
58+
/** Internal utility class for handling Agent Identity certificate-bound access tokens. */
59+
final class AgentIdentityUtils {
60+
private static final Logger LOGGER = Logger.getLogger(AgentIdentityUtils.class.getName());
61+
62+
static final String GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG";
63+
static final String GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES =
64+
"GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES";
65+
66+
private static final List<Pattern> AGENT_IDENTITY_SPIFFE_PATTERNS =
67+
ImmutableList.of(
68+
Pattern.compile("^agents\\.global\\.org-\\d+\\.system\\.id\\.goog$"),
69+
Pattern.compile("^agents\\.global\\.proj-\\d+\\.system\\.id\\.goog$"));
70+
71+
// Polling configuration
72+
static long TOTAL_TIMEOUT_MS = 30000; // 30 seconds
73+
static long FAST_POLL_DURATION_MS = 5000; // 5 seconds
74+
static long FAST_POLL_INTERVAL_MS = 100; // 0.1 seconds
75+
static long SLOW_POLL_INTERVAL_MS = 500; // 0.5 seconds
76+
77+
private static final int SAN_URI_TYPE = 6;
78+
private static final String SPIFFE_SCHEME_PREFIX = "spiffe://";
79+
80+
// Interface to allow mocking System.getenv for tests without exposing it publicly.
81+
interface EnvReader {
82+
String getEnv(String name);
83+
}
84+
85+
private static EnvReader envReader = System::getenv;
86+
87+
private AgentIdentityUtils() {}
88+
89+
/**
90+
* Gets the Agent Identity certificate if available and enabled.
91+
*
92+
* @return The X509Certificate if found and Agent Identities are enabled, null otherwise.
93+
* @throws IOException If there is an error reading the certificate file after retries.
94+
*/
95+
static X509Certificate getAgentIdentityCertificate() throws IOException {
96+
if (isOptedOut()) {
97+
return null;
98+
}
99+
100+
String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG);
101+
if (Strings.isNullOrEmpty(certConfigPath)) {
102+
return null;
103+
}
104+
105+
String certPath = getCertificatePathWithRetry(certConfigPath);
106+
return parseCertificate(certPath);
107+
}
108+
109+
/** Checks if the user has opted out of Agent Token sharing. */
110+
private static boolean isOptedOut() {
111+
String optOut = envReader.getEnv(GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES);
112+
return optOut != null && "false".equalsIgnoreCase(optOut);
113+
}
114+
115+
/** Polls for the certificate config file and the certificate file it references. */
116+
private static String getCertificatePathWithRetry(String certConfigPath) throws IOException {
117+
long startTime = System.currentTimeMillis();
118+
boolean warned = false;
119+
120+
while (true) {
121+
try {
122+
if (Files.exists(Paths.get(certConfigPath))) {
123+
String certPath = extractCertPathFromConfig(certConfigPath);
124+
if (!Strings.isNullOrEmpty(certPath) && Files.exists(Paths.get(certPath))) {
125+
return certPath;
126+
}
127+
}
128+
} catch (Exception e) {
129+
// Ignore exceptions during polling and retry
130+
LOGGER.log(Level.FINE, "Error while polling for certificate files", e);
131+
}
132+
133+
long elapsedTime = System.currentTimeMillis() - startTime;
134+
if (elapsedTime >= TOTAL_TIMEOUT_MS) {
135+
throw new IOException(
136+
"Certificate config or certificate file not found after multiple retries. "
137+
+ "Token binding protection is failing. You can turn off this protection by setting "
138+
+ GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES
139+
+ " to false to fall back to unbound tokens.");
140+
}
141+
142+
if (!warned) {
143+
LOGGER.warning(
144+
String.format(
145+
"Certificate config file not found at %s (from %s environment variable). "
146+
+ "Retrying for up to %d seconds.",
147+
certConfigPath,
148+
GOOGLE_API_CERTIFICATE_CONFIG,
149+
TOTAL_TIMEOUT_MS / 1000));
150+
warned = true;
151+
}
152+
153+
try {
154+
long sleepTime =
155+
elapsedTime < FAST_POLL_DURATION_MS ? FAST_POLL_INTERVAL_MS : SLOW_POLL_INTERVAL_MS;
156+
Thread.sleep(sleepTime);
157+
} catch (InterruptedException e) {
158+
Thread.currentThread().interrupt();
159+
throw new IOException("Interrupted while waiting for certificate files", e);
160+
}
161+
}
162+
}
163+
164+
@SuppressWarnings("unchecked")
165+
private static String extractCertPathFromConfig(String certConfigPath) throws IOException {
166+
try (InputStream stream = new FileInputStream(certConfigPath)) {
167+
JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY);
168+
GenericJson config =
169+
parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class);
170+
Map<String, Object> certConfigs = (Map<String, Object>) config.get("cert_configs");
171+
if (certConfigs != null) {
172+
Map<String, Object> workload = (Map<String, Object>) certConfigs.get("workload");
173+
if (workload != null) {
174+
return (String) workload.get("cert_path");
175+
}
176+
}
177+
}
178+
return null;
179+
}
180+
181+
private static X509Certificate parseCertificate(String certPath) throws IOException {
182+
try (InputStream stream = new FileInputStream(certPath)) {
183+
CertificateFactory cf = CertificateFactory.getInstance("X.509");
184+
return (X509Certificate) cf.generateCertificate(stream);
185+
} catch (GeneralSecurityException e) {
186+
throw new IOException("Failed to parse certificate", e);
187+
}
188+
}
189+
190+
/** Checks if the certificate belongs to an Agent Identity by inspecting SANs. */
191+
static boolean shouldRequestBoundToken(X509Certificate cert) {
192+
try {
193+
Collection<List<?>> sans = cert.getSubjectAlternativeNames();
194+
if (sans == null) {
195+
return false;
196+
}
197+
for (List<?> san : sans) {
198+
// SAN entry is a list where first element is the type (Integer) and second is value (mostly
199+
// String)
200+
if (san.size() >= 2
201+
&& san.get(0) instanceof Integer
202+
&& (Integer) san.get(0) == SAN_URI_TYPE) {
203+
Object value = san.get(1);
204+
if (value instanceof String) {
205+
String uri = (String) value;
206+
if (uri.startsWith(SPIFFE_SCHEME_PREFIX)) {
207+
// Extract trust domain: spiffe://<trust_domain>/...
208+
String withoutScheme = uri.substring(SPIFFE_SCHEME_PREFIX.length());
209+
int slashIndex = withoutScheme.indexOf('/');
210+
String trustDomain =
211+
(slashIndex == -1) ? withoutScheme : withoutScheme.substring(0, slashIndex);
212+
213+
for (Pattern pattern : AGENT_IDENTITY_SPIFFE_PATTERNS) {
214+
if (pattern.matcher(trustDomain).matches()) {
215+
return true;
216+
}
217+
}
218+
}
219+
}
220+
}
221+
}
222+
} catch (CertificateParsingException e) {
223+
LOGGER.log(Level.WARNING, "Failed to parse Subject Alternative Names from certificate", e);
224+
}
225+
return false;
226+
}
227+
228+
/** Calculates the SHA-256 fingerprint of the certificate, Base64Url encoded without padding. */
229+
static String calculateCertificateFingerprint(X509Certificate cert) throws IOException {
230+
try {
231+
MessageDigest md = MessageDigest.getInstance("SHA-256");
232+
byte[] der = cert.getEncoded();
233+
md.update(der);
234+
byte[] digest = md.digest();
235+
return BaseEncoding.base64Url().omitPadding().encode(digest);
236+
} catch (GeneralSecurityException e) {
237+
throw new IOException("Failed to calculate certificate fingerprint", e);
238+
}
239+
}
240+
241+
@VisibleForTesting
242+
static void setEnvReader(EnvReader reader) {
243+
envReader = reader;
244+
}
245+
}

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

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@
6262
import java.io.ObjectInputStream;
6363
import java.net.SocketTimeoutException;
6464
import java.net.UnknownHostException;
65+
import java.security.cert.X509Certificate;
6566
import java.time.Duration;
6667
import java.util.ArrayList;
6768
import java.util.Arrays;
@@ -344,8 +345,26 @@ private String getUniverseDomainFromMetadata() throws IOException {
344345
/** Refresh the access token by getting it from the GCE metadata server */
345346
@Override
346347
public AccessToken refreshAccessToken() throws IOException {
347-
HttpResponse response =
348-
getMetadataResponse(createTokenUrlWithScopes(), RequestType.ACCESS_TOKEN_REQUEST, true);
348+
String tokenUrl = createTokenUrlWithScopes();
349+
350+
try {
351+
X509Certificate cert =
352+
AgentIdentityUtils.getAgentIdentityCertificate();
353+
if (cert != null && AgentIdentityUtils.shouldRequestBoundToken(cert)) {
354+
String fingerprint = AgentIdentityUtils.calculateCertificateFingerprint(cert);
355+
GenericUrl url = new GenericUrl(tokenUrl);
356+
url.set("bindCertificateFingerprint", fingerprint);
357+
tokenUrl = url.build();
358+
}
359+
} catch (IOException e) {
360+
LOGGER.log(
361+
Level.WARNING,
362+
"Failed to process Agent Identity certificate for bound token request.",
363+
e);
364+
throw e;
365+
}
366+
367+
HttpResponse response = getMetadataResponse(tokenUrl, RequestType.ACCESS_TOKEN_REQUEST, true);
349368
int statusCode = response.getStatusCode();
350369
if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) {
351370
throw new IOException(

0 commit comments

Comments
 (0)