Skip to content

Commit c30b10c

Browse files
committed
Added documentation and addressed comments.
1 parent 432a980 commit c30b10c

10 files changed

Lines changed: 166 additions & 69 deletions

File tree

google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java

Lines changed: 104 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -30,21 +30,19 @@
3030
*/
3131
package com.google.auth.oauth2;
3232

33+
import com.google.api.core.InternalApi;
3334
import com.google.api.client.json.GenericJson;
3435
import com.google.api.client.json.JsonObjectParser;
3536
import com.google.common.annotations.VisibleForTesting;
3637
import com.google.common.base.Strings;
3738
import com.google.common.collect.ImmutableList;
38-
import com.google.common.io.BaseEncoding;
3939
import java.io.FileInputStream;
4040
import java.io.IOException;
4141
import java.io.InputStream;
42-
import java.net.URLEncoder;
4342
import java.nio.charset.StandardCharsets;
4443
import java.nio.file.Files;
4544
import java.nio.file.Paths;
4645
import java.security.GeneralSecurityException;
47-
import java.security.MessageDigest;
4846
import java.security.PrivateKey;
4947
import java.security.Signature;
5048
import java.security.cert.CertificateFactory;
@@ -60,7 +58,8 @@
6058
import org.slf4j.LoggerFactory;
6159

6260
/** Utility class for Agent Identity token binding in Cloud Run. */
63-
class AgentIdentityUtils {
61+
@InternalApi
62+
public final class AgentIdentityUtils {
6463

6564
private static final Logger LOGGER = LoggerFactory.getLogger(AgentIdentityUtils.class);
6665

@@ -104,7 +103,7 @@ static void setWellKnownDir(String dir) {
104103
POLLING_INTERVALS = Collections.unmodifiableList(intervals);
105104
}
106105

107-
interface EnvReader {
106+
public interface EnvReader {
108107
String getEnv(String name);
109108
}
110109

@@ -142,21 +141,66 @@ static class CertInfo {
142141
}
143142
}
144143

144+
static class ResolvedCertAndKeyPaths {
145+
final String certPath;
146+
final String keyPath;
147+
148+
ResolvedCertAndKeyPaths(String certPath, String keyPath) {
149+
this.certPath = certPath;
150+
this.keyPath = keyPath;
151+
}
152+
}
153+
154+
/**
155+
* Retrieves the certificate and path for the Agent Identity.
156+
*
157+
* <p>This method attempts to load the certificate and private key for the agent identity. It
158+
* first checks the location specified by the {@code GOOGLE_API_CERTIFICATE_CONFIG} environment
159+
* variable. If not set, it falls back to well-known default locations.
160+
*
161+
* <p>To handle transient race conditions during certificate rotation on disk, this method employs
162+
* a retry mechanism with backoff when reading the configuration and certificate files.
163+
*
164+
* @return A {@link CertInfo} object containing the loaded certificate and its path, or {@code
165+
* null} if the agent identity features are disabled, opted out, or if no valid credentials
166+
* could be loaded.
167+
* @throws IOException If an I/O error occurs while reading the files, or if the key-pair
168+
* verification fails after retries.
169+
*/
145170
static CertInfo getAgentIdentityCertInfo() throws IOException {
146171
if (isOptedOut()) {
147172
return null;
148173
}
149174
String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG);
150-
151175
boolean configExists =
152176
!Strings.isNullOrEmpty(certConfigPath) && Files.exists(Paths.get(certConfigPath));
177+
178+
ResolvedCertAndKeyPaths paths = resolveCertAndKeyPaths(certConfigPath);
179+
boolean certsPresent = !Strings.isNullOrEmpty(paths.certPath);
180+
181+
if (!shouldEnableMtls(certsPresent, configExists)) {
182+
return null;
183+
}
184+
185+
return loadAndVerifyCredentials(paths.certPath, paths.keyPath);
186+
}
187+
188+
/**
189+
* Resolves the paths for the certificate and private key based on the config path or well-known
190+
* locations.
191+
*/
192+
static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(String certConfigPath) throws IOException {
153193
String certPath = null;
154194
String keyPath = null;
155195

156196
if (!Strings.isNullOrEmpty(certConfigPath)) {
197+
// Read cert path from config file. We use retry with backoff to handle transient race
198+
// conditions where the config file might be being updated by a rotation process.
157199
certPath = getCertificatePathWithRetry(certConfigPath);
158200
keyPath = extractKeyPathFromConfig(certConfigPath);
159201
} else {
202+
// Fallback to well-known locations. We use retry with backoff here as well to handle
203+
// race conditions during file replacement by a rotation process.
160204
certPath = getWellKnownCertificatePathWithRetry();
161205
if (certPath != null) {
162206
if (certPath.endsWith("credentialbundle.pem")) {
@@ -166,13 +210,13 @@ static CertInfo getAgentIdentityCertInfo() throws IOException {
166210
}
167211
}
168212
}
213+
return new ResolvedCertAndKeyPaths(certPath, keyPath);
214+
}
169215

170-
boolean certsPresent = !Strings.isNullOrEmpty(certPath);
171-
172-
if (!shouldEnableMtls(certsPresent, configExists)) {
173-
return null;
174-
}
175-
216+
/**
217+
* Loads the certificate and private key, and verifies that they match if they are separate files.
218+
*/
219+
static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws IOException {
176220
X509Certificate cert = null;
177221
PrivateKey privateKey = null;
178222

@@ -220,11 +264,18 @@ static CertInfo getAgentIdentityCertInfo() throws IOException {
220264
return new CertInfo(cert, certPath);
221265
}
222266

267+
/**
268+
* Checks if the user has opted out of token sharing by setting the environment variable to true.
269+
*/
223270
private static boolean isOptedOut() {
224271
String optOut = envReader.getEnv(GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES);
225-
return "false".equalsIgnoreCase(optOut);
272+
return "true".equalsIgnoreCase(optOut);
226273
}
227274

275+
/**
276+
* Reads the certificate path from the config file with retry logic to handle rotation race
277+
* conditions.
278+
*/
228279
private static String getCertificatePathWithRetry(String certConfigPath) throws IOException {
229280
boolean warned = false;
230281
for (long sleepInterval : POLLING_INTERVALS) {
@@ -263,6 +314,7 @@ private static String getCertificatePathWithRetry(String certConfigPath) throws
263314
+ " to false to fall back to unbound tokens.");
264315
}
265316

317+
/** Searches for certificates at well-known locations with retry logic. */
266318
private static String getWellKnownCertificatePathWithRetry() throws IOException {
267319
String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString();
268320
String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString();
@@ -300,10 +352,15 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException
300352
"Unable to find well-known certificate file for bound token request after multiple retries.");
301353
}
302354

355+
/** Reads the full certificate chain from the specified path as a string. */
303356
static String readCertificateChain(String certPath) throws IOException {
304357
return new String(Files.readAllBytes(Paths.get(certPath)), StandardCharsets.UTF_8);
305358
}
306359

360+
/**
361+
* Verifies that the private key corresponds to the public key in the certificate by performing a
362+
* test signature and verification.
363+
*/
307364
static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) {
308365
try {
309366
byte[] data = "verification-data".getBytes(StandardCharsets.UTF_8);
@@ -334,27 +391,38 @@ static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) {
334391
}
335392
}
336393

394+
/** Reads the private key from the specified path using PKCS8 format. */
337395
static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOException {
338396
String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8);
339397
OAuth2Utils.Pkcs8Algorithm pkcs8Alg =
340398
"EC".equals(algorithm) ? OAuth2Utils.Pkcs8Algorithm.EC : OAuth2Utils.Pkcs8Algorithm.RSA;
341399
return OAuth2Utils.privateKeyFromPkcs8(keyPem, pkcs8Alg);
342400
}
343401

402+
/**
403+
* Determines if mTLS should be enabled based on environment variables and certificate presence.
404+
*/
344405
static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) throws IOException {
345406
String useClientCert = envReader.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE");
346407

408+
// Case 1: Explicitly enabled via environment variable
347409
if ("true".equalsIgnoreCase(useClientCert)) {
348410
if (certsPresent) {
411+
// Certs are available, enable mTLS
349412
return true;
350413
}
351414
if (configExists) {
415+
// Config exists but files are missing - fail fast
352416
throw new IOException(
353417
"Certificate intent established via config, but cert files are missing.");
354418
}
419+
// Neither exist, do not enable
355420
return false;
356-
} else if ("false".equalsIgnoreCase(useClientCert)) {
421+
}
422+
// Case 2: Explicitly disabled via environment variable
423+
else if ("false".equalsIgnoreCase(useClientCert)) {
357424
if (certsPresent) {
425+
// Warn that we are ignoring present certs because it was explicitly disabled
358426
Slf4jUtils.log(
359427
LOGGER,
360428
org.slf4j.event.Level.WARN,
@@ -363,18 +431,24 @@ static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) thro
363431
return false;
364432
}
365433
return false;
366-
} else {
434+
}
435+
// Case 3: Environment variable is unset
436+
else {
367437
if (certsPresent) {
438+
// Infer mTLS is enabled because certs are present
368439
return true;
369440
}
370441
if (configExists) {
442+
// Config exists but files are missing - fail fast
371443
throw new IOException(
372-
"Certificate intent inferred via config, but cert files are missing."); // Case 7
444+
"Certificate intent inferred via config, but cert files are missing.");
373445
}
374-
return false; // Case 8
446+
// Neither cert-config nor certsexist, do not enable
447+
return false;
375448
}
376449
}
377450

451+
/** Retrieves the bound token payload (certificate chain) if applicable. */
378452
static String getBoundTokenPayload() throws IOException {
379453
CertInfo info = getAgentIdentityCertInfo();
380454
if (info != null && shouldRequestBoundToken(info.certificate)) {
@@ -384,6 +458,7 @@ static String getBoundTokenPayload() throws IOException {
384458
}
385459

386460
@SuppressWarnings("unchecked")
461+
/** Extracts the certificate path from the JSON configuration file. */
387462
private static String extractCertPathFromConfig(String certConfigPath) throws IOException {
388463
try (InputStream stream = new FileInputStream(certConfigPath)) {
389464
JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY);
@@ -400,6 +475,7 @@ private static String extractCertPathFromConfig(String certConfigPath) throws IO
400475
}
401476

402477
@SuppressWarnings("unchecked")
478+
/** Extracts the private key path from the JSON configuration file. */
403479
private static String extractKeyPathFromConfig(String certConfigPath) throws IOException {
404480
try (InputStream stream = new FileInputStream(certConfigPath)) {
405481
JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY);
@@ -415,6 +491,7 @@ private static String extractKeyPathFromConfig(String certConfigPath) throws IOE
415491
return null;
416492
}
417493

494+
/** Parses the X509 certificate from the specified path. */
418495
private static X509Certificate parseCertificate(String certPath) throws IOException {
419496
try (InputStream stream = new FileInputStream(certPath)) {
420497
CertificateFactory cf = CertificateFactory.getInstance("X.509");
@@ -425,24 +502,33 @@ private static X509Certificate parseCertificate(String certPath) throws IOExcept
425502
}
426503
}
427504

505+
/**
506+
* Determines if a bound token should be requested by checking if any of the certificate's Subject
507+
* Alternative Names (SANs) match allowed SPIFFE patterns.
508+
*/
428509
static boolean shouldRequestBoundToken(X509Certificate cert) {
429510
try {
430511
Collection<List<?>> sans = cert.getSubjectAlternativeNames();
431512
if (sans == null) {
432513
return false;
433514
}
515+
// Iterate through all Subject Alternative Names
434516
for (List<?> san : sans) {
517+
// Check if the SAN entry is a URI (type 6)
435518
if (san.size() >= 2
436519
&& san.get(0) instanceof Integer
437520
&& (Integer) san.get(0) == SAN_URI_TYPE) {
438521
Object value = san.get(1);
439522
if (value instanceof String) {
440523
String uri = (String) value;
524+
// Check if the URI starts with "spiffe://"
441525
if (uri.startsWith(SPIFFE_SCHEME_PREFIX)) {
442526
String withoutScheme = uri.substring(SPIFFE_SCHEME_PREFIX.length());
443527
int slashIndex = withoutScheme.indexOf('/');
528+
// Extract the trust domain (part before the first slash)
444529
String trustDomain =
445530
(slashIndex == -1) ? withoutScheme : withoutScheme.substring(0, slashIndex);
531+
// Match the trust domain against allowed agent patterns
446532
for (Pattern pattern : AGENT_IDENTITY_SPIFFE_PATTERNS) {
447533
if (pattern.matcher(trustDomain).matches()) {
448534
return true;
@@ -458,21 +544,8 @@ static boolean shouldRequestBoundToken(X509Certificate cert) {
458544
return false;
459545
}
460546

461-
static String calculateCertificateFingerprint(X509Certificate cert) throws IOException {
462-
try {
463-
MessageDigest md = MessageDigest.getInstance("SHA-256");
464-
byte[] der = cert.getEncoded();
465-
md.update(der);
466-
byte[] digest = md.digest();
467-
String base64Fingerprint = BaseEncoding.base64().omitPadding().encode(digest);
468-
return URLEncoder.encode(base64Fingerprint, "UTF-8");
469-
} catch (GeneralSecurityException e) {
470-
throw new IOException("Failed to calculate fingerprint for Agent Identity certificate.", e);
471-
}
472-
}
473-
474547
@VisibleForTesting
475-
static void setEnvReader(EnvReader reader) {
548+
public static void setEnvReader(EnvReader reader) {
476549
envReader = reader;
477550
}
478551

google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java

Lines changed: 1 addition & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -130,19 +130,9 @@ private X509Certificate mockCertWithSanUri(String uri) throws CertificateExcepti
130130
return mockCert;
131131
}
132132

133-
@Test
134-
public void calculateCertificateFingerprint_knownInput_returnsExpectedOutput() throws Exception {
135-
X509Certificate mockCert = mock(X509Certificate.class);
136-
byte[] fakeDer = new byte[] {0x01, 0x02, 0x03, 0x04, (byte) 0xFF};
137-
when(mockCert.getEncoded()).thenReturn(fakeDer);
138-
String expectedFingerprint = "%2FEAuXk1xSDxtU3mEowwrTIsGVTmkvRsCbGESkmulJ5M";
139-
String actualFingerprint = AgentIdentityUtils.calculateCertificateFingerprint(mockCert);
140-
assertEquals(expectedFingerprint, actualFingerprint);
141-
}
142-
143133
@Test
144134
public void getAgentIdentityCertificate_optedOut_returnsNullImmediately() throws IOException {
145-
envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false");
135+
envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true");
146136
envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", "/non/existent/path");
147137
assertNull(AgentIdentityUtils.getAgentIdentityCertInfo());
148138
}

google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ public void sleep(long millis) {
109109
}
110110
});
111111
// Opt out of bound tokens by default in tests to avoid polling delays
112-
envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false");
112+
envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true");
113113
}
114114

115115
@AfterEach
@@ -1221,7 +1221,7 @@ void getProjectId_explicitSet_noMDsCall() {
12211221

12221222
@Test
12231223
void refreshAccessToken_agentConfigMissingFile_throws() throws IOException {
1224-
envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true");
1224+
envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false");
12251225
envProvider.setEnv(
12261226
AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG,
12271227
tempDir.resolve("missing_config.json").toAbsolutePath().toString());
@@ -1250,11 +1250,13 @@ public void sleep(long millis) {
12501250
}
12511251

12521252
private void setupCertAndKeyConfig() throws IOException {
1253-
java.nio.file.Path certSource = java.nio.file.Paths.get("testresources/agent_spiffe_cert.pem");
1253+
java.nio.file.Path certSource =
1254+
java.nio.file.Paths.get("testresources/agent/agent_spiffe_cert.pem");
12541255
java.nio.file.Path certTarget = tempDir.resolve("certificates.pem");
12551256
Files.copy(certSource, certTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
12561257

1257-
java.nio.file.Path keySource = java.nio.file.Paths.get("testresources/agent_spiffe_key.pem");
1258+
java.nio.file.Path keySource =
1259+
java.nio.file.Paths.get("testresources/agent/agent_spiffe_key.pem");
12581260
java.nio.file.Path keyTarget = tempDir.resolve("private_key.pem");
12591261
Files.copy(keySource, keyTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING);
12601262

@@ -1278,7 +1280,7 @@ private void setupCertAndKeyConfig() throws IOException {
12781280
void refreshAccessToken_withValidCertAndKey_requestsBoundToken() throws IOException {
12791281
setupCertAndKeyConfig();
12801282
envProvider.setEnv(
1281-
"GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token
1283+
"GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); // Enable bound token
12821284
MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory();
12831285
transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL);
12841286
transportFactory.transport.setAccessToken("default", ACCESS_TOKEN);
@@ -1299,7 +1301,7 @@ void refreshAccessToken_withValidCertAndKey_requestsBoundToken() throws IOExcept
12991301
void idTokenWithAudience_withValidCertAndKey_requestsBoundToken() throws IOException {
13001302
setupCertAndKeyConfig();
13011303
envProvider.setEnv(
1302-
"GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token
1304+
"GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); // Enable bound token
13031305
MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory();
13041306
transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL);
13051307
transportFactory.transport.setIdToken(STANDARD_ID_TOKEN);

0 commit comments

Comments
 (0)