From 0af6032cc5c40ba26eb9adb362414bec6dfa985a Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Fri, 17 Apr 2026 16:26:18 -0700 Subject: [PATCH 01/25] Port Agent Identities feature for Cloud Run --- .../oauth2_http/EnableAutoValue.txt | 0 .../auth/oauth2/AgentIdentityUtils.java | 249 ++++++++++++++++++ .../auth/oauth2/ComputeEngineCredentials.java | 15 +- .../auth/oauth2/AgentIdentityUtilsTest.java | 180 +++++++++++++ .../oauth2/ComputeEngineCredentialsTest.java | 137 ++++++++++ .../DefaultCredentialsProviderTest.java | 14 + .../oauth2_http/testresources/agent_cert.pem | 19 ++ .../testresources/agent_spiffe_cert.pem | 20 ++ .../gradle/gradle/wrapper/gradle-wrapper.jar | Bin 59536 -> 0 bytes 9 files changed, 632 insertions(+), 2 deletions(-) delete mode 100644 google-auth-library-java/oauth2_http/EnableAutoValue.txt create mode 100644 google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java create mode 100644 google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java create mode 100644 google-auth-library-java/oauth2_http/testresources/agent_cert.pem create mode 100644 google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem delete mode 100644 rules_java_gapic/resources/gradle/gradle/wrapper/gradle-wrapper.jar diff --git a/google-auth-library-java/oauth2_http/EnableAutoValue.txt b/google-auth-library-java/oauth2_http/EnableAutoValue.txt deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java new file mode 100644 index 000000000000..b66ef8a2679a --- /dev/null +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -0,0 +1,249 @@ +package com.google.auth.oauth2; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonObjectParser; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.io.BaseEncoding; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.cert.CertificateFactory; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility class for Agent Identity token binding in Cloud Run. + */ +class AgentIdentityUtils { + + private static final Logger LOGGER = LoggerFactory.getLogger(AgentIdentityUtils.class); + + // Environment variables + static final String GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG"; + static final String GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES = + "GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES"; + + private static final List AGENT_IDENTITY_SPIFFE_PATTERNS = + ImmutableList.of( + Pattern.compile("^agents\\.global\\.org-\\d+\\.system\\.id\\.goog$"), + Pattern.compile("^agents\\.global\\.proj-\\d+\\.system\\.id\\.goog$")); + + private static final int SAN_URI_TYPE = 6; + private static final String SPIFFE_SCHEME_PREFIX = "spiffe://"; + + // Polling configuration + private static final int FAST_POLL_CYCLES = 50; + private static final long FAST_POLL_INTERVAL_MS = 100; // 0.1 seconds + private static final long SLOW_POLL_INTERVAL_MS = 500; // 0.5 seconds + private static final long TOTAL_TIMEOUT_MS = 30000; // 30 seconds + private static final List POLLING_INTERVALS; + + static { + List intervals = new ArrayList<>(); + for (int i = 0; i < FAST_POLL_CYCLES; i++) { + intervals.add(FAST_POLL_INTERVAL_MS); + } + long remainingTime = TOTAL_TIMEOUT_MS - (FAST_POLL_CYCLES * FAST_POLL_INTERVAL_MS); + int slowPollCycles = (int) (remainingTime / SLOW_POLL_INTERVAL_MS); + for (int i = 0; i < slowPollCycles; i++) { + intervals.add(SLOW_POLL_INTERVAL_MS); + } + POLLING_INTERVALS = Collections.unmodifiableList(intervals); + } + + interface EnvReader { + String getEnv(String name); + } + + private static EnvReader envReader = System::getenv; + + @VisibleForTesting + interface TimeService { + long currentTimeMillis(); + void sleep(long millis) throws InterruptedException; + } + + private static TimeService timeService = + new TimeService() { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + + @Override + public void sleep(long millis) throws InterruptedException { + Thread.sleep(millis); + } + }; + + private AgentIdentityUtils() {} + + static X509Certificate getAgentIdentityCertificate() throws IOException { + if (isOptedOut()) { + return null; + } + String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG); + if (Strings.isNullOrEmpty(certConfigPath)) { + return null; + } + String certPath = getCertificatePathWithRetry(certConfigPath); + return parseCertificate(certPath); + } + + private static boolean isOptedOut() { + String optOut = envReader.getEnv(GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES); + return "false".equalsIgnoreCase(optOut); + } + + private static String getCertificatePathWithRetry(String certConfigPath) throws IOException { + boolean warned = false; + for (long sleepInterval : POLLING_INTERVALS) { + try { + if (Files.exists(Paths.get(certConfigPath))) { + String certPath = extractCertPathFromConfig(certConfigPath); + if (!Strings.isNullOrEmpty(certPath) && Files.exists(Paths.get(certPath))) { + return certPath; + } + } + } catch (IOException e) { + // Fall through to retry + } + if (!warned) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + String.format( + "Certificate config file not found at %s (from %s environment variable). Retrying for up to %d seconds.", + certConfigPath, GOOGLE_API_CERTIFICATE_CONFIG, TOTAL_TIMEOUT_MS / 1000)); + warned = true; + } + try { + timeService.sleep(sleepInterval); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException( + "Interrupted while waiting for Agent Identity certificate files for bound token request.", + e); + } + } + throw new IOException( + "Unable to find Agent Identity certificate config or file for bound token request after multiple retries. Token binding protection is failing. You can turn off this protection by setting " + + GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES + + " to false to fall back to unbound tokens."); + } + + @SuppressWarnings("unchecked") + private static String extractCertPathFromConfig(String certConfigPath) throws IOException { + try (InputStream stream = new FileInputStream(certConfigPath)) { + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson config = parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class); + Map certConfigs = (Map) config.get("cert_configs"); + if (certConfigs != null) { + Map workload = (Map) certConfigs.get("workload"); + if (workload != null) { + return (String) workload.get("cert_path"); + } + } + } + return null; + } + + private static X509Certificate parseCertificate(String certPath) throws IOException { + try (InputStream stream = new FileInputStream(certPath)) { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate) cf.generateCertificate(stream); + } catch (GeneralSecurityException e) { + throw new IOException( + "Failed to parse Agent Identity certificate for bound token request.", e); + } + } + + static boolean shouldRequestBoundToken(X509Certificate cert) { + try { + Collection> sans = cert.getSubjectAlternativeNames(); + if (sans == null) { + return false; + } + for (List san : sans) { + if (san.size() >= 2 + && san.get(0) instanceof Integer + && (Integer) san.get(0) == SAN_URI_TYPE) { + Object value = san.get(1); + if (value instanceof String) { + String uri = (String) value; + if (uri.startsWith(SPIFFE_SCHEME_PREFIX)) { + String withoutScheme = uri.substring(SPIFFE_SCHEME_PREFIX.length()); + int slashIndex = withoutScheme.indexOf('/'); + String trustDomain = + (slashIndex == -1) ? withoutScheme : withoutScheme.substring(0, slashIndex); + for (Pattern pattern : AGENT_IDENTITY_SPIFFE_PATTERNS) { + if (pattern.matcher(trustDomain).matches()) { + return true; + } + } + } + } + } + } + } catch (CertificateParsingException e) { + LOGGER.warn("Failed to parse Subject Alternative Names from certificate", e); + } + return false; + } + + static String calculateCertificateFingerprint(X509Certificate cert) throws IOException { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] der = cert.getEncoded(); + md.update(der); + byte[] digest = md.digest(); + String base64Fingerprint = BaseEncoding.base64().omitPadding().encode(digest); + return URLEncoder.encode(base64Fingerprint, "UTF-8"); + } catch (GeneralSecurityException e) { + throw new IOException("Failed to calculate fingerprint for Agent Identity certificate.", e); + } + } + + @VisibleForTesting + static void setEnvReader(EnvReader reader) { + envReader = reader; + } + + @VisibleForTesting + static void setTimeService(TimeService service) { + timeService = service; + } + + @VisibleForTesting + static void resetTimeService() { + timeService = + new TimeService() { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + + @Override + public void sleep(long millis) throws InterruptedException { + Thread.sleep(millis); + } + }; + } +} diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java index d1933126d629..553eb7da0cd5 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java @@ -61,6 +61,7 @@ import java.io.ObjectInputStream; import java.net.SocketTimeoutException; import java.net.UnknownHostException; +import java.security.cert.X509Certificate; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; @@ -421,8 +422,18 @@ private String getProjectIdFromMetadata() { /** Refresh the access token by getting it from the GCE metadata server */ @Override public AccessToken refreshAccessToken() throws IOException { - HttpResponse response = - getMetadataResponse(createTokenUrlWithScopes(), RequestType.ACCESS_TOKEN_REQUEST, true); + String tokenUrl = createTokenUrlWithScopes(); + + // Checks whether access token has to be bound to certificate for agent identity. + X509Certificate cert = AgentIdentityUtils.getAgentIdentityCertificate(); + if (cert != null && AgentIdentityUtils.shouldRequestBoundToken(cert)) { + String fingerprint = AgentIdentityUtils.calculateCertificateFingerprint(cert); + GenericUrl url = new GenericUrl(tokenUrl); + url.set("bindCertificateFingerprint", fingerprint); + tokenUrl = url.build(); + } + + HttpResponse response = getMetadataResponse(tokenUrl, RequestType.ACCESS_TOKEN_REQUEST, true); int statusCode = response.getStatusCode(); if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { throw new IOException( diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java new file mode 100644 index 000000000000..2c9ec9879f57 --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java @@ -0,0 +1,180 @@ +package com.google.auth.oauth2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class AgentIdentityUtilsTest { + + private static final String VALID_SPIFFE_ORG = + "spiffe://agents.global.org-12345.system.id.goog/path/to/resource"; + private static final String VALID_SPIFFE_PROJ = + "spiffe://agents.global.proj-98765.system.id.goog/another/path"; + private static final String INVALID_SPIFFE_DOMAIN = "spiffe://example.com/workload"; + private static final String INVALID_SPIFFE_FORMAT = + "spiffe://agents.global.org-INVALID.system.id.goog/path"; + + private TestEnvironmentProvider envProvider; + private Path tempDir; + + @BeforeEach + void setUp() throws IOException { + envProvider = new TestEnvironmentProvider(); + AgentIdentityUtils.setEnvReader(envProvider::getEnv); + tempDir = Files.createTempDirectory("agent_identity_test"); + } + + @AfterEach + void tearDown() throws IOException { + AgentIdentityUtils.resetTimeService(); + if (tempDir != null) { + Files.walk(tempDir) + .sorted(java.util.Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + } + + @Test + public void shouldRequestBoundToken_validOrgSpiffe_returnsTrue() throws CertificateException { + assertTrue(AgentIdentityUtils.shouldRequestBoundToken(mockCertWithSanUri(VALID_SPIFFE_ORG))); + } + + @Test + public void shouldRequestBoundToken_validProjSpiffe_returnsTrue() throws CertificateException { + assertTrue(AgentIdentityUtils.shouldRequestBoundToken(mockCertWithSanUri(VALID_SPIFFE_PROJ))); + } + + @Test + public void shouldRequestBoundToken_invalidDomain_returnsFalse() throws CertificateException { + assertFalse( + AgentIdentityUtils.shouldRequestBoundToken(mockCertWithSanUri(INVALID_SPIFFE_DOMAIN))); + } + + @Test + public void shouldRequestBoundToken_invalidFormat_returnsFalse() throws CertificateException { + assertFalse( + AgentIdentityUtils.shouldRequestBoundToken(mockCertWithSanUri(INVALID_SPIFFE_FORMAT))); + } + + @Test + public void shouldRequestBoundToken_noSan_returnsFalse() throws CertificateException { + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getSubjectAlternativeNames()).thenReturn(null); + assertFalse(AgentIdentityUtils.shouldRequestBoundToken(mockCert)); + } + + private X509Certificate mockCertWithSanUri(String uri) throws CertificateException { + X509Certificate mockCert = mock(X509Certificate.class); + List spiffeEntry = Arrays.asList(6, uri); + Collection> sans = Collections.singletonList(spiffeEntry); + when(mockCert.getSubjectAlternativeNames()).thenReturn(sans); + return mockCert; + } + + @Test + public void calculateCertificateFingerprint_knownInput_returnsExpectedOutput() throws Exception { + X509Certificate mockCert = mock(X509Certificate.class); + byte[] fakeDer = new byte[] {0x01, 0x02, 0x03, 0x04, (byte) 0xFF}; + when(mockCert.getEncoded()).thenReturn(fakeDer); + String expectedFingerprint = "%2FEAuXk1xSDxtU3mEowwrTIsGVTmkvRsCbGESkmulJ5M"; + String actualFingerprint = AgentIdentityUtils.calculateCertificateFingerprint(mockCert); + assertEquals(expectedFingerprint, actualFingerprint); + } + + @Test + public void getAgentIdentityCertificate_optedOut_returnsNullImmediately() throws IOException { + envProvider.setEnv("GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", "/non/existent/path"); + assertNull(AgentIdentityUtils.getAgentIdentityCertificate()); + } + + @Test + public void getAgentIdentityCertificate_noConfigEnvVar_returnsNull() throws IOException { + assertNull(AgentIdentityUtils.getAgentIdentityCertificate()); + } + + @Test + public void getAgentIdentityCertificate_happyPath_loadsCertificate() throws IOException { + URL certUrl = getClass().getClassLoader().getResource("x509_leaf_certificate.pem"); + assertNotNull(certUrl, "Test resource x509_leaf_certificate.pem not found"); + String certPath = new File(certUrl.getFile()).getAbsolutePath(); + File configFile = tempDir.resolve("config.json").toFile(); + String configJson = + "{" + + " \"cert_configs\": {" + + " \"workload\": {" + + " \"cert_path\": \"" + + certPath.replace("\\", "\\\\") + + "\"" + + " }" + + " }" + + "}"; + try (FileOutputStream fos = new FileOutputStream(configFile)) { + fos.write(configJson.getBytes(StandardCharsets.UTF_8)); + } + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath()); + X509Certificate cert = AgentIdentityUtils.getAgentIdentityCertificate(); + assertNotNull(cert); + assertTrue(cert.getIssuerDN().getName().contains("unit-tests")); + } + + @Test + public void getAgentIdentityCertificate_timeout_throwsIOException() { + envProvider.setEnv( + "GOOGLE_API_CERTIFICATE_CONFIG", + tempDir.resolve("missing.json").toAbsolutePath().toString()); + AgentIdentityUtils.setTimeService(new FakeTimeService()); + IOException e = + assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertificate); + assertTrue( + e.getMessage() + .contains( + "Unable to find Agent Identity certificate config or file for bound token request after multiple retries.")); + } + + private static class FakeTimeService implements AgentIdentityUtils.TimeService { + private final AtomicLong currentTime = new AtomicLong(0); + @Override + public long currentTimeMillis() { + return currentTime.get(); + } + @Override + public void sleep(long millis) throws InterruptedException { + currentTime.addAndGet(millis); + } + } + + private static class TestEnvironmentProvider { + private final java.util.Map env = new java.util.HashMap<>(); + void setEnv(String key, String value) { + env.put(key, value); + } + String getEnv(String key) { + return env.get(key); + } + } +} diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 17184032e94a..640434dc1e2f 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -65,9 +65,15 @@ import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayDeque; import java.util.Arrays; import java.util.Collection; +import java.util.HashMap; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import java.util.Collections; import java.util.List; import java.util.Map; @@ -82,6 +88,24 @@ class ComputeEngineCredentialsTest extends BaseSerializationTest { private static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo"); + private TestEnvironmentProvider envProvider; + private Path tempDir; + + @BeforeEach + void setUp() throws IOException { + envProvider = new TestEnvironmentProvider(); + // Inject our test environment reader into AgentIdentityUtils + AgentIdentityUtils.setEnvReader(envProvider::getEnv); + tempDir = Files.createTempDirectory("compute_engine_creds_test"); + } + + @AfterEach + void tearDown() { + // Reset the mocks + AgentIdentityUtils.resetTimeService(); + AgentIdentityUtils.setEnvReader(System::getenv); + } + private static final String TOKEN_URL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token"; @@ -1234,6 +1258,119 @@ InputStream readStream(File file) throws FileNotFoundException { boolean isOnGce = ComputeEngineCredentials.isOnGce(transportFactory, provider); assertTrue(isOnGce); assertEquals(1, transportFactory.transport.getRequestCount()); + + void refreshAccessToken_noAgentConfig_requestsNormalToken() throws IOException { + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + AccessToken token = credentials.refreshAccessToken(); + assertNotNull(token); + assertEquals(ACCESS_TOKEN, token.getTokenValue()); + String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); + assertFalse(requestUrl.contains("bindCertificateFingerprint")); + } + + @Test + void refreshAccessToken_withStandardCert_requestsNormalToken() throws IOException { + setupCertConfig("x509_leaf_certificate.pem"); + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + AccessToken token = credentials.refreshAccessToken(); + assertNotNull(token); + assertEquals(ACCESS_TOKEN, token.getTokenValue()); + String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); + assertFalse(requestUrl.contains("bindCertificateFingerprint")); + } + + @Test + void refreshAccessToken_withAgentCert_requestsBoundToken() throws IOException { + setupCertConfig("agent_spiffe_cert.pem"); + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + AccessToken token = credentials.refreshAccessToken(); + assertNotNull(token); + assertEquals(ACCESS_TOKEN, token.getTokenValue()); + String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); + assertTrue(requestUrl.contains("bindCertificateFingerprint")); + } + + @Test + void refreshAccessToken_withAgentCert_optedOut_requestsNormalToken() throws IOException { + setupCertConfig("agent_spiffe_cert.pem"); + envProvider.setEnv("GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + AccessToken token = credentials.refreshAccessToken(); + assertNotNull(token); + assertEquals(ACCESS_TOKEN, token.getTokenValue()); + String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); + assertFalse(requestUrl.contains("bindCertificateFingerprint")); + } + + @Test + void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { + envProvider.setEnv( + AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, + tempDir.resolve("missing_config.json").toAbsolutePath().toString()); + final AtomicLong currentTime = new AtomicLong(0); + AgentIdentityUtils.setTimeService( + new AgentIdentityUtils.TimeService() { + @Override + public long currentTimeMillis() { + return currentTime.get(); + } + @Override + public void sleep(long millis) { + currentTime.addAndGet(millis); + } + }); + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + IOException e = assertThrows(IOException.class, credentials::refreshAccessToken); + assertTrue( + e.getMessage() + .contains( + "Unable to find Agent Identity certificate config or file for bound token request after multiple retries.")); + } + + private void setupCertConfig(String certResourceName) throws IOException { + Path certPath = tempDir.resolve("cert.pem"); + try (InputStream certStream = + getClass().getClassLoader().getResourceAsStream(certResourceName)) { + assertNotNull(certStream, "Test resource " + certResourceName + " not found"); + Files.copy(certStream, certPath); + } + Path configPath = tempDir.resolve("config.json"); + String configContent = + "{\"cert_configs\": {\"workload\": {\"cert_path\": \"" + + certPath.toAbsolutePath().toString().replace("\\", "\\\\") + + "\"}}}"; + Files.write(configPath, configContent.getBytes(StandardCharsets.UTF_8)); + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configPath.toAbsolutePath().toString()); + } + + private static class TestEnvironmentProvider { + private final Map env = new HashMap<>(); + void setEnv(String key, String value) { + env.put(key, value); + } + String getEnv(String key) { + return env.get(key); + } } static class MockMetadataServerTransportFactory implements HttpTransportFactory { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java index dbfb70ea0038..d641f9b18a3d 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java @@ -67,11 +67,25 @@ import java.util.logging.Level; import java.util.logging.LogRecord; import java.util.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; /** Test case for {@link DefaultCredentialsProvider}. */ class DefaultCredentialsProviderTest { + @BeforeEach + void setUp() { + // Isolate tests from user's GOOGLE_API_CERTIFICATE_CONFIG environment variable. + AgentIdentityUtils.setEnvReader(name -> null); + } + + @AfterEach + void tearDown() { + // Reset to default behavior. + AgentIdentityUtils.setEnvReader(System::getenv); + } + private static final String USER_CLIENT_SECRET = "jakuaL9YyieakhECKL2SwZcu"; private static final String USER_CLIENT_ID = "ya29.1.AADtN_UtlxN3PuGAxrN2XQnZTVRvDyVWnYq4I6dws"; private static final String GCLOUDSDK_CLIENT_ID = diff --git a/google-auth-library-java/oauth2_http/testresources/agent_cert.pem b/google-auth-library-java/oauth2_http/testresources/agent_cert.pem new file mode 100644 index 000000000000..7af6ca3f9314 --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/agent_cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDIzCCAgugAwIBAgIJAMfISuBQ5m+5MA0GCSqGSIb3DQEBBQUAMBUxEzARBgNV +BAMTCnVuaXQtdGVzdHMwHhcNMTExMjA2MTYyNjAyWhcNMjExMjAzMTYyNjAyWjAV +MRMwEQYDVQQDEwp1bml0LXRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZgkdmM +7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU1Wer +uQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYsSliS5qQp +gyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18pe+zpyl4 ++WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xkSBc//fy3 +ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABo3YwdDAdBgNVHQ4EFgQU2RQ8yO+O +gN8oVW2SW7RLrfYd9jEwRQYDVR0jBD4wPIAU2RQ8yO+OgN8oVW2SW7RLrfYd9jGh +GaQXMBUxEzARBgNVBAMTCnVuaXQtdGVzdHOCCQDHyErgUOZvuTAMBgNVHRMEBTAD +AQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBRv+M/6+FiVu7KXNjFI5pSN17OcW5QUtPr +odJMlWrJBtynn/TA1oJlYu3yV5clc/71Vr/AxuX5xGP+IXL32YDF9lTUJXG/uUGk ++JETpKmQviPbRsvzYhz4pf6ZIOZMc3/GIcNq92ECbseGO+yAgyWUVKMmZM0HqXC9 +ovNslqe0M8C1sLm1zAR5z/h/litE7/8O2ietija3Q/qtl2TOXJdCA6sgjJX2WUql +ybrC55ct18NKf3qhpcEkGQvFU40rVYApJpi98DiZPYFdx1oBDp/f4uZ3ojpxRVFT +cDwcJLfNRCPUhormsY7fDS9xSyThiHsW9mjJYdcaKQkwYZ0F11yB +-----END CERTIFICATE----- diff --git a/google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem b/google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem new file mode 100644 index 000000000000..d0a0ed1a883d --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDPjCCAiagAwIBAgIUCYeV4dwM29T5yucwWrSWlOC9wwYwDQYJKoZIhvcNAQEL +BQAwIjEgMB4GA1UEAwwXVGVzdCBTUElGRkUgQ2VydGlmaWNhdGUwHhcNMjUxMTA3 +MDEyMjQ4WhcNMzUxMTA1MDEyMjQ4WjAiMSAwHgYDVQQDDBdUZXN0IFNQSUZGRSBD +ZXJ0aWZpY2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDr1Bzo +KtzIZB35acQ+mpk6yScf59AnwHjjgNCMbC7kq2DSUfQzTlu9Kd0uUB6O7DmJ73D8 +Pge4XLE/Q1B6dI6DzJx7lhPoC1BiQFUGJ4Cu+TbbdlK3RiXNAZYjIj9UKP7DejCY +WRgFB+PYyLczEkByvU9cy7Z9Uuufsn6LnYu7qOG+DcRSE41ThurZxQ14OWvLfjZm +lhZXam4VBBli8Qku8qFIALe78kpy+hp2YCRnK84amATwPpGprRACp9WVka2JDYKD +LY0OoYlyAQel6960aS11N3/2v0cvx03/LM5+Yj+DTvdyb2Mk/NVeRIKo8cM5YwPn +sTLCf1cdxJvseRMCAwEAAaNsMGowSQYDVR0RBEIwQIY+c3BpZmZlOi8vYWdlbnRz +Lmdsb2JhbC5wcm9qLTEyMzQ1LnN5c3RlbS5pZC5nb29nL3Rlc3Qtd29ya2xvYWQw +HQYDVR0OBBYEFPvn+KXBcrYCmAMopkghUczUx/IkMA0GCSqGSIb3DQEBCwUAA4IB +AQCbwd9RMFkr1C9AEgnLMWd1l9ciBbK0t1Sydu3eA0SNm2w6E58ih8O+huo6eGsM +7z0E4i7YuaHnTdah/lPMqd75YRO57GSRbvi2g+yPyw6XdFl9HCHwF4WARdTF4Nkf +1c1WstvBXb24PSSQQdy9un72ZG6f9fSVQrko6hchd8Rg6yyBTFE8APPkeMR/EJtV +cnXg4CgsQIPHxJGQrhNvQhF7VLZePlTass4bqTqTYXwAte2jX/KW3qlW/t/v4AJe +/q+pcXmNIvwRpT8zYA5tJHIDVJ+v9pWZA+nhoD9Qtr7FVHfB4mdNuFv7bMPoXN0+ +mCPzP08MnjgbX7zRETVlblrx +-----END CERTIFICATE----- diff --git a/rules_java_gapic/resources/gradle/gradle/wrapper/gradle-wrapper.jar b/rules_java_gapic/resources/gradle/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 7454180f2ae8848c63b8b4dea2cb829da983f2fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 59536 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 QuEJmfE|#`Dqx|px0L@K;Y5)KL From 9d5672f9b9bb50cea76f08fda3a2bdcec89c9483 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Thu, 7 May 2026 17:47:04 -0700 Subject: [PATCH 02/25] Added support for: 1. POST request to MDS with cert-chain 2. Cert-key matching 3. Included logic to consider the user's choice by looking at GOOGLE_API_USE_CLIENT_CERTIFICATE env variable 4. Bound ID tokens. # Conflicts: # google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java # google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java --- .../auth/oauth2/AgentIdentityUtils.java | 166 +++++++++++++++++- .../auth/oauth2/ComputeEngineCredentials.java | 55 ++++-- .../auth/oauth2/AgentIdentityUtilsTest.java | 84 ++++++++- .../oauth2/ComputeEngineCredentialsTest.java | 136 +++++++------- .../DefaultCredentialsProviderTest.java | 9 +- .../oauth2/MockMetadataServerTransport.java | 6 + 6 files changed, 356 insertions(+), 100 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index b66ef8a2679a..9e52d57b7423 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -16,6 +16,8 @@ import java.security.GeneralSecurityException; import java.security.MessageDigest; import java.security.cert.CertificateFactory; +import java.security.PrivateKey; +import java.security.Signature; import java.security.cert.CertificateParsingException; import java.security.cert.X509Certificate; import java.util.ArrayList; @@ -36,8 +38,8 @@ class AgentIdentityUtils { // Environment variables static final String GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG"; - static final String GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES = - "GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES"; + static final String GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES = + "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES"; private static final List AGENT_IDENTITY_SPIFFE_PATTERNS = ImmutableList.of( @@ -47,6 +49,13 @@ class AgentIdentityUtils { private static final int SAN_URI_TYPE = 6; private static final String SPIFFE_SCHEME_PREFIX = "spiffe://"; + private static String wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/"; + + @VisibleForTesting + static void setWellKnownDir(String dir) { + wellKnownDir = dir; + } + // Polling configuration private static final int FAST_POLL_CYCLES = 50; private static final long FAST_POLL_INTERVAL_MS = 100; // 0.1 seconds @@ -94,20 +103,41 @@ public void sleep(long millis) throws InterruptedException { private AgentIdentityUtils() {} - static X509Certificate getAgentIdentityCertificate() throws IOException { + static class CertInfo { + final X509Certificate certificate; + final String path; + CertInfo(X509Certificate certificate, String path) { + this.certificate = certificate; + this.path = path; + } + } + + static CertInfo getAgentIdentityCertInfo() throws IOException { if (isOptedOut()) { return null; } String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG); - if (Strings.isNullOrEmpty(certConfigPath)) { + + boolean configExists = !Strings.isNullOrEmpty(certConfigPath) && Files.exists(Paths.get(certConfigPath)); + String certPath; + if (!Strings.isNullOrEmpty(certConfigPath)) { + certPath = getCertificatePathWithRetry(certConfigPath); + } else { + certPath = getWellKnownCertificatePathWithRetry(); + } + + boolean certsPresent = !Strings.isNullOrEmpty(certPath); + + if (!shouldEnableMtls(certsPresent, configExists)) { return null; } - String certPath = getCertificatePathWithRetry(certConfigPath); - return parseCertificate(certPath); + + X509Certificate cert = parseCertificate(certPath); + return new CertInfo(cert, certPath); } private static boolean isOptedOut() { - String optOut = envReader.getEnv(GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES); + String optOut = envReader.getEnv(GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES); return "false".equalsIgnoreCase(optOut); } @@ -145,10 +175,130 @@ private static String getCertificatePathWithRetry(String certConfigPath) throws } throw new IOException( "Unable to find Agent Identity certificate config or file for bound token request after multiple retries. Token binding protection is failing. You can turn off this protection by setting " - + GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES + + GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES + " to false to fall back to unbound tokens."); } + private static String getWellKnownCertificatePathWithRetry() throws IOException { + String bundlePath = wellKnownDir + "credentialbundle.pem"; + String certOnlyPath = wellKnownDir + "certificates.pem"; + + boolean warned = false; + for (long sleepInterval : POLLING_INTERVALS) { + try { + if (Files.exists(Paths.get(bundlePath))) { + return bundlePath; + } + if (Files.exists(Paths.get(certOnlyPath))) { + return certOnlyPath; + } + } catch (Exception e) { + // Fall through to retry + } + if (!warned) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + String.format( + "Well-known certificate file not found at %s. Retrying for up to %d seconds.", + wellKnownDir, TOTAL_TIMEOUT_MS / 1000)); + warned = true; + } + try { + timeService.sleep(sleepInterval); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException( + "Interrupted while waiting for well-known certificate files.", + e); + } + } + throw new IOException( + "Unable to find well-known certificate file for bound token request after multiple retries."); + } + + static String readCertificateChain(String certPath) throws IOException { + System.out.println("AgentIdentityUtils: Reading certificate chain from: " + certPath); + return new String(Files.readAllBytes(Paths.get(certPath)), StandardCharsets.UTF_8); + } + + static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { + try { + byte[] data = "verification-data".getBytes(StandardCharsets.UTF_8); + + String keyAlgorithm = cert.getPublicKey().getAlgorithm(); + String sigAlg; + if ("RSA".equals(keyAlgorithm)) { + sigAlg = "SHA256withRSA"; + } else if ("EC".equals(keyAlgorithm)) { + sigAlg = "SHA256withECDSA"; + } else { + throw new IllegalArgumentException("Unsupported key algorithm: " + keyAlgorithm); + } + + Signature signer = Signature.getInstance(sigAlg); + signer.initSign(privateKey); + signer.update(data); + byte[] signature = signer.sign(); + + Signature verifier = Signature.getInstance(sigAlg); + verifier.initVerify(cert.getPublicKey()); + verifier.update(data); + + return verifier.verify(signature); + } catch (Exception e) { + System.out.println("AgentIdentityUtils: Key pair verification failed: " + e.getMessage()); + return false; + } + } + + static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOException { + String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); + OAuth2Utils.Pkcs8Algorithm pkcs8Alg = "EC".equals(algorithm) ? OAuth2Utils.Pkcs8Algorithm.EC : OAuth2Utils.Pkcs8Algorithm.RSA; + return OAuth2Utils.privateKeyFromPkcs8(keyPem, pkcs8Alg); + } + + static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) throws IOException { + String useClientCert = envReader.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); + + if ("true".equalsIgnoreCase(useClientCert)) { + if (certsPresent) { + return true; // Case 1 + } + if (configExists) { + throw new IOException("Certificate intent established via config, but cert files are missing."); // Case 2 + } + return false; // Case 3 + } else if ("false".equalsIgnoreCase(useClientCert)) { + if (certsPresent) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Token binding protection is disabled because mTLS was explicitly disabled via GOOGLE_API_USE_CLIENT_CERTIFICATE."); + return false; // Case 4 + } + return false; // Case 5 + } else { // Unset + if (certsPresent) { + return true; // Case 6 (Infer enabled) + } + if (configExists) { + throw new IOException("Certificate intent inferred via config, but cert files are missing."); // Case 7 + } + return false; // Case 8 + } + } + + static String getBoundTokenPayload() throws IOException { + CertInfo info = getAgentIdentityCertInfo(); + if (info != null && shouldRequestBoundToken(info.certificate)) { + return readCertificateChain(info.path); + } + return null; + } + @SuppressWarnings("unchecked") private static String extractCertPathFromConfig(String certConfigPath) throws IOException { try (InputStream stream = new FileInputStream(certConfigPath)) { diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java index 553eb7da0cd5..b8b64edde86e 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java @@ -61,6 +61,7 @@ import java.io.ObjectInputStream; import java.net.SocketTimeoutException; import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; import java.security.cert.X509Certificate; import java.time.Duration; import java.util.ArrayList; @@ -321,7 +322,7 @@ public String getUniverseDomain() throws IOException { private String getUniverseDomainFromMetadata() throws IOException { HttpResponse response = - getMetadataResponse(getUniverseDomainUrl(), RequestType.UNTRACKED, false); + getMetadataResponse(getUniverseDomainUrl(), "GET", null, RequestType.UNTRACKED, false); int statusCode = response.getStatusCode(); if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { return Credentials.GOOGLE_DEFAULT_UNIVERSE; @@ -380,7 +381,7 @@ public String getProjectId() { private String getProjectIdFromMetadata() { try { - HttpResponse response = getMetadataResponse(getProjectIdUrl(), RequestType.UNTRACKED, false); + HttpResponse response = getMetadataResponse(getProjectIdUrl(), "GET", null, RequestType.UNTRACKED, false); int statusCode = response.getStatusCode(); if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { LoggingUtils.log( @@ -424,16 +425,17 @@ private String getProjectIdFromMetadata() { public AccessToken refreshAccessToken() throws IOException { String tokenUrl = createTokenUrlWithScopes(); - // Checks whether access token has to be bound to certificate for agent identity. - X509Certificate cert = AgentIdentityUtils.getAgentIdentityCertificate(); - if (cert != null && AgentIdentityUtils.shouldRequestBoundToken(cert)) { - String fingerprint = AgentIdentityUtils.calculateCertificateFingerprint(cert); - GenericUrl url = new GenericUrl(tokenUrl); - url.set("bindCertificateFingerprint", fingerprint); - tokenUrl = url.build(); + String boundTokenPayload = AgentIdentityUtils.getBoundTokenPayload(); + HttpResponse response; + + if (boundTokenPayload != null) { + String escapedChain = boundTokenPayload.replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + String jsonString = "{\"certificate_chain\":\"" + escapedChain + "\"}"; + + response = getMetadataResponse(tokenUrl, "POST", jsonString, RequestType.ACCESS_TOKEN_REQUEST, true); + } else { + response = getMetadataResponse(tokenUrl, "GET", null, RequestType.ACCESS_TOKEN_REQUEST, true); } - - HttpResponse response = getMetadataResponse(tokenUrl, RequestType.ACCESS_TOKEN_REQUEST, true); int statusCode = response.getStatusCode(); if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { throw new IOException( @@ -501,8 +503,19 @@ public IdToken idTokenWithAudience(String targetAudience, List AgentIdentityUtils.shouldEnableMtls(false, true)); + } + + @Test + public void shouldEnableMtls_false_certsPresent_returnsFalse() throws IOException { + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false"); + assertFalse(AgentIdentityUtils.shouldEnableMtls(true, true)); + } + + @Test + public void shouldEnableMtls_unset_certsPresent_returnsTrue() throws IOException { + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", null); + assertTrue(AgentIdentityUtils.shouldEnableMtls(true, true)); + } + + @Test + public void getAgentIdentityCertInfo_fallbackPath_loadsCertificate() throws IOException { + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + + URL certUrl = getClass().getClassLoader().getResource("x509_leaf_certificate.pem"); + assertNotNull(certUrl, "Test resource x509_leaf_certificate.pem not found"); + String certPath = new File(certUrl.getFile()).getAbsolutePath(); + + Files.copy(Paths.get(certPath), tempDir.resolve("certificates.pem")); + + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); + + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(info); + assertEquals(tempDir.resolve("certificates.pem").toAbsolutePath().toString(), info.path); + } + + @Test + public void verifyKeyPair_match_returnsTrue() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getPublicKey()).thenReturn(kp.getPublic()); + + assertTrue(AgentIdentityUtils.verifyKeyPair(mockCert, kp.getPrivate())); + } + + @Test + public void verifyKeyPair_mismatch_returnsFalse() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp1 = kpg.generateKeyPair(); + KeyPair kp2 = kpg.generateKeyPair(); + + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getPublicKey()).thenReturn(kp1.getPublic()); + + assertFalse(AgentIdentityUtils.verifyKeyPair(mockCert, kp2.getPrivate())); + } + private static class FakeTimeService implements AgentIdentityUtils.TimeService { private final AtomicLong currentTime = new AtomicLong(0); @Override diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 640434dc1e2f..3e6a8b5cf2f3 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -97,6 +97,22 @@ void setUp() throws IOException { // Inject our test environment reader into AgentIdentityUtils AgentIdentityUtils.setEnvReader(envProvider::getEnv); tempDir = Files.createTempDirectory("compute_engine_creds_test"); + + // Speed up polling in tests by using a fake time service that advances time immediately + final AtomicLong currentTime = new AtomicLong(0); + AgentIdentityUtils.setTimeService( + new AgentIdentityUtils.TimeService() { + @Override + public long currentTimeMillis() { + return currentTime.get(); + } + @Override + public void sleep(long millis) { + currentTime.addAndGet(millis); + } + }); + // Opt out of bound tokens by default in tests to avoid polling delays + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); } @AfterEach @@ -1258,69 +1274,12 @@ InputStream readStream(File file) throws FileNotFoundException { boolean isOnGce = ComputeEngineCredentials.isOnGce(transportFactory, provider); assertTrue(isOnGce); assertEquals(1, transportFactory.transport.getRequestCount()); - - void refreshAccessToken_noAgentConfig_requestsNormalToken() throws IOException { - envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); - MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); - transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); - transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); - ComputeEngineCredentials credentials = - ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); - AccessToken token = credentials.refreshAccessToken(); - assertNotNull(token); - assertEquals(ACCESS_TOKEN, token.getTokenValue()); - String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); - assertFalse(requestUrl.contains("bindCertificateFingerprint")); } - @Test - void refreshAccessToken_withStandardCert_requestsNormalToken() throws IOException { - setupCertConfig("x509_leaf_certificate.pem"); - MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); - transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); - transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); - ComputeEngineCredentials credentials = - ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); - AccessToken token = credentials.refreshAccessToken(); - assertNotNull(token); - assertEquals(ACCESS_TOKEN, token.getTokenValue()); - String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); - assertFalse(requestUrl.contains("bindCertificateFingerprint")); - } - - @Test - void refreshAccessToken_withAgentCert_requestsBoundToken() throws IOException { - setupCertConfig("agent_spiffe_cert.pem"); - MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); - transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); - transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); - ComputeEngineCredentials credentials = - ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); - AccessToken token = credentials.refreshAccessToken(); - assertNotNull(token); - assertEquals(ACCESS_TOKEN, token.getTokenValue()); - String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); - assertTrue(requestUrl.contains("bindCertificateFingerprint")); - } - - @Test - void refreshAccessToken_withAgentCert_optedOut_requestsNormalToken() throws IOException { - setupCertConfig("agent_spiffe_cert.pem"); - envProvider.setEnv("GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); - MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); - transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); - transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); - ComputeEngineCredentials credentials = - ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); - AccessToken token = credentials.refreshAccessToken(); - assertNotNull(token); - assertEquals(ACCESS_TOKEN, token.getTokenValue()); - String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); - assertFalse(requestUrl.contains("bindCertificateFingerprint")); - } @Test void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); envProvider.setEnv( AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, tempDir.resolve("missing_config.json").toAbsolutePath().toString()); @@ -1347,22 +1306,67 @@ public void sleep(long millis) { "Unable to find Agent Identity certificate config or file for bound token request after multiple retries.")); } - private void setupCertConfig(String certResourceName) throws IOException { - Path certPath = tempDir.resolve("cert.pem"); - try (InputStream certStream = - getClass().getClassLoader().getResourceAsStream(certResourceName)) { - assertNotNull(certStream, "Test resource " + certResourceName + " not found"); - Files.copy(certStream, certPath); - } + + + private void setupCertAndKeyConfig() throws IOException { + java.nio.file.Path certSource = java.nio.file.Paths.get("testresources/agent_spiffe_cert.pem"); + java.nio.file.Path keySource = java.nio.file.Paths.get("testresources/mtls/test_key.pem"); + + java.nio.file.Path certTarget = tempDir.resolve("certificates.pem"); + java.nio.file.Path keyTarget = tempDir.resolve("private_key.pem"); + + Files.copy(certSource, certTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + Files.copy(keySource, keyTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + Path configPath = tempDir.resolve("config.json"); String configContent = "{\"cert_configs\": {\"workload\": {\"cert_path\": \"" - + certPath.toAbsolutePath().toString().replace("\\", "\\\\") + + certTarget.toAbsolutePath().toString().replace("\\", "\\\\") + + "\", \"key_path\": \"" + + keyTarget.toAbsolutePath().toString().replace("\\", "\\\\") + "\"}}}"; Files.write(configPath, configContent.getBytes(StandardCharsets.UTF_8)); envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configPath.toAbsolutePath().toString()); } + @Test + void refreshAccessToken_withValidCertAndKey_requestsBoundToken() throws IOException { + setupCertAndKeyConfig(); + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); + + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + AccessToken token = credentials.refreshAccessToken(); + + assertNotNull(token); + com.google.api.client.testing.http.MockLowLevelHttpRequest request = transportFactory.transport.getRequest(); + assertEquals("POST", transportFactory.transport.getRequestMethod()); + String body = request.getContentAsString(); + assertTrue(body.contains("certificate_chain")); + } + + @Test + void idTokenWithAudience_withValidCertAndKey_requestsBoundToken() throws IOException { + setupCertAndKeyConfig(); + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setIdToken(STANDARD_ID_TOKEN); + + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + IdToken token = credentials.idTokenWithAudience("https://foo.bar", null); + + assertNotNull(token); + com.google.api.client.testing.http.MockLowLevelHttpRequest request = transportFactory.transport.getRequest(); + assertEquals("POST", transportFactory.transport.getRequestMethod()); + String body = request.getContentAsString(); + assertTrue(body.contains("certificate_chain")); + } + private static class TestEnvironmentProvider { private final Map env = new HashMap<>(); void setEnv(String key, String value) { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java index d641f9b18a3d..a45c11cd8717 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java @@ -76,8 +76,13 @@ class DefaultCredentialsProviderTest { @BeforeEach void setUp() { - // Isolate tests from user's GOOGLE_API_CERTIFICATE_CONFIG environment variable. - AgentIdentityUtils.setEnvReader(name -> null); + // Isolate tests and opt out of bound tokens by default to avoid polling delays + AgentIdentityUtils.setEnvReader(name -> { + if ("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES".equals(name)) { + return "false"; // Triggers isOptedOut() = true + } + return null; + }); } @AfterEach diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java index 369ea35b857c..1f8878587c88 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java @@ -72,6 +72,7 @@ public class MockMetadataServerTransport extends MockHttpTransport { private boolean emptyContent; private MockLowLevelHttpRequest request; private int requestCount = 0; + private String requestMethod; public int getRequestCount() { return requestCount; @@ -128,9 +129,14 @@ public MockLowLevelHttpRequest getRequest() { return request; } + public String getRequestMethod() { + return requestMethod; + } + @Override public LowLevelHttpRequest buildRequest(String method, String url) throws IOException { requestCount++; + this.requestMethod = method; if (url.startsWith(ComputeEngineCredentials.getTokenServerEncodedUrl())) { this.request = getMockRequestForTokenEndpoint(url); return this.request; From a591db4bb9366888246e6fe1287d7b5861a67aa6 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Mon, 11 May 2026 16:39:43 -0700 Subject: [PATCH 03/25] Added logic to verify key and cert to ensure no mismatch along with retry logic. Nit fixes. # Conflicts: # google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java --- .../auth/oauth2/AgentIdentityUtils.java | 162 ++++++++++++++---- .../auth/oauth2/ComputeEngineCredentials.java | 52 ++++-- .../auth/oauth2/AgentIdentityUtilsTest.java | 106 ++++++++++-- .../oauth2/ComputeEngineCredentialsTest.java | 65 +++---- .../DefaultCredentialsProviderTest.java | 13 +- .../auth/oauth2/IdTokenCredentialsTest.java | 31 ++++ .../testresources/agent_spiffe_cert.pem | 37 ++-- .../testresources/agent_spiffe_key.pem | 28 +++ 8 files changed, 381 insertions(+), 113 deletions(-) create mode 100644 google-auth-library-java/oauth2_http/testresources/agent_spiffe_key.pem diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 9e52d57b7423..6d17514d3e60 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -1,3 +1,33 @@ +/* + * Copyright 2026, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ package com.google.auth.oauth2; import com.google.api.client.json.GenericJson; @@ -15,9 +45,9 @@ import java.nio.file.Paths; import java.security.GeneralSecurityException; import java.security.MessageDigest; -import java.security.cert.CertificateFactory; import java.security.PrivateKey; import java.security.Signature; +import java.security.cert.CertificateFactory; import java.security.cert.CertificateParsingException; import java.security.cert.X509Certificate; import java.util.ArrayList; @@ -29,9 +59,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** - * Utility class for Agent Identity token binding in Cloud Run. - */ +/** Utility class for Agent Identity token binding in Cloud Run. */ class AgentIdentityUtils { private static final Logger LOGGER = LoggerFactory.getLogger(AgentIdentityUtils.class); @@ -85,6 +113,7 @@ interface EnvReader { @VisibleForTesting interface TimeService { long currentTimeMillis(); + void sleep(long millis) throws InterruptedException; } @@ -106,6 +135,7 @@ private AgentIdentityUtils() {} static class CertInfo { final X509Certificate certificate; final String path; + CertInfo(X509Certificate certificate, String path) { this.certificate = certificate; this.path = path; @@ -117,22 +147,76 @@ static CertInfo getAgentIdentityCertInfo() throws IOException { return null; } String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG); - - boolean configExists = !Strings.isNullOrEmpty(certConfigPath) && Files.exists(Paths.get(certConfigPath)); - String certPath; + + boolean configExists = + !Strings.isNullOrEmpty(certConfigPath) && Files.exists(Paths.get(certConfigPath)); + String certPath = null; + String keyPath = null; + if (!Strings.isNullOrEmpty(certConfigPath)) { certPath = getCertificatePathWithRetry(certConfigPath); + keyPath = extractKeyPathFromConfig(certConfigPath); } else { certPath = getWellKnownCertificatePathWithRetry(); + if (certPath != null) { + if (certPath.endsWith("credentialbundle.pem")) { + keyPath = certPath; // Bundle contains both + } else if (certPath.endsWith("certificates.pem")) { + keyPath = Paths.get(wellKnownDir, "private_key.pem").toString(); + } + } } boolean certsPresent = !Strings.isNullOrEmpty(certPath); - + if (!shouldEnableMtls(certsPresent, configExists)) { return null; } - X509Certificate cert = parseCertificate(certPath); + X509Certificate cert = null; + PrivateKey privateKey = null; + + if (!Strings.isNullOrEmpty(certPath) + && !Strings.isNullOrEmpty(keyPath) + && !certPath.equals(keyPath) + && Files.exists(Paths.get(keyPath))) { + // Separate files, verify match with retry + int retries = 0; + boolean matched = false; + while (retries < 3) { + try { + cert = parseCertificate(certPath); + privateKey = readPrivateKey(keyPath, cert.getPublicKey().getAlgorithm()); + + if (verifyKeyPair(cert, privateKey)) { + matched = true; + break; + } + LOGGER.warn("Cert and key mismatch, retrying..."); + } catch (Exception e) { + LOGGER.warn("Failed to read or verify cert/key, retrying...", e); + } + + retries++; + if (retries < 3) { + try { + timeService.sleep(100); // 0.1 seconds backoff + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for cert/key match.", e); + } + } + } + + if (!matched) { + throw new IOException( + "Agent Identity certificate and private key mismatch or read failure after 3 retries."); + } + } else if (!Strings.isNullOrEmpty(certPath)) { + // Bundle or only cert available + cert = parseCertificate(certPath); + } + return new CertInfo(cert, certPath); } @@ -180,9 +264,9 @@ private static String getCertificatePathWithRetry(String certConfigPath) throws } private static String getWellKnownCertificatePathWithRetry() throws IOException { - String bundlePath = wellKnownDir + "credentialbundle.pem"; - String certOnlyPath = wellKnownDir + "certificates.pem"; - + String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString(); + String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString(); + boolean warned = false; for (long sleepInterval : POLLING_INTERVALS) { try { @@ -209,9 +293,7 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException timeService.sleep(sleepInterval); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new IOException( - "Interrupted while waiting for well-known certificate files.", - e); + throw new IOException("Interrupted while waiting for well-known certificate files.", e); } } throw new IOException( @@ -219,14 +301,13 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException } static String readCertificateChain(String certPath) throws IOException { - System.out.println("AgentIdentityUtils: Reading certificate chain from: " + certPath); return new String(Files.readAllBytes(Paths.get(certPath)), StandardCharsets.UTF_8); } static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { try { byte[] data = "verification-data".getBytes(StandardCharsets.UTF_8); - + String keyAlgorithm = cert.getPublicKey().getAlgorithm(); String sigAlg; if ("RSA".equals(keyAlgorithm)) { @@ -236,40 +317,42 @@ static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { } else { throw new IllegalArgumentException("Unsupported key algorithm: " + keyAlgorithm); } - + Signature signer = Signature.getInstance(sigAlg); signer.initSign(privateKey); signer.update(data); byte[] signature = signer.sign(); - + Signature verifier = Signature.getInstance(sigAlg); verifier.initVerify(cert.getPublicKey()); verifier.update(data); - + return verifier.verify(signature); } catch (Exception e) { - System.out.println("AgentIdentityUtils: Key pair verification failed: " + e.getMessage()); + LOGGER.warn("Key pair verification failed", e); return false; } } static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOException { String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); - OAuth2Utils.Pkcs8Algorithm pkcs8Alg = "EC".equals(algorithm) ? OAuth2Utils.Pkcs8Algorithm.EC : OAuth2Utils.Pkcs8Algorithm.RSA; + OAuth2Utils.Pkcs8Algorithm pkcs8Alg = + "EC".equals(algorithm) ? OAuth2Utils.Pkcs8Algorithm.EC : OAuth2Utils.Pkcs8Algorithm.RSA; return OAuth2Utils.privateKeyFromPkcs8(keyPem, pkcs8Alg); } static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) throws IOException { String useClientCert = envReader.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); - + if ("true".equalsIgnoreCase(useClientCert)) { if (certsPresent) { - return true; // Case 1 + return true; } if (configExists) { - throw new IOException("Certificate intent established via config, but cert files are missing."); // Case 2 + throw new IOException( + "Certificate intent established via config, but cert files are missing."); } - return false; // Case 3 + return false; } else if ("false".equalsIgnoreCase(useClientCert)) { if (certsPresent) { Slf4jUtils.log( @@ -277,15 +360,16 @@ static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) thro org.slf4j.event.Level.WARN, Collections.emptyMap(), "Token binding protection is disabled because mTLS was explicitly disabled via GOOGLE_API_USE_CLIENT_CERTIFICATE."); - return false; // Case 4 + return false; } - return false; // Case 5 - } else { // Unset + return false; + } else { if (certsPresent) { - return true; // Case 6 (Infer enabled) + return true; } if (configExists) { - throw new IOException("Certificate intent inferred via config, but cert files are missing."); // Case 7 + throw new IOException( + "Certificate intent inferred via config, but cert files are missing."); // Case 7 } return false; // Case 8 } @@ -315,6 +399,22 @@ private static String extractCertPathFromConfig(String certConfigPath) throws IO return null; } + @SuppressWarnings("unchecked") + private static String extractKeyPathFromConfig(String certConfigPath) throws IOException { + try (InputStream stream = new FileInputStream(certConfigPath)) { + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson config = parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class); + Map certConfigs = (Map) config.get("cert_configs"); + if (certConfigs != null) { + Map workload = (Map) certConfigs.get("workload"); + if (workload != null) { + return (String) workload.get("key_path"); + } + } + } + return null; + } + private static X509Certificate parseCertificate(String certPath) throws IOException { try (InputStream stream = new FileInputStream(certPath)) { CertificateFactory cf = CertificateFactory.getInstance("X.509"); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java index b8b64edde86e..0e5a296bcbf2 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java @@ -62,7 +62,6 @@ import java.net.SocketTimeoutException; import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; -import java.security.cert.X509Certificate; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; @@ -381,7 +380,8 @@ public String getProjectId() { private String getProjectIdFromMetadata() { try { - HttpResponse response = getMetadataResponse(getProjectIdUrl(), "GET", null, RequestType.UNTRACKED, false); + HttpResponse response = + getMetadataResponse(getProjectIdUrl(), "GET", null, RequestType.UNTRACKED, false); int statusCode = response.getStatusCode(); if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { LoggingUtils.log( @@ -427,12 +427,14 @@ public AccessToken refreshAccessToken() throws IOException { String boundTokenPayload = AgentIdentityUtils.getBoundTokenPayload(); HttpResponse response; - + if (boundTokenPayload != null) { - String escapedChain = boundTokenPayload.replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); - String jsonString = "{\"certificate_chain\":\"" + escapedChain + "\"}"; - - response = getMetadataResponse(tokenUrl, "POST", jsonString, RequestType.ACCESS_TOKEN_REQUEST, true); + java.util.Map payload = + Collections.singletonMap("certificate_chain", boundTokenPayload); + String jsonString = OAuth2Utils.JSON_FACTORY.toString(payload); + + response = + getMetadataResponse(tokenUrl, "POST", jsonString, RequestType.ACCESS_TOKEN_REQUEST, true); } else { response = getMetadataResponse(tokenUrl, "GET", null, RequestType.ACCESS_TOKEN_REQUEST, true); } @@ -505,16 +507,19 @@ public IdToken idTokenWithAudience(String targetAudience, List payload = + Collections.singletonMap("certificate_chain", boundTokenPayload); + String jsonString = OAuth2Utils.JSON_FACTORY.toString(payload); + + response = + getMetadataResponse( + documentUrl.toString(), "POST", jsonString, RequestType.ID_TOKEN_REQUEST, true); } else { - System.out.println("ComputeEngineCredentials: Requesting standard unbound ID token via GET."); - response = getMetadataResponse(documentUrl.toString(), "GET", null, RequestType.ID_TOKEN_REQUEST, true); + response = + getMetadataResponse( + documentUrl.toString(), "GET", null, RequestType.ID_TOKEN_REQUEST, true); } int statusCode = response.getStatusCode(); if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { @@ -544,15 +549,23 @@ public IdToken idTokenWithAudience(String targetAudience, List env = new java.util.HashMap<>(); + void setEnv(String key, String value) { env.put(key, value); } + String getEnv(String key) { return env.get(key); } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 3e6a8b5cf2f3..9931aec8459a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -62,7 +62,6 @@ import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; -import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -70,17 +69,17 @@ import java.util.ArrayDeque; import java.util.Arrays; import java.util.Collection; -import java.util.HashMap; -import java.util.concurrent.atomic.AtomicLong; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Queue; +import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; import java.util.stream.IntStream; import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; /** Test case for {@link ComputeEngineCredentials}. */ @@ -97,7 +96,7 @@ void setUp() throws IOException { // Inject our test environment reader into AgentIdentityUtils AgentIdentityUtils.setEnvReader(envProvider::getEnv); tempDir = Files.createTempDirectory("compute_engine_creds_test"); - + // Speed up polling in tests by using a fake time service that advances time immediately final AtomicLong currentTime = new AtomicLong(0); AgentIdentityUtils.setTimeService( @@ -106,6 +105,7 @@ void setUp() throws IOException { public long currentTimeMillis() { return currentTime.get(); } + @Override public void sleep(long millis) { currentTime.addAndGet(millis); @@ -1275,8 +1275,6 @@ InputStream readStream(File file) throws FileNotFoundException { assertTrue(isOnGce); assertEquals(1, transportFactory.transport.getRequestCount()); } - - @Test void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); @@ -1290,6 +1288,7 @@ void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { public long currentTimeMillis() { return currentTime.get(); } + @Override public void sleep(long millis) { currentTime.addAndGet(millis); @@ -1306,25 +1305,27 @@ public void sleep(long millis) { "Unable to find Agent Identity certificate config or file for bound token request after multiple retries.")); } - - private void setupCertAndKeyConfig() throws IOException { java.nio.file.Path certSource = java.nio.file.Paths.get("testresources/agent_spiffe_cert.pem"); - java.nio.file.Path keySource = java.nio.file.Paths.get("testresources/mtls/test_key.pem"); - java.nio.file.Path certTarget = tempDir.resolve("certificates.pem"); - java.nio.file.Path keyTarget = tempDir.resolve("private_key.pem"); - Files.copy(certSource, certTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + + java.nio.file.Path keySource = java.nio.file.Paths.get("testresources/agent_spiffe_key.pem"); + java.nio.file.Path keyTarget = tempDir.resolve("private_key.pem"); Files.copy(keySource, keyTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); - + Path configPath = tempDir.resolve("config.json"); - String configContent = - "{\"cert_configs\": {\"workload\": {\"cert_path\": \"" - + certTarget.toAbsolutePath().toString().replace("\\", "\\\\") - + "\", \"key_path\": \"" - + keyTarget.toAbsolutePath().toString().replace("\\", "\\\\") - + "\"}}}"; + Map workload = new HashMap<>(); + workload.put("cert_path", certTarget.toAbsolutePath().toString()); + workload.put("key_path", keyTarget.toAbsolutePath().toString()); + + Map certConfigs = new HashMap<>(); + certConfigs.put("workload", workload); + + Map config = new HashMap<>(); + config.put("cert_configs", certConfigs); + + String configContent = OAuth2Utils.JSON_FACTORY.toString(config); Files.write(configPath, configContent.getBytes(StandardCharsets.UTF_8)); envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configPath.toAbsolutePath().toString()); } @@ -1332,17 +1333,19 @@ private void setupCertAndKeyConfig() throws IOException { @Test void refreshAccessToken_withValidCertAndKey_requestsBoundToken() throws IOException { setupCertAndKeyConfig(); - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token + envProvider.setEnv( + "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); - + ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); AccessToken token = credentials.refreshAccessToken(); - + assertNotNull(token); - com.google.api.client.testing.http.MockLowLevelHttpRequest request = transportFactory.transport.getRequest(); + com.google.api.client.testing.http.MockLowLevelHttpRequest request = + transportFactory.transport.getRequest(); assertEquals("POST", transportFactory.transport.getRequestMethod()); String body = request.getContentAsString(); assertTrue(body.contains("certificate_chain")); @@ -1351,17 +1354,19 @@ void refreshAccessToken_withValidCertAndKey_requestsBoundToken() throws IOExcept @Test void idTokenWithAudience_withValidCertAndKey_requestsBoundToken() throws IOException { setupCertAndKeyConfig(); - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token + envProvider.setEnv( + "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); transportFactory.transport.setIdToken(STANDARD_ID_TOKEN); - + ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); IdToken token = credentials.idTokenWithAudience("https://foo.bar", null); - + assertNotNull(token); - com.google.api.client.testing.http.MockLowLevelHttpRequest request = transportFactory.transport.getRequest(); + com.google.api.client.testing.http.MockLowLevelHttpRequest request = + transportFactory.transport.getRequest(); assertEquals("POST", transportFactory.transport.getRequestMethod()); String body = request.getContentAsString(); assertTrue(body.contains("certificate_chain")); @@ -1369,9 +1374,11 @@ void idTokenWithAudience_withValidCertAndKey_requestsBoundToken() throws IOExcep private static class TestEnvironmentProvider { private final Map env = new HashMap<>(); + void setEnv(String key, String value) { env.put(key, value); } + String getEnv(String key) { return env.get(key); } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java index a45c11cd8717..0961db67ccb5 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java @@ -77,12 +77,13 @@ class DefaultCredentialsProviderTest { @BeforeEach void setUp() { // Isolate tests and opt out of bound tokens by default to avoid polling delays - AgentIdentityUtils.setEnvReader(name -> { - if ("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES".equals(name)) { - return "false"; // Triggers isOptedOut() = true - } - return null; - }); + AgentIdentityUtils.setEnvReader( + name -> { + if ("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES".equals(name)) { + return "false"; // Triggers isOptedOut() = true + } + return null; + }); } @AfterEach diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java index e3dcec4b520c..9e80bc63477f 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java @@ -34,11 +34,42 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; /** Test case for {@link IdTokenCredentials}. */ class IdTokenCredentialsTest extends BaseSerializationTest { + private static class TestEnvironmentProvider { + private final Map env = new HashMap<>(); + + void setEnv(String key, String value) { + env.put(key, value); + } + + String getEnv(String key) { + return env.get(key); + } + } + + private TestEnvironmentProvider envProvider; + + @BeforeEach + void setUp() { + envProvider = new TestEnvironmentProvider(); + AgentIdentityUtils.setEnvReader(envProvider::getEnv); + // Opt out by default to avoid polling delays or file reads + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); + } + + @AfterEach + void tearDown() { + AgentIdentityUtils.setEnvReader(System::getenv); + } + @Test void hashCode_equals() throws IOException { ComputeEngineCredentialsTest.MockMetadataServerTransportFactory transportFactory = diff --git a/google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem b/google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem index d0a0ed1a883d..885ecec9b6cf 100644 --- a/google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem +++ b/google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem @@ -1,20 +1,21 @@ -----BEGIN CERTIFICATE----- -MIIDPjCCAiagAwIBAgIUCYeV4dwM29T5yucwWrSWlOC9wwYwDQYJKoZIhvcNAQEL -BQAwIjEgMB4GA1UEAwwXVGVzdCBTUElGRkUgQ2VydGlmaWNhdGUwHhcNMjUxMTA3 -MDEyMjQ4WhcNMzUxMTA1MDEyMjQ4WjAiMSAwHgYDVQQDDBdUZXN0IFNQSUZGRSBD -ZXJ0aWZpY2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDr1Bzo -KtzIZB35acQ+mpk6yScf59AnwHjjgNCMbC7kq2DSUfQzTlu9Kd0uUB6O7DmJ73D8 -Pge4XLE/Q1B6dI6DzJx7lhPoC1BiQFUGJ4Cu+TbbdlK3RiXNAZYjIj9UKP7DejCY -WRgFB+PYyLczEkByvU9cy7Z9Uuufsn6LnYu7qOG+DcRSE41ThurZxQ14OWvLfjZm -lhZXam4VBBli8Qku8qFIALe78kpy+hp2YCRnK84amATwPpGprRACp9WVka2JDYKD -LY0OoYlyAQel6960aS11N3/2v0cvx03/LM5+Yj+DTvdyb2Mk/NVeRIKo8cM5YwPn -sTLCf1cdxJvseRMCAwEAAaNsMGowSQYDVR0RBEIwQIY+c3BpZmZlOi8vYWdlbnRz -Lmdsb2JhbC5wcm9qLTEyMzQ1LnN5c3RlbS5pZC5nb29nL3Rlc3Qtd29ya2xvYWQw -HQYDVR0OBBYEFPvn+KXBcrYCmAMopkghUczUx/IkMA0GCSqGSIb3DQEBCwUAA4IB -AQCbwd9RMFkr1C9AEgnLMWd1l9ciBbK0t1Sydu3eA0SNm2w6E58ih8O+huo6eGsM -7z0E4i7YuaHnTdah/lPMqd75YRO57GSRbvi2g+yPyw6XdFl9HCHwF4WARdTF4Nkf -1c1WstvBXb24PSSQQdy9un72ZG6f9fSVQrko6hchd8Rg6yyBTFE8APPkeMR/EJtV -cnXg4CgsQIPHxJGQrhNvQhF7VLZePlTass4bqTqTYXwAte2jX/KW3qlW/t/v4AJe -/q+pcXmNIvwRpT8zYA5tJHIDVJ+v9pWZA+nhoD9Qtr7FVHfB4mdNuFv7bMPoXN0+ -mCPzP08MnjgbX7zRETVlblrx +MIIDcjCCAlqgAwIBAgIUb5t9VPgT3uTrj+z47BfU8xF6sFowDQYJKoZIhvcNAQEL +BQAwIjEgMB4GA1UEAwwXVGVzdCBTUElGRkUgQ2VydGlmaWNhdGUwHhcNMjYwNTEx +MjMyODU3WhcNMzYwNTA4MjMyODU3WjAiMSAwHgYDVQQDDBdUZXN0IFNQSUZGRSBD +ZXJ0aWZpY2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJlPtSAh +hYqci8YJaPlgZWUbup4q/TGuaLdR/zWpFCevAQW7/hq29LT8WsoyuirSlbJg67io +PPuNp98e614L/88jP3wMeSuBSXGMyeExrMQ35ZbrnmD268Fl/jEZXPujmeFWmRoy +miKSkNvYLvkN0fiooyFYZdNG0/f6zT+dwJqoOYgeBl9XXDf5QjnQbhQXFQJIbxfQ +TTaGFtEXXwKCs9cd5EvwbiDuaM+F2O7txkaD4r+77AjsXWUFkBBbSUzs2b3ujZot +ZqwNCeeGML/G+5TQj3UExTtDzYpjvncEuqdomFraqXy6DmyELhyChugsz5rGx5Vt +aP3RvDkpLwUxjWcCAwEAAaOBnzCBnDAdBgNVHQ4EFgQUoS1OUx9a1hBHt0tHAefV +7QQ3JYswHwYDVR0jBBgwFoAUoS1OUx9a1hBHt0tHAefV7QQ3JYswDwYDVR0TAQH/ +BAUwAwEB/zBJBgNVHREEQjBAhj5zcGlmZmU6Ly9hZ2VudHMuZ2xvYmFsLnByb2ot +MTIzNDUuc3lzdGVtLmlkLmdvb2cvdGVzdC13b3JrbG9hZDANBgkqhkiG9w0BAQsF +AAOCAQEAfCrNuLFIlpvtDpBKD8lxj2vF4/6fbLhl/5YGGFf2uydaWLr2hTmLFrer +fzGf04hPD5UJTms0ZlvHTvapm1IikxKhkmp8GOlMvKWl/EwIDaJyJ8EaYCpkrTNs +pLR/ujgeSKnIHgm2Oql7HZxB1T25teFcLIIMd0zs69h/Sxejw+OTKnwb7san1qSy +uNvXVDPNFrGyjvBXAVyjyvs3Adz/A+GYhyaP31s+3qGUUR4/axv0M8pUVmK6D8lU +5TkO7smVELt51xaq77Nvv1r5FasJkF5CrnqwAjq1QfVJpDsuTCEriUXguxaXFbzM +Qfw7TEXp3xPVinhkDjlRfiGg6hIvNw== -----END CERTIFICATE----- diff --git a/google-auth-library-java/oauth2_http/testresources/agent_spiffe_key.pem b/google-auth-library-java/oauth2_http/testresources/agent_spiffe_key.pem new file mode 100644 index 000000000000..b136c31e3db7 --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/agent_spiffe_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCZT7UgIYWKnIvG +CWj5YGVlG7qeKv0xrmi3Uf81qRQnrwEFu/4atvS0/FrKMroq0pWyYOu4qDz7jaff +HuteC//PIz98DHkrgUlxjMnhMazEN+WW655g9uvBZf4xGVz7o5nhVpkaMpoikpDb +2C75DdH4qKMhWGXTRtP3+s0/ncCaqDmIHgZfV1w3+UI50G4UFxUCSG8X0E02hhbR +F18CgrPXHeRL8G4g7mjPhdju7cZGg+K/u+wI7F1lBZAQW0lM7Nm97o2aLWasDQnn +hjC/xvuU0I91BMU7Q82KY753BLqnaJha2ql8ug5shC4cgoboLM+axseVbWj90bw5 +KS8FMY1nAgMBAAECggEAA/g2Eq6bhEYr/lHL2u0kIvT2JSNGBAb/WIPLs/cWE2le +GujOGjrGskSSEaGa0JuiqAg6m9p+hCFPIyFShQOSUsNkYOqUkDJPe8+vG62xsTDx +uMsoAijsKMlI+bqtsPXm+MuWHw4Hww5nDsvI4Sz6iB6bS4E8JB2cEaDEtx/do7aq +5XHJYoy0kO/l87n1UlIq8Pi6yW36pQKKEK+Dm5q+QmgJIeNLFp0hb5r7/fLFENTD +CZO0GvMdmtTkTwyPB1mvFjP6mNNAZcO6j9eaWNCxFhPH+MNWtF+694wgPOQIFKL3 +n4FPmWUS7vGJjNI55Ff2tTHrbWcMuf4RwXbVIG+v1QKBgQDPnNEs2TRrMglFtT6J +wDbICK8AQbBAPFjtZUGxNnsCOdiprZXshZJ7Yq8lokDlsU+5l0YTzRj2v1IcEL49 +oy8WeQfbcOZTGgf+Qo50ZiLhziCqNTlxiXMp3Kg5h4cjxUz2tsF2Nmzn75411uPa +Q/SKHCzfz4uUtHJla8tHENuzdQKBgQC9CwOleHwdS5D1jzBpb/+rvc0vcyYeihZe +v2ao2ItEZZpDyZoou3HiuTe8b9jpg4iS1PtBe6ck/99dBA3CSCF6VCS+++fc+HOz +IyPZjfg8qQ/88XMIEJy8eAa2OFT/EwK3AeZvIuXVumUHI2R8AfkVB0OfjSVZ7hnI +p1ndQ09t6wKBgHDiQVHzX+8RK719SN25Z4/oOM8Y6G5k4a1iqw9iIgwZy9amjagn +EHiKNdVunX7GpCSzPeUyVWqEqG6eI/J7sfS0JjOI9ZMlykbThYWAq2K/oz8o5Wz4 +YWfXlJiDOlWWx7w1rodKHHkX7pwzlXxuCp61pyiiPrDCVJkUvViMsAipAoGAQHda +FfqhcKgNVgAvhTVBXgLKzwyYij+S41qoGppF29w+IDHG1W8epi99d1A5C2DkmRXy +XOFbHX34YNL6Ei/g4sOBCHQFHNDJO+SW3CDS73TD1AFOtghcOtU/jLJnIdkMyvXl +7C5dbGY0/5stMDDIDUi94dITU7ijqE6RkafblWMCgYEAxwFH2JJn8HyU30PVrIFh +AsviZ3BYrCbYFlkZpelCHzHc5imyR9euLwXHIPS0vTE5V8GzkPEgW3DIOFMmBwe2 +8M/jGi/RQCnD7Hh0C+Y0vtUyF8z88lvN5ac6lU9UpGVBBkd8HEsq5ukL3PlnW4B0 +uRGJOa6FU01LIEMoCL5LP28= +-----END PRIVATE KEY----- From 18afacb0a5bad42618d4ad22bee025d6b1d1cd13 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Mon, 11 May 2026 18:17:11 -0700 Subject: [PATCH 04/25] Added documentation and addressed comments. --- .../auth/oauth2/AgentIdentityUtils.java | 135 ++++++++++++++---- .../auth/oauth2/AgentIdentityUtilsTest.java | 12 +- .../oauth2/ComputeEngineCredentialsTest.java | 14 +- .../DefaultCredentialsProviderTest.java | 2 +- .../auth/oauth2/IdTokenCredentialsTest.java | 2 +- .../com/google/auth/oauth2/LoggingTest.java | 19 +++ .../FTComputeEngineCredentialsTest.java | 32 +++++ .../{ => agent}/agent_spiffe_cert.pem | 0 .../{ => agent}/agent_spiffe_key.pem | 0 .../oauth2_http/testresources/agent_cert.pem | 19 --- 10 files changed, 166 insertions(+), 69 deletions(-) rename google-auth-library-java/oauth2_http/testresources/{ => agent}/agent_spiffe_cert.pem (100%) rename google-auth-library-java/oauth2_http/testresources/{ => agent}/agent_spiffe_key.pem (100%) delete mode 100644 google-auth-library-java/oauth2_http/testresources/agent_cert.pem diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 6d17514d3e60..6c2fa2503dff 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -30,21 +30,19 @@ */ package com.google.auth.oauth2; +import com.google.api.core.InternalApi; import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonObjectParser; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; -import com.google.common.io.BaseEncoding; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; -import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.security.GeneralSecurityException; -import java.security.MessageDigest; import java.security.PrivateKey; import java.security.Signature; import java.security.cert.CertificateFactory; @@ -60,7 +58,8 @@ import org.slf4j.LoggerFactory; /** Utility class for Agent Identity token binding in Cloud Run. */ -class AgentIdentityUtils { +@InternalApi +public final class AgentIdentityUtils { private static final Logger LOGGER = LoggerFactory.getLogger(AgentIdentityUtils.class); @@ -104,7 +103,7 @@ static void setWellKnownDir(String dir) { POLLING_INTERVALS = Collections.unmodifiableList(intervals); } - interface EnvReader { + public interface EnvReader { String getEnv(String name); } @@ -142,21 +141,66 @@ static class CertInfo { } } + static class ResolvedCertAndKeyPaths { + final String certPath; + final String keyPath; + + ResolvedCertAndKeyPaths(String certPath, String keyPath) { + this.certPath = certPath; + this.keyPath = keyPath; + } + } + + /** + * Retrieves the certificate and path for the Agent Identity. + * + *

This method attempts to load the certificate and private key for the agent identity. It + * first checks the location specified by the {@code GOOGLE_API_CERTIFICATE_CONFIG} environment + * variable. If not set, it falls back to well-known default locations. + * + *

To handle transient race conditions during certificate rotation on disk, this method employs + * a retry mechanism with backoff when reading the configuration and certificate files. + * + * @return A {@link CertInfo} object containing the loaded certificate and its path, or {@code + * null} if the agent identity features are disabled, opted out, or if no valid credentials + * could be loaded. + * @throws IOException If an I/O error occurs while reading the files, or if the key-pair + * verification fails after retries. + */ static CertInfo getAgentIdentityCertInfo() throws IOException { if (isOptedOut()) { return null; } String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG); - boolean configExists = !Strings.isNullOrEmpty(certConfigPath) && Files.exists(Paths.get(certConfigPath)); + + ResolvedCertAndKeyPaths paths = resolveCertAndKeyPaths(certConfigPath); + boolean certsPresent = !Strings.isNullOrEmpty(paths.certPath); + + if (!shouldEnableMtls(certsPresent, configExists)) { + return null; + } + + return loadAndVerifyCredentials(paths.certPath, paths.keyPath); + } + + /** + * Resolves the paths for the certificate and private key based on the config path or well-known + * locations. + */ + static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(String certConfigPath) throws IOException { String certPath = null; String keyPath = null; if (!Strings.isNullOrEmpty(certConfigPath)) { + // Read cert path from config file. We use retry with backoff to handle transient race + // conditions where the config file might be being updated by a rotation process. certPath = getCertificatePathWithRetry(certConfigPath); keyPath = extractKeyPathFromConfig(certConfigPath); } else { + // Fallback to well-known locations. We use retry with backoff here as well to handle + // race conditions during file replacement by a rotation process. certPath = getWellKnownCertificatePathWithRetry(); if (certPath != null) { if (certPath.endsWith("credentialbundle.pem")) { @@ -166,13 +210,13 @@ static CertInfo getAgentIdentityCertInfo() throws IOException { } } } + return new ResolvedCertAndKeyPaths(certPath, keyPath); + } - boolean certsPresent = !Strings.isNullOrEmpty(certPath); - - if (!shouldEnableMtls(certsPresent, configExists)) { - return null; - } - + /** + * Loads the certificate and private key, and verifies that they match if they are separate files. + */ + static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws IOException { X509Certificate cert = null; PrivateKey privateKey = null; @@ -220,11 +264,18 @@ static CertInfo getAgentIdentityCertInfo() throws IOException { return new CertInfo(cert, certPath); } + /** + * Checks if the user has opted out of token sharing by setting the environment variable to true. + */ private static boolean isOptedOut() { String optOut = envReader.getEnv(GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES); - return "false".equalsIgnoreCase(optOut); + return "true".equalsIgnoreCase(optOut); } + /** + * Reads the certificate path from the config file with retry logic to handle rotation race + * conditions. + */ private static String getCertificatePathWithRetry(String certConfigPath) throws IOException { boolean warned = false; for (long sleepInterval : POLLING_INTERVALS) { @@ -263,6 +314,7 @@ private static String getCertificatePathWithRetry(String certConfigPath) throws + " to false to fall back to unbound tokens."); } + /** Searches for certificates at well-known locations with retry logic. */ private static String getWellKnownCertificatePathWithRetry() throws IOException { String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString(); String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString(); @@ -300,10 +352,15 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException "Unable to find well-known certificate file for bound token request after multiple retries."); } + /** Reads the full certificate chain from the specified path as a string. */ static String readCertificateChain(String certPath) throws IOException { return new String(Files.readAllBytes(Paths.get(certPath)), StandardCharsets.UTF_8); } + /** + * Verifies that the private key corresponds to the public key in the certificate by performing a + * test signature and verification. + */ static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { try { byte[] data = "verification-data".getBytes(StandardCharsets.UTF_8); @@ -334,6 +391,7 @@ static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { } } + /** Reads the private key from the specified path using PKCS8 format. */ static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOException { String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); OAuth2Utils.Pkcs8Algorithm pkcs8Alg = @@ -341,20 +399,30 @@ static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOExce return OAuth2Utils.privateKeyFromPkcs8(keyPem, pkcs8Alg); } + /** + * Determines if mTLS should be enabled based on environment variables and certificate presence. + */ static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) throws IOException { String useClientCert = envReader.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); + // Case 1: Explicitly enabled via environment variable if ("true".equalsIgnoreCase(useClientCert)) { if (certsPresent) { + // Certs are available, enable mTLS return true; } if (configExists) { + // Config exists but files are missing - fail fast throw new IOException( "Certificate intent established via config, but cert files are missing."); } + // Neither exist, do not enable return false; - } else if ("false".equalsIgnoreCase(useClientCert)) { + } + // Case 2: Explicitly disabled via environment variable + else if ("false".equalsIgnoreCase(useClientCert)) { if (certsPresent) { + // Warn that we are ignoring present certs because it was explicitly disabled Slf4jUtils.log( LOGGER, org.slf4j.event.Level.WARN, @@ -363,18 +431,24 @@ static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) thro return false; } return false; - } else { + } + // Case 3: Environment variable is unset + else { if (certsPresent) { + // Infer mTLS is enabled because certs are present return true; } if (configExists) { + // Config exists but files are missing - fail fast throw new IOException( - "Certificate intent inferred via config, but cert files are missing."); // Case 7 + "Certificate intent inferred via config, but cert files are missing."); } - return false; // Case 8 + // Neither cert-config nor certsexist, do not enable + return false; } } + /** Retrieves the bound token payload (certificate chain) if applicable. */ static String getBoundTokenPayload() throws IOException { CertInfo info = getAgentIdentityCertInfo(); if (info != null && shouldRequestBoundToken(info.certificate)) { @@ -384,6 +458,7 @@ static String getBoundTokenPayload() throws IOException { } @SuppressWarnings("unchecked") + /** Extracts the certificate path from the JSON configuration file. */ private static String extractCertPathFromConfig(String certConfigPath) throws IOException { try (InputStream stream = new FileInputStream(certConfigPath)) { JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); @@ -400,6 +475,7 @@ private static String extractCertPathFromConfig(String certConfigPath) throws IO } @SuppressWarnings("unchecked") + /** Extracts the private key path from the JSON configuration file. */ private static String extractKeyPathFromConfig(String certConfigPath) throws IOException { try (InputStream stream = new FileInputStream(certConfigPath)) { JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); @@ -415,6 +491,7 @@ private static String extractKeyPathFromConfig(String certConfigPath) throws IOE return null; } + /** Parses the X509 certificate from the specified path. */ private static X509Certificate parseCertificate(String certPath) throws IOException { try (InputStream stream = new FileInputStream(certPath)) { CertificateFactory cf = CertificateFactory.getInstance("X.509"); @@ -425,24 +502,33 @@ private static X509Certificate parseCertificate(String certPath) throws IOExcept } } + /** + * Determines if a bound token should be requested by checking if any of the certificate's Subject + * Alternative Names (SANs) match allowed SPIFFE patterns. + */ static boolean shouldRequestBoundToken(X509Certificate cert) { try { Collection> sans = cert.getSubjectAlternativeNames(); if (sans == null) { return false; } + // Iterate through all Subject Alternative Names for (List san : sans) { + // Check if the SAN entry is a URI (type 6) if (san.size() >= 2 && san.get(0) instanceof Integer && (Integer) san.get(0) == SAN_URI_TYPE) { Object value = san.get(1); if (value instanceof String) { String uri = (String) value; + // Check if the URI starts with "spiffe://" if (uri.startsWith(SPIFFE_SCHEME_PREFIX)) { String withoutScheme = uri.substring(SPIFFE_SCHEME_PREFIX.length()); int slashIndex = withoutScheme.indexOf('/'); + // Extract the trust domain (part before the first slash) String trustDomain = (slashIndex == -1) ? withoutScheme : withoutScheme.substring(0, slashIndex); + // Match the trust domain against allowed agent patterns for (Pattern pattern : AGENT_IDENTITY_SPIFFE_PATTERNS) { if (pattern.matcher(trustDomain).matches()) { return true; @@ -458,21 +544,8 @@ static boolean shouldRequestBoundToken(X509Certificate cert) { return false; } - static String calculateCertificateFingerprint(X509Certificate cert) throws IOException { - try { - MessageDigest md = MessageDigest.getInstance("SHA-256"); - byte[] der = cert.getEncoded(); - md.update(der); - byte[] digest = md.digest(); - String base64Fingerprint = BaseEncoding.base64().omitPadding().encode(digest); - return URLEncoder.encode(base64Fingerprint, "UTF-8"); - } catch (GeneralSecurityException e) { - throw new IOException("Failed to calculate fingerprint for Agent Identity certificate.", e); - } - } - @VisibleForTesting - static void setEnvReader(EnvReader reader) { + public static void setEnvReader(EnvReader reader) { envReader = reader; } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java index 6791b4a441fa..2c69aaf8cb1a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java @@ -130,19 +130,9 @@ private X509Certificate mockCertWithSanUri(String uri) throws CertificateExcepti return mockCert; } - @Test - public void calculateCertificateFingerprint_knownInput_returnsExpectedOutput() throws Exception { - X509Certificate mockCert = mock(X509Certificate.class); - byte[] fakeDer = new byte[] {0x01, 0x02, 0x03, 0x04, (byte) 0xFF}; - when(mockCert.getEncoded()).thenReturn(fakeDer); - String expectedFingerprint = "%2FEAuXk1xSDxtU3mEowwrTIsGVTmkvRsCbGESkmulJ5M"; - String actualFingerprint = AgentIdentityUtils.calculateCertificateFingerprint(mockCert); - assertEquals(expectedFingerprint, actualFingerprint); - } - @Test public void getAgentIdentityCertificate_optedOut_returnsNullImmediately() throws IOException { - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", "/non/existent/path"); assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 9931aec8459a..a36f758cafc7 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -112,7 +112,7 @@ public void sleep(long millis) { } }); // Opt out of bound tokens by default in tests to avoid polling delays - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); } @AfterEach @@ -1277,7 +1277,7 @@ InputStream readStream(File file) throws FileNotFoundException { } @Test void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); envProvider.setEnv( AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, tempDir.resolve("missing_config.json").toAbsolutePath().toString()); @@ -1306,11 +1306,13 @@ public void sleep(long millis) { } private void setupCertAndKeyConfig() throws IOException { - java.nio.file.Path certSource = java.nio.file.Paths.get("testresources/agent_spiffe_cert.pem"); + java.nio.file.Path certSource = + java.nio.file.Paths.get("testresources/agent/agent_spiffe_cert.pem"); java.nio.file.Path certTarget = tempDir.resolve("certificates.pem"); Files.copy(certSource, certTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); - java.nio.file.Path keySource = java.nio.file.Paths.get("testresources/agent_spiffe_key.pem"); + java.nio.file.Path keySource = + java.nio.file.Paths.get("testresources/agent/agent_spiffe_key.pem"); java.nio.file.Path keyTarget = tempDir.resolve("private_key.pem"); Files.copy(keySource, keyTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); @@ -1334,7 +1336,7 @@ private void setupCertAndKeyConfig() throws IOException { void refreshAccessToken_withValidCertAndKey_requestsBoundToken() throws IOException { setupCertAndKeyConfig(); envProvider.setEnv( - "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token + "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); // Enable bound token MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); @@ -1355,7 +1357,7 @@ void refreshAccessToken_withValidCertAndKey_requestsBoundToken() throws IOExcept void idTokenWithAudience_withValidCertAndKey_requestsBoundToken() throws IOException { setupCertAndKeyConfig(); envProvider.setEnv( - "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token + "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); // Enable bound token MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); transportFactory.transport.setIdToken(STANDARD_ID_TOKEN); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java index 0961db67ccb5..b00f05d11621 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java @@ -80,7 +80,7 @@ void setUp() { AgentIdentityUtils.setEnvReader( name -> { if ("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES".equals(name)) { - return "false"; // Triggers isOptedOut() = true + return "true"; // Triggers isOptedOut() = true } return null; }); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java index 9e80bc63477f..614a87e7d063 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java @@ -62,7 +62,7 @@ void setUp() { envProvider = new TestEnvironmentProvider(); AgentIdentityUtils.setEnvReader(envProvider::getEnv); // Opt out by default to avoid polling delays or file reads - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); } @AfterEach diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java index 524a312ce0c1..8a68dd6eebd5 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java @@ -65,7 +65,9 @@ import java.util.Arrays; import java.util.List; import java.util.Map; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -78,6 +80,23 @@ */ class LoggingTest { + @BeforeEach + void setUp() { + // Opt out of bound tokens by default to avoid polling delays + AgentIdentityUtils.setEnvReader( + name -> { + if ("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES".equals(name)) { + return "true"; + } + return null; + }); + } + + @AfterEach + void tearDown() { + AgentIdentityUtils.setEnvReader(System::getenv); + } + private TestAppender setupTestLogger(Class clazz) { TestAppender testAppender = new TestAppender(); testAppender.start(); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java index 1a27d3cdc98a..52c23c1f14a1 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java @@ -39,18 +39,50 @@ import com.google.api.client.json.gson.GsonFactory; import com.google.api.client.json.webtoken.JsonWebSignature; import com.google.auth.oauth2.AccessToken; +import com.google.auth.oauth2.AgentIdentityUtils; import com.google.auth.oauth2.ComputeEngineCredentials; import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.IdToken; import com.google.auth.oauth2.IdTokenCredentials; import com.google.auth.oauth2.IdTokenProvider; import com.google.auth.oauth2.OAuth2Utils; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; final class FTComputeEngineCredentialsTest { private final String computeUrl = "https://compute.googleapis.com/compute/v1/projects/gcloud-devel/zones/us-central1-a/instances"; + private static class TestEnvironmentProvider { + private final Map env = new HashMap<>(); + + void setEnv(String key, String value) { + env.put(key, value); + } + + String getEnv(String key) { + return env.get(key); + } + } + + private TestEnvironmentProvider envProvider; + + @BeforeEach + void setUp() { + envProvider = new TestEnvironmentProvider(); + AgentIdentityUtils.setEnvReader(envProvider::getEnv); + // Opt out by default to avoid polling delays or file reads + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); + } + + @AfterEach + void tearDown() { + AgentIdentityUtils.setEnvReader(System::getenv); + } + @Test void RefreshCredentials() throws Exception { final ComputeEngineCredentials credentials = ComputeEngineCredentials.create(); diff --git a/google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem b/google-auth-library-java/oauth2_http/testresources/agent/agent_spiffe_cert.pem similarity index 100% rename from google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem rename to google-auth-library-java/oauth2_http/testresources/agent/agent_spiffe_cert.pem diff --git a/google-auth-library-java/oauth2_http/testresources/agent_spiffe_key.pem b/google-auth-library-java/oauth2_http/testresources/agent/agent_spiffe_key.pem similarity index 100% rename from google-auth-library-java/oauth2_http/testresources/agent_spiffe_key.pem rename to google-auth-library-java/oauth2_http/testresources/agent/agent_spiffe_key.pem diff --git a/google-auth-library-java/oauth2_http/testresources/agent_cert.pem b/google-auth-library-java/oauth2_http/testresources/agent_cert.pem deleted file mode 100644 index 7af6ca3f9314..000000000000 --- a/google-auth-library-java/oauth2_http/testresources/agent_cert.pem +++ /dev/null @@ -1,19 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDIzCCAgugAwIBAgIJAMfISuBQ5m+5MA0GCSqGSIb3DQEBBQUAMBUxEzARBgNV -BAMTCnVuaXQtdGVzdHMwHhcNMTExMjA2MTYyNjAyWhcNMjExMjAzMTYyNjAyWjAV -MRMwEQYDVQQDEwp1bml0LXRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB -CgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZgkdmM -7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU1Wer -uQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYsSliS5qQp -gyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18pe+zpyl4 -+WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xkSBc//fy3 -ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABo3YwdDAdBgNVHQ4EFgQU2RQ8yO+O -gN8oVW2SW7RLrfYd9jEwRQYDVR0jBD4wPIAU2RQ8yO+OgN8oVW2SW7RLrfYd9jGh -GaQXMBUxEzARBgNVBAMTCnVuaXQtdGVzdHOCCQDHyErgUOZvuTAMBgNVHRMEBTAD -AQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBRv+M/6+FiVu7KXNjFI5pSN17OcW5QUtPr -odJMlWrJBtynn/TA1oJlYu3yV5clc/71Vr/AxuX5xGP+IXL32YDF9lTUJXG/uUGk -+JETpKmQviPbRsvzYhz4pf6ZIOZMc3/GIcNq92ECbseGO+yAgyWUVKMmZM0HqXC9 -ovNslqe0M8C1sLm1zAR5z/h/litE7/8O2ietija3Q/qtl2TOXJdCA6sgjJX2WUql -ybrC55ct18NKf3qhpcEkGQvFU40rVYApJpi98DiZPYFdx1oBDp/f4uZ3ojpxRVFT -cDwcJLfNRCPUhormsY7fDS9xSyThiHsW9mjJYdcaKQkwYZ0F11yB ------END CERTIFICATE----- From 7a2ae21e2163eb7666f893cac27c048abebdbc97 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Mon, 11 May 2026 18:24:04 -0700 Subject: [PATCH 05/25] lint fix. --- .../java/com/google/auth/oauth2/AgentIdentityUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 6c2fa2503dff..45513f2a697a 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -30,9 +30,9 @@ */ package com.google.auth.oauth2; -import com.google.api.core.InternalApi; import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonObjectParser; +import com.google.api.core.InternalApi; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; From 2c1c2985364c71e047dad225fe15c1629ec07523 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Wed, 22 Jul 2026 17:48:02 +0000 Subject: [PATCH 06/25] Fix unaddressed comments from PR 13169 --- .../auth/oauth2/AgentIdentityUtils.java | 74 ++++++++++--------- .../auth/oauth2/AgentIdentityUtilsTest.java | 3 +- .../oauth2/ComputeEngineCredentialsTest.java | 1 + 3 files changed, 43 insertions(+), 35 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 45513f2a697a..a3adbe973b67 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -194,11 +194,22 @@ static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(String certConfigPath) thr String keyPath = null; if (!Strings.isNullOrEmpty(certConfigPath)) { - // Read cert path from config file. We use retry with backoff to handle transient race - // conditions where the config file might be being updated by a rotation process. - certPath = getCertificatePathWithRetry(certConfigPath); - keyPath = extractKeyPathFromConfig(certConfigPath); + if (!Files.exists(Paths.get(certConfigPath)) && !Files.exists(Paths.get(wellKnownDir))) { + // Fail-fast if config doesn't exist and we're not in a workload environment (e.g. workstation) + return new ResolvedCertAndKeyPaths(null, null); + } + // Read cert and key paths from config file. We use retry with backoff to handle transient + // race conditions where the config file might be being updated by a rotation process. + ResolvedCertAndKeyPaths paths = getPathsFromConfigWithRetry(certConfigPath); + if (paths != null) { + certPath = paths.certPath; + keyPath = paths.keyPath; + } } else { + if (!Files.exists(Paths.get(wellKnownDir))) { + // Fail-fast if well-known dir doesn't exist (e.g. workstation) + return new ResolvedCertAndKeyPaths(null, null); + } // Fallback to well-known locations. We use retry with backoff here as well to handle // race conditions during file replacement by a rotation process. certPath = getWellKnownCertificatePathWithRetry(); @@ -276,14 +287,14 @@ private static boolean isOptedOut() { * Reads the certificate path from the config file with retry logic to handle rotation race * conditions. */ - private static String getCertificatePathWithRetry(String certConfigPath) throws IOException { + private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(String certConfigPath) throws IOException { boolean warned = false; for (long sleepInterval : POLLING_INTERVALS) { try { if (Files.exists(Paths.get(certConfigPath))) { - String certPath = extractCertPathFromConfig(certConfigPath); - if (!Strings.isNullOrEmpty(certPath) && Files.exists(Paths.get(certPath))) { - return certPath; + ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath); + if (paths != null && !Strings.isNullOrEmpty(paths.certPath) && Files.exists(Paths.get(paths.certPath))) { + return paths; } } } catch (IOException e) { @@ -295,7 +306,7 @@ private static String getCertificatePathWithRetry(String certConfigPath) throws org.slf4j.event.Level.WARN, Collections.emptyMap(), String.format( - "Certificate config file not found at %s (from %s environment variable). Retrying for up to %d seconds.", + "Certificate config file not found or invalid at %s (from %s environment variable). Retrying for up to %d seconds.", certConfigPath, GOOGLE_API_CERTIFICATE_CONFIG, TOTAL_TIMEOUT_MS / 1000)); warned = true; } @@ -443,7 +454,7 @@ else if ("false".equalsIgnoreCase(useClientCert)) { throw new IOException( "Certificate intent inferred via config, but cert files are missing."); } - // Neither cert-config nor certsexist, do not enable + // Neither cert-config nor certs exist, do not enable return false; } } @@ -458,35 +469,30 @@ static String getBoundTokenPayload() throws IOException { } @SuppressWarnings("unchecked") - /** Extracts the certificate path from the JSON configuration file. */ - private static String extractCertPathFromConfig(String certConfigPath) throws IOException { - try (InputStream stream = new FileInputStream(certConfigPath)) { - JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); - GenericJson config = parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class); - Map certConfigs = (Map) config.get("cert_configs"); - if (certConfigs != null) { - Map workload = (Map) certConfigs.get("workload"); - if (workload != null) { - return (String) workload.get("cert_path"); - } - } - } - return null; - } - - @SuppressWarnings("unchecked") - /** Extracts the private key path from the JSON configuration file. */ - private static String extractKeyPathFromConfig(String certConfigPath) throws IOException { + /** Extracts the certificate and private key paths from the JSON configuration file. */ + private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigPath) throws IOException { try (InputStream stream = new FileInputStream(certConfigPath)) { JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); GenericJson config = parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class); - Map certConfigs = (Map) config.get("cert_configs"); - if (certConfigs != null) { - Map workload = (Map) certConfigs.get("workload"); - if (workload != null) { - return (String) workload.get("key_path"); + Object certConfigsObj = config.get("cert_configs"); + if (certConfigsObj instanceof Map) { + Map certConfigs = (Map) certConfigsObj; + Object workloadObj = certConfigs.get("workload"); + if (workloadObj instanceof Map) { + Map workload = (Map) workloadObj; + String certPath = null; + String keyPath = null; + if (workload.get("cert_path") instanceof String) { + certPath = (String) workload.get("cert_path"); + } + if (workload.get("key_path") instanceof String) { + keyPath = (String) workload.get("key_path"); + } + return new ResolvedCertAndKeyPaths(certPath, keyPath); } } + } catch (Exception e) { + throw new IOException("Failed to parse Agent Identity config JSON", e); } return null; } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java index 2c69aaf8cb1a..74bdeb188d5e 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java @@ -140,7 +140,7 @@ public void getAgentIdentityCertificate_optedOut_returnsNullImmediately() throws @Test public void getAgentIdentityCertificate_noConfigEnvVar_returnsNull() throws IOException { AgentIdentityUtils.setTimeService(new FakeTimeService()); - assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); } @Test @@ -173,6 +173,7 @@ public void getAgentIdentityCertificate_timeout_throwsIOException() { envProvider.setEnv( "GOOGLE_API_CERTIFICATE_CONFIG", tempDir.resolve("missing.json").toAbsolutePath().toString()); + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); AgentIdentityUtils.setTimeService(new FakeTimeService()); IOException e = assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); assertTrue( diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index a36f758cafc7..7c28129ef417 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -1281,6 +1281,7 @@ void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { envProvider.setEnv( AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, tempDir.resolve("missing_config.json").toAbsolutePath().toString()); + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); final AtomicLong currentTime = new AtomicLong(0); AgentIdentityUtils.setTimeService( new AgentIdentityUtils.TimeService() { From 0e3d44016a8903cd706ccc693489e46b03d2ff21 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Wed, 22 Jul 2026 22:01:23 +0000 Subject: [PATCH 07/25] Fix incorrect fail-fast logic for custom certificate config paths --- .../java/com/google/auth/oauth2/AgentIdentityUtils.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index a3adbe973b67..418155d2fe8b 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -194,8 +194,10 @@ static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(String certConfigPath) thr String keyPath = null; if (!Strings.isNullOrEmpty(certConfigPath)) { - if (!Files.exists(Paths.get(certConfigPath)) && !Files.exists(Paths.get(wellKnownDir))) { - // Fail-fast if config doesn't exist and we're not in a workload environment (e.g. workstation) + java.nio.file.Path configPath = Paths.get(certConfigPath); + java.nio.file.Path parentPath = configPath.getParent(); + if (!Files.exists(configPath) && (parentPath == null || !Files.exists(parentPath))) { + // Fail-fast if config doesn't exist and its parent directory doesn't exist return new ResolvedCertAndKeyPaths(null, null); } // Read cert and key paths from config file. We use retry with backoff to handle transient From c5fab1d6ec455d13a9ef130660cb395cfd3fd671 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 23 Jul 2026 15:13:02 +0000 Subject: [PATCH 08/25] Fix AgentIdentityUtils token binding logic and TOCTOU vulnerabilities --- .../auth/oauth2/AgentIdentityUtils.java | 33 ++++++++++--------- .../auth/oauth2/AgentIdentityUtilsTest.java | 2 +- .../oauth2/ComputeEngineCredentialsTest.java | 12 +++---- .../DefaultCredentialsProviderTest.java | 2 +- .../auth/oauth2/IdTokenCredentialsTest.java | 3 +- 5 files changed, 27 insertions(+), 25 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 418155d2fe8b..2f93ca07eae4 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -133,11 +133,11 @@ private AgentIdentityUtils() {} static class CertInfo { final X509Certificate certificate; - final String path; + final String certContent; - CertInfo(X509Certificate certificate, String path) { + CertInfo(X509Certificate certificate, String certContent) { this.certificate = certificate; - this.path = path; + this.certContent = certContent; } } @@ -168,7 +168,7 @@ static class ResolvedCertAndKeyPaths { * verification fails after retries. */ static CertInfo getAgentIdentityCertInfo() throws IOException { - if (isOptedOut()) { + if (!isTokenBindingEnabled()) { return null; } String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG); @@ -232,6 +232,7 @@ static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(String certConfigPath) thr static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws IOException { X509Certificate cert = null; PrivateKey privateKey = null; + String certContent = null; if (!Strings.isNullOrEmpty(certPath) && !Strings.isNullOrEmpty(keyPath) @@ -242,7 +243,8 @@ static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws boolean matched = false; while (retries < 3) { try { - cert = parseCertificate(certPath); + certContent = readCertificateChain(certPath); + cert = parseCertificateContent(certContent); privateKey = readPrivateKey(keyPath, cert.getPublicKey().getAlgorithm()); if (verifyKeyPair(cert, privateKey)) { @@ -271,18 +273,19 @@ static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws } } else if (!Strings.isNullOrEmpty(certPath)) { // Bundle or only cert available - cert = parseCertificate(certPath); + certContent = readCertificateChain(certPath); + cert = parseCertificateContent(certContent); } - return new CertInfo(cert, certPath); + return new CertInfo(cert, certContent); } /** - * Checks if the user has opted out of token sharing by setting the environment variable to true. + * Checks if the user has disabled token binding by setting the environment variable to false. */ - private static boolean isOptedOut() { - String optOut = envReader.getEnv(GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES); - return "true".equalsIgnoreCase(optOut); + private static boolean isTokenBindingEnabled() { + String preventSharing = envReader.getEnv(GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES); + return !("false".equalsIgnoreCase(preventSharing)); } /** @@ -465,7 +468,7 @@ else if ("false".equalsIgnoreCase(useClientCert)) { static String getBoundTokenPayload() throws IOException { CertInfo info = getAgentIdentityCertInfo(); if (info != null && shouldRequestBoundToken(info.certificate)) { - return readCertificateChain(info.path); + return info.certContent; } return null; } @@ -499,9 +502,9 @@ private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigP return null; } - /** Parses the X509 certificate from the specified path. */ - private static X509Certificate parseCertificate(String certPath) throws IOException { - try (InputStream stream = new FileInputStream(certPath)) { + /** Parses the X509 certificate from the specified content string. */ + private static X509Certificate parseCertificateContent(String certContent) throws IOException { + try (InputStream stream = new java.io.ByteArrayInputStream(certContent.getBytes(StandardCharsets.UTF_8))) { CertificateFactory cf = CertificateFactory.getInstance("X.509"); return (X509Certificate) cf.generateCertificate(stream); } catch (GeneralSecurityException e) { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java index 74bdeb188d5e..49b697357d93 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java @@ -220,7 +220,7 @@ public void getAgentIdentityCertInfo_fallbackPath_loadsCertificate() throws IOEx AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); assertNotNull(info); - assertEquals(tempDir.resolve("certificates.pem").toAbsolutePath().toString(), info.path); + assertEquals(new String(Files.readAllBytes(tempDir.resolve("certificates.pem")), StandardCharsets.UTF_8), info.certContent); } @Test diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 7c28129ef417..ed2c891f5a8a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -112,7 +112,7 @@ public void sleep(long millis) { } }); // Opt out of bound tokens by default in tests to avoid polling delays - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); } @AfterEach @@ -1277,7 +1277,7 @@ InputStream readStream(File file) throws FileNotFoundException { } @Test void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); envProvider.setEnv( AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, tempDir.resolve("missing_config.json").toAbsolutePath().toString()); @@ -1308,12 +1308,12 @@ public void sleep(long millis) { private void setupCertAndKeyConfig() throws IOException { java.nio.file.Path certSource = - java.nio.file.Paths.get("testresources/agent/agent_spiffe_cert.pem"); + java.nio.file.Paths.get(ComputeEngineCredentialsTest.class.getResource("/agent/agent_spiffe_cert.pem").getPath()); java.nio.file.Path certTarget = tempDir.resolve("certificates.pem"); Files.copy(certSource, certTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); java.nio.file.Path keySource = - java.nio.file.Paths.get("testresources/agent/agent_spiffe_key.pem"); + java.nio.file.Paths.get(ComputeEngineCredentialsTest.class.getResource("/agent/agent_spiffe_key.pem").getPath()); java.nio.file.Path keyTarget = tempDir.resolve("private_key.pem"); Files.copy(keySource, keyTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); @@ -1337,7 +1337,7 @@ private void setupCertAndKeyConfig() throws IOException { void refreshAccessToken_withValidCertAndKey_requestsBoundToken() throws IOException { setupCertAndKeyConfig(); envProvider.setEnv( - "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); // Enable bound token + "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); @@ -1358,7 +1358,7 @@ void refreshAccessToken_withValidCertAndKey_requestsBoundToken() throws IOExcept void idTokenWithAudience_withValidCertAndKey_requestsBoundToken() throws IOException { setupCertAndKeyConfig(); envProvider.setEnv( - "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); // Enable bound token + "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); transportFactory.transport.setIdToken(STANDARD_ID_TOKEN); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java index b00f05d11621..7a5a50c4dd64 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java @@ -80,7 +80,7 @@ void setUp() { AgentIdentityUtils.setEnvReader( name -> { if ("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES".equals(name)) { - return "true"; // Triggers isOptedOut() = true + return "false"; // Triggers isTokenBindingEnabled() = false } return null; }); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java index 614a87e7d063..7a22aeaeb9b6 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java @@ -61,8 +61,7 @@ String getEnv(String key) { void setUp() { envProvider = new TestEnvironmentProvider(); AgentIdentityUtils.setEnvReader(envProvider::getEnv); - // Opt out by default to avoid polling delays or file reads - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); } @AfterEach From 0036d0cbd32b025c1ff141cc60fc1949c16a68f5 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 23 Jul 2026 18:47:34 +0000 Subject: [PATCH 09/25] Fix token binding 30s timeout on permission and absent config errors --- .../auth/oauth2/AgentIdentityUtils.java | 65 ++++++++++++++++--- 1 file changed, 55 insertions(+), 10 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 2f93ca07eae4..80d4606d0b51 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -195,9 +195,8 @@ static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(String certConfigPath) thr if (!Strings.isNullOrEmpty(certConfigPath)) { java.nio.file.Path configPath = Paths.get(certConfigPath); - java.nio.file.Path parentPath = configPath.getParent(); - if (!Files.exists(configPath) && (parentPath == null || !Files.exists(parentPath))) { - // Fail-fast if config doesn't exist and its parent directory doesn't exist + if (!Files.exists(configPath) && !Files.exists(Paths.get(wellKnownDir))) { + // Fail-fast if config doesn't exist and we are not in a workload environment return new ResolvedCertAndKeyPaths(null, null); } // Read cert and key paths from config file. We use retry with backoff to handle transient @@ -252,6 +251,13 @@ static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws break; } LOGGER.warn("Cert and key mismatch, retrying..."); + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading certificate or key files. Falling back to unbound token."); + return null; } catch (Exception e) { LOGGER.warn("Failed to read or verify cert/key, retrying...", e); } @@ -273,13 +279,36 @@ static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws } } else if (!Strings.isNullOrEmpty(certPath)) { // Bundle or only cert available - certContent = readCertificateChain(certPath); - cert = parseCertificateContent(certContent); + try { + certContent = readCertificateChain(certPath); + cert = parseCertificateContent(certContent); + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading certificate files. Falling back to unbound token."); + return null; + } } return new CertInfo(cert, certContent); } + /** + * Checks if a file exists, throwing AccessDeniedException if permission is denied. + */ + private static boolean checkExistsOrAccessDenied(java.nio.file.Path path) throws java.nio.file.AccessDeniedException { + try { + Files.readAttributes(path, java.nio.file.attribute.BasicFileAttributes.class); + return true; + } catch (java.nio.file.AccessDeniedException e) { + throw e; + } catch (IOException e) { + return false; + } + } + /** * Checks if the user has disabled token binding by setting the environment variable to false. */ @@ -296,12 +325,19 @@ private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(String certCo boolean warned = false; for (long sleepInterval : POLLING_INTERVALS) { try { - if (Files.exists(Paths.get(certConfigPath))) { + if (checkExistsOrAccessDenied(Paths.get(certConfigPath))) { ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath); - if (paths != null && !Strings.isNullOrEmpty(paths.certPath) && Files.exists(Paths.get(paths.certPath))) { + if (paths != null && !Strings.isNullOrEmpty(paths.certPath) && checkExistsOrAccessDenied(Paths.get(paths.certPath))) { return paths; } } + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading certificate config file. Falling back to unbound token."); + return null; } catch (IOException e) { // Fall through to retry } @@ -338,12 +374,19 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException boolean warned = false; for (long sleepInterval : POLLING_INTERVALS) { try { - if (Files.exists(Paths.get(bundlePath))) { + if (checkExistsOrAccessDenied(Paths.get(bundlePath))) { return bundlePath; } - if (Files.exists(Paths.get(certOnlyPath))) { + if (checkExistsOrAccessDenied(Paths.get(certOnlyPath))) { return certOnlyPath; } + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading well-known certificates. Falling back to unbound token."); + return null; } catch (Exception e) { // Fall through to retry } @@ -476,7 +519,7 @@ static String getBoundTokenPayload() throws IOException { @SuppressWarnings("unchecked") /** Extracts the certificate and private key paths from the JSON configuration file. */ private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigPath) throws IOException { - try (InputStream stream = new FileInputStream(certConfigPath)) { + try (InputStream stream = Files.newInputStream(Paths.get(certConfigPath))) { JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); GenericJson config = parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class); Object certConfigsObj = config.get("cert_configs"); @@ -496,6 +539,8 @@ private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigP return new ResolvedCertAndKeyPaths(certPath, keyPath); } } + } catch (java.nio.file.AccessDeniedException e) { + throw e; } catch (Exception e) { throw new IOException("Failed to parse Agent Identity config JSON", e); } From 4d99d25a78ba71e664710121ca95436c687ab023 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 23 Jul 2026 19:25:57 +0000 Subject: [PATCH 10/25] fix: resolve lint/formatting issues in AgentIdentityUtils --- .../java/com/google/auth/oauth2/AgentIdentityUtils.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 80d4606d0b51..1f0735634ac8 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -298,7 +298,8 @@ static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws /** * Checks if a file exists, throwing AccessDeniedException if permission is denied. */ - private static boolean checkExistsOrAccessDenied(java.nio.file.Path path) throws java.nio.file.AccessDeniedException { + private static boolean checkExistsOrAccessDenied(java.nio.file.Path path) + throws java.nio.file.AccessDeniedException { try { Files.readAttributes(path, java.nio.file.attribute.BasicFileAttributes.class); return true; @@ -327,7 +328,9 @@ private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(String certCo try { if (checkExistsOrAccessDenied(Paths.get(certConfigPath))) { ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath); - if (paths != null && !Strings.isNullOrEmpty(paths.certPath) && checkExistsOrAccessDenied(Paths.get(paths.certPath))) { + if (paths != null + && !Strings.isNullOrEmpty(paths.certPath) + && checkExistsOrAccessDenied(Paths.get(paths.certPath))) { return paths; } } From 727b59613d21b0838bce5e737e028081d5d55310 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 23 Jul 2026 19:31:02 +0000 Subject: [PATCH 11/25] fix: resolve checkstyle and format violations --- .../auth/oauth2/AgentIdentityUtils.java | 34 +++++++++++-------- .../auth/oauth2/AgentIdentityUtilsTest.java | 10 ++++-- .../oauth2/ComputeEngineCredentialsTest.java | 16 ++++++--- 3 files changed, 38 insertions(+), 22 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 1f0735634ac8..c8ddafec717b 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -36,7 +36,6 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; -import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -295,9 +294,7 @@ static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws return new CertInfo(cert, certContent); } - /** - * Checks if a file exists, throwing AccessDeniedException if permission is denied. - */ + /** Checks if a file exists, throwing AccessDeniedException if permission is denied. */ private static boolean checkExistsOrAccessDenied(java.nio.file.Path path) throws java.nio.file.AccessDeniedException { try { @@ -310,9 +307,7 @@ private static boolean checkExistsOrAccessDenied(java.nio.file.Path path) } } - /** - * Checks if the user has disabled token binding by setting the environment variable to false. - */ + /** Checks if the user has disabled token binding by setting the environment variable to false. */ private static boolean isTokenBindingEnabled() { String preventSharing = envReader.getEnv(GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES); return !("false".equalsIgnoreCase(preventSharing)); @@ -322,7 +317,8 @@ private static boolean isTokenBindingEnabled() { * Reads the certificate path from the config file with retry logic to handle rotation race * conditions. */ - private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(String certConfigPath) throws IOException { + private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(String certConfigPath) + throws IOException { boolean warned = false; for (long sleepInterval : POLLING_INTERVALS) { try { @@ -350,7 +346,8 @@ && checkExistsOrAccessDenied(Paths.get(paths.certPath))) { org.slf4j.event.Level.WARN, Collections.emptyMap(), String.format( - "Certificate config file not found or invalid at %s (from %s environment variable). Retrying for up to %d seconds.", + "Certificate config file not found or invalid at %s (from %s environment variable)." + + " Retrying for up to %d seconds.", certConfigPath, GOOGLE_API_CERTIFICATE_CONFIG, TOTAL_TIMEOUT_MS / 1000)); warned = true; } @@ -359,12 +356,15 @@ && checkExistsOrAccessDenied(Paths.get(paths.certPath))) { } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException( - "Interrupted while waiting for Agent Identity certificate files for bound token request.", + "Interrupted while waiting for Agent Identity certificate files for bound token" + + " request.", e); } } throw new IOException( - "Unable to find Agent Identity certificate config or file for bound token request after multiple retries. Token binding protection is failing. You can turn off this protection by setting " + "Unable to find Agent Identity certificate config or file for bound token request after" + + " multiple retries. Token binding protection is failing. You can turn off this" + + " protection by setting " + GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES + " to false to fall back to unbound tokens."); } @@ -411,7 +411,8 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException } } throw new IOException( - "Unable to find well-known certificate file for bound token request after multiple retries."); + "Unable to find well-known certificate file for bound token request after multiple" + + " retries."); } /** Reads the full certificate chain from the specified path as a string. */ @@ -489,7 +490,8 @@ else if ("false".equalsIgnoreCase(useClientCert)) { LOGGER, org.slf4j.event.Level.WARN, Collections.emptyMap(), - "Token binding protection is disabled because mTLS was explicitly disabled via GOOGLE_API_USE_CLIENT_CERTIFICATE."); + "Token binding protection is disabled because mTLS was explicitly disabled via" + + " GOOGLE_API_USE_CLIENT_CERTIFICATE."); return false; } return false; @@ -521,7 +523,8 @@ static String getBoundTokenPayload() throws IOException { @SuppressWarnings("unchecked") /** Extracts the certificate and private key paths from the JSON configuration file. */ - private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigPath) throws IOException { + private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigPath) + throws IOException { try (InputStream stream = Files.newInputStream(Paths.get(certConfigPath))) { JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); GenericJson config = parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class); @@ -552,7 +555,8 @@ private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigP /** Parses the X509 certificate from the specified content string. */ private static X509Certificate parseCertificateContent(String certContent) throws IOException { - try (InputStream stream = new java.io.ByteArrayInputStream(certContent.getBytes(StandardCharsets.UTF_8))) { + try (InputStream stream = + new java.io.ByteArrayInputStream(certContent.getBytes(StandardCharsets.UTF_8))) { CertificateFactory cf = CertificateFactory.getInstance("X.509"); return (X509Certificate) cf.generateCertificate(stream); } catch (GeneralSecurityException e) { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java index 49b697357d93..e529bf9a5ca2 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java @@ -179,7 +179,8 @@ public void getAgentIdentityCertificate_timeout_throwsIOException() { assertTrue( e.getMessage() .contains( - "Unable to find Agent Identity certificate config or file for bound token request after multiple retries.")); + "Unable to find Agent Identity certificate config or file for bound token request" + + " after multiple retries.")); } @Test @@ -220,7 +221,9 @@ public void getAgentIdentityCertInfo_fallbackPath_loadsCertificate() throws IOEx AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); assertNotNull(info); - assertEquals(new String(Files.readAllBytes(tempDir.resolve("certificates.pem")), StandardCharsets.UTF_8), info.certContent); + assertEquals( + new String(Files.readAllBytes(tempDir.resolve("certificates.pem")), StandardCharsets.UTF_8), + info.certContent); } @Test @@ -295,7 +298,8 @@ public void getAgentIdentityCertInfo_mismatch_throwsIOExceptionAfterRetries() th assertTrue( e.getMessage() .contains( - "Agent Identity certificate and private key mismatch or read failure after 3 retries.")); + "Agent Identity certificate and private key mismatch or read failure after 3" + + " retries.")); assertEquals(200, fakeTime.currentTimeMillis()); // 2 retries * 100ms } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index ed2c891f5a8a..f9272b657bea 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -536,7 +536,8 @@ void toString_explicit_containsFields() { new MockMetadataServerTransportFactory(); String expectedToString = String.format( - "ComputeEngineCredentials{quotaProjectId=%s, universeDomain=%s, isExplicitUniverseDomain=%s, transportFactoryClassName=%s, scopes=%s}", + "ComputeEngineCredentials{quotaProjectId=%s, universeDomain=%s," + + " isExplicitUniverseDomain=%s, transportFactoryClassName=%s, scopes=%s}", "some-project", "some-domain", true, @@ -1303,17 +1304,24 @@ public void sleep(long millis) { assertTrue( e.getMessage() .contains( - "Unable to find Agent Identity certificate config or file for bound token request after multiple retries.")); + "Unable to find Agent Identity certificate config or file for bound token request" + + " after multiple retries.")); } private void setupCertAndKeyConfig() throws IOException { java.nio.file.Path certSource = - java.nio.file.Paths.get(ComputeEngineCredentialsTest.class.getResource("/agent/agent_spiffe_cert.pem").getPath()); + java.nio.file.Paths.get( + ComputeEngineCredentialsTest.class + .getResource("/agent/agent_spiffe_cert.pem") + .getPath()); java.nio.file.Path certTarget = tempDir.resolve("certificates.pem"); Files.copy(certSource, certTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); java.nio.file.Path keySource = - java.nio.file.Paths.get(ComputeEngineCredentialsTest.class.getResource("/agent/agent_spiffe_key.pem").getPath()); + java.nio.file.Paths.get( + ComputeEngineCredentialsTest.class + .getResource("/agent/agent_spiffe_key.pem") + .getPath()); java.nio.file.Path keyTarget = tempDir.resolve("private_key.pem"); Files.copy(keySource, keyTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); From bd2f98b417df3730abf81821eb1dd60b76f32365 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Fri, 24 Jul 2026 03:19:17 +0000 Subject: [PATCH 12/25] test: add edge case coverage and refactor brittle resource loading --- .../auth/oauth2/AgentIdentityUtils.java | 13 +++++--- .../auth/oauth2/AgentIdentityUtilsTest.java | 33 +++++++++++++++++++ .../oauth2/ComputeEngineCredentialsTest.java | 28 ++++++++++------ 3 files changed, 60 insertions(+), 14 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index c8ddafec717b..2bb2f4cad9e1 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -83,6 +83,9 @@ static void setWellKnownDir(String dir) { } // Polling configuration + private static final int CERT_KEY_MATCH_RETRIES = 3; + private static final long CERT_KEY_MATCH_RETRY_INTERVAL_MS = 100; + private static final int FAST_POLL_CYCLES = 50; private static final long FAST_POLL_INTERVAL_MS = 100; // 0.1 seconds private static final long SLOW_POLL_INTERVAL_MS = 500; // 0.5 seconds @@ -239,7 +242,7 @@ static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws // Separate files, verify match with retry int retries = 0; boolean matched = false; - while (retries < 3) { + while (retries < CERT_KEY_MATCH_RETRIES) { try { certContent = readCertificateChain(certPath); cert = parseCertificateContent(certContent); @@ -262,9 +265,9 @@ static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws } retries++; - if (retries < 3) { + if (retries < CERT_KEY_MATCH_RETRIES) { try { - timeService.sleep(100); // 0.1 seconds backoff + timeService.sleep(CERT_KEY_MATCH_RETRY_INTERVAL_MS); // 0.1 seconds backoff } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("Interrupted while waiting for cert/key match.", e); @@ -274,7 +277,9 @@ static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws if (!matched) { throw new IOException( - "Agent Identity certificate and private key mismatch or read failure after 3 retries."); + String.format( + "Agent Identity certificate and private key mismatch or read failure after %d retries.", + CERT_KEY_MATCH_RETRIES)); } } else if (!Strings.isNullOrEmpty(certPath)) { // Bundle or only cert available diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java index e529bf9a5ca2..d960aed73022 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java @@ -115,6 +115,21 @@ public void shouldRequestBoundToken_invalidFormat_returnsFalse() throws Certific AgentIdentityUtils.shouldRequestBoundToken(mockCertWithSanUri(INVALID_SPIFFE_FORMAT))); } + @Test + public void shouldRequestBoundToken_certificateParsingException_returnsFalse() throws java.security.cert.CertificateParsingException { + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getSubjectAlternativeNames()).thenThrow(new java.security.cert.CertificateParsingException()); + assertFalse(AgentIdentityUtils.shouldRequestBoundToken(mockCert)); + } + + @Test + public void shouldRequestBoundToken_nonUriSan_returnsFalse() throws java.security.cert.CertificateParsingException { + X509Certificate mockCert = mock(X509Certificate.class); + List dnsSan = Arrays.asList(2, "www.example.com"); + when(mockCert.getSubjectAlternativeNames()).thenReturn(Collections.>singleton(dnsSan)); + assertFalse(AgentIdentityUtils.shouldRequestBoundToken(mockCert)); + } + @Test public void shouldRequestBoundToken_noSan_returnsFalse() throws CertificateException { X509Certificate mockCert = mock(X509Certificate.class); @@ -183,6 +198,24 @@ public void getAgentIdentityCertificate_timeout_throwsIOException() { + " after multiple retries.")); } + @Test + public void getAgentIdentityCertInfo_malformedJson_throwsIOException() throws IOException { + File configFile = tempDir.resolve("config.json").toFile(); + try (FileOutputStream fos = new FileOutputStream(configFile)) { + fos.write("{ \"cert_configs\": invalid json} ".getBytes(StandardCharsets.UTF_8)); + } + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath()); + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + AgentIdentityUtils.setTimeService(new FakeTimeService()); + + IOException e = assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); + assertTrue( + e.getMessage() + .contains( + "Unable to find Agent Identity certificate config or file for bound token request" + + " after multiple retries.")); + } + @Test public void shouldEnableMtls_true_certsPresent_returnsTrue() throws IOException { envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index f9272b657bea..abfc83602e1b 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -1309,19 +1309,27 @@ public void sleep(long millis) { } private void setupCertAndKeyConfig() throws IOException { - java.nio.file.Path certSource = - java.nio.file.Paths.get( - ComputeEngineCredentialsTest.class - .getResource("/agent/agent_spiffe_cert.pem") - .getPath()); + java.nio.file.Path certSource = null; + try { + certSource = java.nio.file.Paths.get( + ComputeEngineCredentialsTest.class + .getResource("/agent/agent_spiffe_cert.pem") + .toURI()); + } catch (java.net.URISyntaxException e) { + throw new IOException("Failed to load test resource", e); + } java.nio.file.Path certTarget = tempDir.resolve("certificates.pem"); Files.copy(certSource, certTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); - java.nio.file.Path keySource = - java.nio.file.Paths.get( - ComputeEngineCredentialsTest.class - .getResource("/agent/agent_spiffe_key.pem") - .getPath()); + java.nio.file.Path keySource = null; + try { + keySource = java.nio.file.Paths.get( + ComputeEngineCredentialsTest.class + .getResource("/agent/agent_spiffe_key.pem") + .toURI()); + } catch (java.net.URISyntaxException e) { + throw new IOException("Failed to load test resource", e); + } java.nio.file.Path keyTarget = tempDir.resolve("private_key.pem"); Files.copy(keySource, keyTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); From e3d105e4c8930fe736694adbe01285f5cef41324 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Mon, 27 Jul 2026 15:47:15 +0000 Subject: [PATCH 13/25] style: fix checkstyle violations in auth utilities # Conflicts: # google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java --- .../auth/oauth2/AgentIdentityUtils.java | 1119 +++++++++-------- 1 file changed, 595 insertions(+), 524 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 2bb2f4cad9e1..1824a8079b32 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -36,6 +36,10 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -53,588 +57,655 @@ import java.util.List; import java.util.Map; import java.util.regex.Pattern; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; /** Utility class for Agent Identity token binding in Cloud Run. */ @InternalApi public final class AgentIdentityUtils { - private static final Logger LOGGER = LoggerFactory.getLogger(AgentIdentityUtils.class); + /** Javadoc. */ + private static final Logger LOGGER = LoggerFactory.getLogger(AgentIdentityUtils.class); - // Environment variables - static final String GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG"; - static final String GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES = - "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES"; + // Environment variables + /** Javadoc. */ + static final String GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG"; - private static final List AGENT_IDENTITY_SPIFFE_PATTERNS = - ImmutableList.of( - Pattern.compile("^agents\\.global\\.org-\\d+\\.system\\.id\\.goog$"), - Pattern.compile("^agents\\.global\\.proj-\\d+\\.system\\.id\\.goog$")); + /** Javadoc. */ + static final String GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES = + "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES"; - private static final int SAN_URI_TYPE = 6; - private static final String SPIFFE_SCHEME_PREFIX = "spiffe://"; + /** Javadoc. */ + private static final List AGENT_IDENTITY_SPIFFE_PATTERNS = + ImmutableList.of( + Pattern.compile("^agents\\.global\\.org-\\d+\\.system\\.id\\.goog$"), + Pattern.compile("^agents\\.global\\.proj-\\d+\\.system\\.id\\.goog$")); - private static String wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/"; + /** Javadoc. */ + private static final int SAN_URI_TYPE = 6; - @VisibleForTesting - static void setWellKnownDir(String dir) { - wellKnownDir = dir; - } + /** Javadoc. */ + private static final String SPIFFE_SCHEME_PREFIX = "spiffe://"; - // Polling configuration - private static final int CERT_KEY_MATCH_RETRIES = 3; - private static final long CERT_KEY_MATCH_RETRY_INTERVAL_MS = 100; + /** Javadoc. */ + private static String wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/"; + + @VisibleForTesting + static void setWellKnownDir(final String dir) { + wellKnownDir = dir; + } - private static final int FAST_POLL_CYCLES = 50; - private static final long FAST_POLL_INTERVAL_MS = 100; // 0.1 seconds - private static final long SLOW_POLL_INTERVAL_MS = 500; // 0.5 seconds - private static final long TOTAL_TIMEOUT_MS = 30000; // 30 seconds - private static final List POLLING_INTERVALS; + // Polling configuration + /** Javadoc. */ + private static final int CERT_KEY_MATCH_RETRIES = 3; - static { - List intervals = new ArrayList<>(); - for (int i = 0; i < FAST_POLL_CYCLES; i++) { - intervals.add(FAST_POLL_INTERVAL_MS); + /** Javadoc. */ + private static final long CERT_KEY_MATCH_RETRY_INTERVAL_MS = 100; + + /** Javadoc. */ + private static final int FAST_POLL_CYCLES = 50; + + /** Javadoc. */ + private static final long FAST_POLL_INTERVAL_MS = 100; // 0.1 seconds + + /** Javadoc. */ + private static final long SLOW_POLL_INTERVAL_MS = 500; // 0.5 seconds + + /** Javadoc. */ + private static final long TOTAL_TIMEOUT_MS = 30000; // 30 seconds + + private static final List POLLING_INTERVALS; + + static { + List intervals = new ArrayList<>(); + for (int i = 0; i < FAST_POLL_CYCLES; i++) { + intervals.add(FAST_POLL_INTERVAL_MS); + } + long remainingTime = TOTAL_TIMEOUT_MS - (FAST_POLL_CYCLES * FAST_POLL_INTERVAL_MS); + int slowPollCycles = (int) (remainingTime / SLOW_POLL_INTERVAL_MS); + for (int i = 0; i < slowPollCycles; i++) { + intervals.add(SLOW_POLL_INTERVAL_MS); + } + POLLING_INTERVALS = Collections.unmodifiableList(intervals); } - long remainingTime = TOTAL_TIMEOUT_MS - (FAST_POLL_CYCLES * FAST_POLL_INTERVAL_MS); - int slowPollCycles = (int) (remainingTime / SLOW_POLL_INTERVAL_MS); - for (int i = 0; i < slowPollCycles; i++) { - intervals.add(SLOW_POLL_INTERVAL_MS); + + public interface EnvReader { + /** Javadoc. */ + String getEnv(String name); } - POLLING_INTERVALS = Collections.unmodifiableList(intervals); - } - public interface EnvReader { - String getEnv(String name); - } + /** Javadoc. */ + private static EnvReader envReader = System::getenv; + + @VisibleForTesting + interface TimeService { + long currentTimeMillis(); + + /** Javadoc. */ + void sleep(final long millis) throws InterruptedException; + } + + /** Javadoc. */ + private static TimeService timeService = + new TimeService() { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + + @Override + public void sleep(final long millis) throws InterruptedException { + Thread.sleep(millis); + } + }; + + private AgentIdentityUtils() {} - private static EnvReader envReader = System::getenv; + static class CertInfo { + /** Javadoc. */ + private final X509Certificate certificate; - @VisibleForTesting - interface TimeService { - long currentTimeMillis(); + /** Javadoc. */ + private final String certContent; - void sleep(long millis) throws InterruptedException; - } + CertInfo(final X509Certificate certificate, final String certContent) { + this.certificate = certificate; + this.certContent = certContent; + } - private static TimeService timeService = - new TimeService() { - @Override - public long currentTimeMillis() { - return System.currentTimeMillis(); + /** Javadoc. */ + public X509Certificate getCertificate() { + return certificate; } - @Override - public void sleep(long millis) throws InterruptedException { - Thread.sleep(millis); + /** Javadoc. */ + public String getCertContent() { + return certContent; } - }; + } + + static class ResolvedCertAndKeyPaths { + /** Javadoc. */ + private final String certPath; + + /** Javadoc. */ + private final String keyPath; - private AgentIdentityUtils() {} + ResolvedCertAndKeyPaths(final String certPath, final String keyPath) { + this.certPath = certPath; + this.keyPath = keyPath; + } - static class CertInfo { - final X509Certificate certificate; - final String certContent; + /** Javadoc. */ + public String getCertPath() { + return certPath; + } - CertInfo(X509Certificate certificate, String certContent) { - this.certificate = certificate; - this.certContent = certContent; + /** Javadoc. */ + public String getKeyPath() { + return keyPath; + } } - } - static class ResolvedCertAndKeyPaths { - final String certPath; - final String keyPath; + /** + * Retrieves the certificate and path for the Agent Identity. + * + *

This method attempts to load the certificate and private key for the agent identity. It + * first checks the location specified by the {@code GOOGLE_API_CERTIFICATE_CONFIG} environment + * variable. If not set, it falls back to well-known default locations. + * + *

To handle transient race conditions during certificate rotation on disk, this method + * employs a retry mechanism with backoff when reading the configuration and certificate files. + * + * @return A {@link CertInfo} object containing the loaded certificate and its path, or {@code + * null} if the agent identity features are disabled, opted out, or if no valid credentials + * could be loaded. + * @throws IOException If an I/O error occurs while reading the files, or if the key-pair + * verification fails after retries. + */ + static CertInfo getAgentIdentityCertInfo() throws IOException { + if (!isTokenBindingEnabled()) { + return null; + } + String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG); + boolean configExists = + !Strings.isNullOrEmpty(certConfigPath) && Files.exists(Paths.get(certConfigPath)); + + ResolvedCertAndKeyPaths paths = resolveCertAndKeyPaths(certConfigPath); + boolean certsPresent = !Strings.isNullOrEmpty(paths.getCertPath()); + + if (!shouldEnableMtls(certsPresent, configExists)) { + return null; + } - ResolvedCertAndKeyPaths(String certPath, String keyPath) { - this.certPath = certPath; - this.keyPath = keyPath; + return loadAndVerifyCredentials(paths.getCertPath(), paths.getKeyPath()); } - } - - /** - * Retrieves the certificate and path for the Agent Identity. - * - *

This method attempts to load the certificate and private key for the agent identity. It - * first checks the location specified by the {@code GOOGLE_API_CERTIFICATE_CONFIG} environment - * variable. If not set, it falls back to well-known default locations. - * - *

To handle transient race conditions during certificate rotation on disk, this method employs - * a retry mechanism with backoff when reading the configuration and certificate files. - * - * @return A {@link CertInfo} object containing the loaded certificate and its path, or {@code - * null} if the agent identity features are disabled, opted out, or if no valid credentials - * could be loaded. - * @throws IOException If an I/O error occurs while reading the files, or if the key-pair - * verification fails after retries. - */ - static CertInfo getAgentIdentityCertInfo() throws IOException { - if (!isTokenBindingEnabled()) { - return null; + + /** + * Resolves the paths for the certificate and private key based on the config path or well-known + * locations. + */ + static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(String certConfigPath) + throws IOException { + String certPath = null; + String keyPath = null; + + if (!Strings.isNullOrEmpty(certConfigPath)) { + java.nio.file.Path configPath = Paths.get(certConfigPath); + if (!Files.exists(configPath) && !Files.exists(Paths.get(wellKnownDir))) { + // Fail-fast if config doesn't exist and we are not in a workload environment + return new ResolvedCertAndKeyPaths(null, null); + } + // Read cert and key paths from config file. We use retry with backoff to handle + // transient + // race conditions where the config file might be being updated by a rotation process. + ResolvedCertAndKeyPaths paths = getPathsFromConfigWithRetry(certConfigPath); + if (paths != null) { + certPath = paths.getCertPath(); + keyPath = paths.getKeyPath(); + } + } else { + if (!Files.exists(Paths.get(wellKnownDir))) { + // Fail-fast if well-known dir doesn't exist (e.g. workstation) + return new ResolvedCertAndKeyPaths(null, null); + } + // Fallback to well-known locations. We use retry with backoff here as well to handle + // race conditions during file replacement by a rotation process. + certPath = getWellKnownCertificatePathWithRetry(); + if (certPath != null) { + if (certPath.endsWith("credentialbundle.pem")) { + keyPath = certPath; // Bundle contains both + } else if (certPath.endsWith("certificates.pem")) { + keyPath = Paths.get(wellKnownDir, "private_key.pem").toString(); + } + } + } + return new ResolvedCertAndKeyPaths(certPath, keyPath); } - String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG); - boolean configExists = - !Strings.isNullOrEmpty(certConfigPath) && Files.exists(Paths.get(certConfigPath)); - ResolvedCertAndKeyPaths paths = resolveCertAndKeyPaths(certConfigPath); - boolean certsPresent = !Strings.isNullOrEmpty(paths.certPath); + /** + * Loads the certificate and private key, and verifies that they match if they are separate + * files. + */ + static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws IOException { + X509Certificate cert = null; + PrivateKey privateKey = null; + String certContent = null; + + if (!Strings.isNullOrEmpty(certPath) + && !Strings.isNullOrEmpty(keyPath) + && !certPath.equals(keyPath) + && Files.exists(Paths.get(keyPath))) { + // Separate files, verify match with retry + int retries = 0; + boolean matched = false; + while (retries < CERT_KEY_MATCH_RETRIES) { + try { + certContent = readCertificateChain(certPath); + cert = parseCertificateContent(certContent); + privateKey = readPrivateKey(keyPath, cert.getPublicKey().getAlgorithm()); + + if (verifyKeyPair(cert, privateKey)) { + matched = true; + break; + } + LOGGER.warn("Cert and key mismatch, retrying..."); + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading certificate or key files. Falling back to" + + " unbound token."); + return null; + } catch (Exception e) { + LOGGER.warn("Failed to read or verify cert/key, retrying...", e); + } - if (!shouldEnableMtls(certsPresent, configExists)) { - return null; - } + retries++; + if (retries < CERT_KEY_MATCH_RETRIES) { + try { + timeService.sleep(CERT_KEY_MATCH_RETRY_INTERVAL_MS); // 0.1 seconds backoff + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for cert/key match.", e); + } + } + } - return loadAndVerifyCredentials(paths.certPath, paths.keyPath); - } - - /** - * Resolves the paths for the certificate and private key based on the config path or well-known - * locations. - */ - static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(String certConfigPath) throws IOException { - String certPath = null; - String keyPath = null; - - if (!Strings.isNullOrEmpty(certConfigPath)) { - java.nio.file.Path configPath = Paths.get(certConfigPath); - if (!Files.exists(configPath) && !Files.exists(Paths.get(wellKnownDir))) { - // Fail-fast if config doesn't exist and we are not in a workload environment - return new ResolvedCertAndKeyPaths(null, null); - } - // Read cert and key paths from config file. We use retry with backoff to handle transient - // race conditions where the config file might be being updated by a rotation process. - ResolvedCertAndKeyPaths paths = getPathsFromConfigWithRetry(certConfigPath); - if (paths != null) { - certPath = paths.certPath; - keyPath = paths.keyPath; - } - } else { - if (!Files.exists(Paths.get(wellKnownDir))) { - // Fail-fast if well-known dir doesn't exist (e.g. workstation) - return new ResolvedCertAndKeyPaths(null, null); - } - // Fallback to well-known locations. We use retry with backoff here as well to handle - // race conditions during file replacement by a rotation process. - certPath = getWellKnownCertificatePathWithRetry(); - if (certPath != null) { - if (certPath.endsWith("credentialbundle.pem")) { - keyPath = certPath; // Bundle contains both - } else if (certPath.endsWith("certificates.pem")) { - keyPath = Paths.get(wellKnownDir, "private_key.pem").toString(); + if (!matched) { + throw new IOException( + String.format( + "Agent Identity certificate and private key mismatch or read" + + " failure after %d retries.", + CERT_KEY_MATCH_RETRIES)); + } + } else if (!Strings.isNullOrEmpty(certPath)) { + // Bundle or only cert available + try { + certContent = readCertificateChain(certPath); + cert = parseCertificateContent(certContent); + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading certificate files. Falling back to unbound" + + " token."); + return null; + } } - } + + return new CertInfo(cert, certContent); } - return new ResolvedCertAndKeyPaths(certPath, keyPath); - } - - /** - * Loads the certificate and private key, and verifies that they match if they are separate files. - */ - static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws IOException { - X509Certificate cert = null; - PrivateKey privateKey = null; - String certContent = null; - - if (!Strings.isNullOrEmpty(certPath) - && !Strings.isNullOrEmpty(keyPath) - && !certPath.equals(keyPath) - && Files.exists(Paths.get(keyPath))) { - // Separate files, verify match with retry - int retries = 0; - boolean matched = false; - while (retries < CERT_KEY_MATCH_RETRIES) { + + /** Checks if a file exists, throwing AccessDeniedException if permission is denied. */ + private static boolean checkExistsOrAccessDenied(java.nio.file.Path path) + throws java.nio.file.AccessDeniedException { try { - certContent = readCertificateChain(certPath); - cert = parseCertificateContent(certContent); - privateKey = readPrivateKey(keyPath, cert.getPublicKey().getAlgorithm()); - - if (verifyKeyPair(cert, privateKey)) { - matched = true; - break; - } - LOGGER.warn("Cert and key mismatch, retrying..."); + Files.readAttributes(path, java.nio.file.attribute.BasicFileAttributes.class); + return true; } catch (java.nio.file.AccessDeniedException e) { - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - "Permission denied reading certificate or key files. Falling back to unbound token."); - return null; - } catch (Exception e) { - LOGGER.warn("Failed to read or verify cert/key, retrying...", e); - } - - retries++; - if (retries < CERT_KEY_MATCH_RETRIES) { - try { - timeService.sleep(CERT_KEY_MATCH_RETRY_INTERVAL_MS); // 0.1 seconds backoff - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("Interrupted while waiting for cert/key match.", e); - } + throw e; + } catch (IOException e) { + return false; } - } - - if (!matched) { - throw new IOException( - String.format( - "Agent Identity certificate and private key mismatch or read failure after %d retries.", - CERT_KEY_MATCH_RETRIES)); - } - } else if (!Strings.isNullOrEmpty(certPath)) { - // Bundle or only cert available - try { - certContent = readCertificateChain(certPath); - cert = parseCertificateContent(certContent); - } catch (java.nio.file.AccessDeniedException e) { - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - "Permission denied reading certificate files. Falling back to unbound token."); - return null; - } } - return new CertInfo(cert, certContent); - } - - /** Checks if a file exists, throwing AccessDeniedException if permission is denied. */ - private static boolean checkExistsOrAccessDenied(java.nio.file.Path path) - throws java.nio.file.AccessDeniedException { - try { - Files.readAttributes(path, java.nio.file.attribute.BasicFileAttributes.class); - return true; - } catch (java.nio.file.AccessDeniedException e) { - throw e; - } catch (IOException e) { - return false; + /** + * Checks if the user has disabled token binding by setting the environment variable to false. + */ + private static boolean isTokenBindingEnabled() { + String preventSharing = envReader.getEnv(GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES); + return !("false".equalsIgnoreCase(preventSharing)); } - } - - /** Checks if the user has disabled token binding by setting the environment variable to false. */ - private static boolean isTokenBindingEnabled() { - String preventSharing = envReader.getEnv(GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES); - return !("false".equalsIgnoreCase(preventSharing)); - } - - /** - * Reads the certificate path from the config file with retry logic to handle rotation race - * conditions. - */ - private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(String certConfigPath) - throws IOException { - boolean warned = false; - for (long sleepInterval : POLLING_INTERVALS) { - try { - if (checkExistsOrAccessDenied(Paths.get(certConfigPath))) { - ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath); - if (paths != null - && !Strings.isNullOrEmpty(paths.certPath) - && checkExistsOrAccessDenied(Paths.get(paths.certPath))) { - return paths; - } + + /** + * Reads the certificate path from the config file with retry logic to handle rotation race + * conditions. + */ + private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(String certConfigPath) + throws IOException { + boolean warned = false; + for (long sleepInterval : POLLING_INTERVALS) { + try { + if (checkExistsOrAccessDenied(Paths.get(certConfigPath))) { + ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath); + if (paths != null + && !Strings.isNullOrEmpty(paths.getCertPath()) + && checkExistsOrAccessDenied(Paths.get(paths.getCertPath()))) { + return paths; + } + } + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading certificate config file. Falling back to unbound" + + " token."); + return null; + } catch (IOException e) { + // Fall through to retry + } + if (!warned) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + String.format( + "Certificate config file not found or invalid at %s (from %s" + + " environment variable). Retrying for up to %d seconds.", + certConfigPath, + GOOGLE_API_CERTIFICATE_CONFIG, + TOTAL_TIMEOUT_MS / 1000)); + warned = true; + } + try { + timeService.sleep(sleepInterval); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException( + "Interrupted while waiting for Agent Identity certificate files for bound" + + " token request.", + e); + } } - } catch (java.nio.file.AccessDeniedException e) { - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - "Permission denied reading certificate config file. Falling back to unbound token."); - return null; - } catch (IOException e) { - // Fall through to retry - } - if (!warned) { - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - String.format( - "Certificate config file not found or invalid at %s (from %s environment variable)." - + " Retrying for up to %d seconds.", - certConfigPath, GOOGLE_API_CERTIFICATE_CONFIG, TOTAL_TIMEOUT_MS / 1000)); - warned = true; - } - try { - timeService.sleep(sleepInterval); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); throw new IOException( - "Interrupted while waiting for Agent Identity certificate files for bound token" - + " request.", - e); - } + "Unable to find Agent Identity certificate config or file for bound token request" + + " after multiple retries. Token binding protection is failing. You can turn" + + " off this protection by setting " + + GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES + + " to false to fall back to unbound tokens."); } - throw new IOException( - "Unable to find Agent Identity certificate config or file for bound token request after" - + " multiple retries. Token binding protection is failing. You can turn off this" - + " protection by setting " - + GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES - + " to false to fall back to unbound tokens."); - } - - /** Searches for certificates at well-known locations with retry logic. */ - private static String getWellKnownCertificatePathWithRetry() throws IOException { - String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString(); - String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString(); - - boolean warned = false; - for (long sleepInterval : POLLING_INTERVALS) { - try { - if (checkExistsOrAccessDenied(Paths.get(bundlePath))) { - return bundlePath; - } - if (checkExistsOrAccessDenied(Paths.get(certOnlyPath))) { - return certOnlyPath; + + /** Searches for certificates at well-known locations with retry logic. */ + private static String getWellKnownCertificatePathWithRetry() throws IOException { + String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString(); + String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString(); + + boolean warned = false; + for (long sleepInterval : POLLING_INTERVALS) { + try { + if (checkExistsOrAccessDenied(Paths.get(bundlePath))) { + return bundlePath; + } + if (checkExistsOrAccessDenied(Paths.get(certOnlyPath))) { + return certOnlyPath; + } + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading well-known certificates. Falling back to unbound" + + " token."); + return null; + } catch (Exception e) { + // Fall through to retry + } + if (!warned) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + String.format( + "Well-known certificate file not found at %s. Retrying for up to %d" + + " seconds.", + wellKnownDir, TOTAL_TIMEOUT_MS / 1000)); + warned = true; + } + try { + timeService.sleep(sleepInterval); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException( + "Interrupted while waiting for well-known certificate files.", e); + } } - } catch (java.nio.file.AccessDeniedException e) { - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - "Permission denied reading well-known certificates. Falling back to unbound token."); - return null; - } catch (Exception e) { - // Fall through to retry - } - if (!warned) { - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - String.format( - "Well-known certificate file not found at %s. Retrying for up to %d seconds.", - wellKnownDir, TOTAL_TIMEOUT_MS / 1000)); - warned = true; - } - try { - timeService.sleep(sleepInterval); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("Interrupted while waiting for well-known certificate files.", e); - } + throw new IOException( + "Unable to find well-known certificate file for bound token request after multiple" + + " retries."); } - throw new IOException( - "Unable to find well-known certificate file for bound token request after multiple" - + " retries."); - } - - /** Reads the full certificate chain from the specified path as a string. */ - static String readCertificateChain(String certPath) throws IOException { - return new String(Files.readAllBytes(Paths.get(certPath)), StandardCharsets.UTF_8); - } - - /** - * Verifies that the private key corresponds to the public key in the certificate by performing a - * test signature and verification. - */ - static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { - try { - byte[] data = "verification-data".getBytes(StandardCharsets.UTF_8); - - String keyAlgorithm = cert.getPublicKey().getAlgorithm(); - String sigAlg; - if ("RSA".equals(keyAlgorithm)) { - sigAlg = "SHA256withRSA"; - } else if ("EC".equals(keyAlgorithm)) { - sigAlg = "SHA256withECDSA"; - } else { - throw new IllegalArgumentException("Unsupported key algorithm: " + keyAlgorithm); - } - - Signature signer = Signature.getInstance(sigAlg); - signer.initSign(privateKey); - signer.update(data); - byte[] signature = signer.sign(); - - Signature verifier = Signature.getInstance(sigAlg); - verifier.initVerify(cert.getPublicKey()); - verifier.update(data); - - return verifier.verify(signature); - } catch (Exception e) { - LOGGER.warn("Key pair verification failed", e); - return false; + + /** Reads the full certificate chain from the specified path as a string. */ + static String readCertificateChain(String certPath) throws IOException { + return new String(Files.readAllBytes(Paths.get(certPath)), StandardCharsets.UTF_8); } - } - - /** Reads the private key from the specified path using PKCS8 format. */ - static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOException { - String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); - OAuth2Utils.Pkcs8Algorithm pkcs8Alg = - "EC".equals(algorithm) ? OAuth2Utils.Pkcs8Algorithm.EC : OAuth2Utils.Pkcs8Algorithm.RSA; - return OAuth2Utils.privateKeyFromPkcs8(keyPem, pkcs8Alg); - } - - /** - * Determines if mTLS should be enabled based on environment variables and certificate presence. - */ - static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) throws IOException { - String useClientCert = envReader.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); - - // Case 1: Explicitly enabled via environment variable - if ("true".equalsIgnoreCase(useClientCert)) { - if (certsPresent) { - // Certs are available, enable mTLS - return true; - } - if (configExists) { - // Config exists but files are missing - fail fast - throw new IOException( - "Certificate intent established via config, but cert files are missing."); - } - // Neither exist, do not enable - return false; + + /** + * Verifies that the private key corresponds to the public key in the certificate by performing + * a test signature and verification. + */ + static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { + try { + byte[] data = "verification-data".getBytes(StandardCharsets.UTF_8); + + String keyAlgorithm = cert.getPublicKey().getAlgorithm(); + String sigAlg; + if ("RSA".equals(keyAlgorithm)) { + sigAlg = "SHA256withRSA"; + } else if ("EC".equals(keyAlgorithm)) { + sigAlg = "SHA256withECDSA"; + } else { + throw new IllegalArgumentException("Unsupported key algorithm: " + keyAlgorithm); + } + + Signature signer = Signature.getInstance(sigAlg); + signer.initSign(privateKey); + signer.update(data); + byte[] signature = signer.sign(); + + Signature verifier = Signature.getInstance(sigAlg); + verifier.initVerify(cert.getPublicKey()); + verifier.update(data); + + return verifier.verify(signature); + } catch (Exception e) { + LOGGER.warn("Key pair verification failed", e); + return false; + } } - // Case 2: Explicitly disabled via environment variable - else if ("false".equalsIgnoreCase(useClientCert)) { - if (certsPresent) { - // Warn that we are ignoring present certs because it was explicitly disabled - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - "Token binding protection is disabled because mTLS was explicitly disabled via" - + " GOOGLE_API_USE_CLIENT_CERTIFICATE."); - return false; - } - return false; + + /** Reads the private key from the specified path using PKCS8 format. */ + static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOException { + String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); + OAuth2Utils.Pkcs8Algorithm pkcs8Alg = + "EC".equals(algorithm) + ? OAuth2Utils.Pkcs8Algorithm.EC + : OAuth2Utils.Pkcs8Algorithm.RSA; + return OAuth2Utils.privateKeyFromPkcs8(keyPem, pkcs8Alg); } - // Case 3: Environment variable is unset - else { - if (certsPresent) { - // Infer mTLS is enabled because certs are present - return true; - } - if (configExists) { - // Config exists but files are missing - fail fast - throw new IOException( - "Certificate intent inferred via config, but cert files are missing."); - } - // Neither cert-config nor certs exist, do not enable - return false; + + /** + * Determines if mTLS should be enabled based on environment variables and certificate presence. + */ + static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) throws IOException { + String useClientCert = envReader.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); + + // Case 1: Explicitly enabled via environment variable + if ("true".equalsIgnoreCase(useClientCert)) { + if (certsPresent) { + // Certs are available, enable mTLS + return true; + } + if (configExists) { + // Config exists but files are missing - fail fast + throw new IOException( + "Certificate intent established via config, but cert files are missing."); + } + // Neither exist, do not enable + return false; + } + // Case 2: Explicitly disabled via environment variable + else if ("false".equalsIgnoreCase(useClientCert)) { + if (certsPresent) { + // Warn that we are ignoring present certs because it was explicitly disabled + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Token binding protection is disabled because mTLS was explicitly disabled" + + " via GOOGLE_API_USE_CLIENT_CERTIFICATE."); + return false; + } + return false; + } + // Case 3: Environment variable is unset + else { + if (certsPresent) { + // Infer mTLS is enabled because certs are present + return true; + } + if (configExists) { + // Config exists but files are missing - fail fast + throw new IOException( + "Certificate intent inferred via config, but cert files are missing."); + } + // Neither cert-config nor certs exist, do not enable + return false; + } } - } - /** Retrieves the bound token payload (certificate chain) if applicable. */ - static String getBoundTokenPayload() throws IOException { - CertInfo info = getAgentIdentityCertInfo(); - if (info != null && shouldRequestBoundToken(info.certificate)) { - return info.certContent; + /** Retrieves the bound token payload (certificate chain) if applicable. */ + static String getBoundTokenPayload() throws IOException { + CertInfo info = getAgentIdentityCertInfo(); + if (info != null && shouldRequestBoundToken(info.getCertificate())) { + return info.getCertContent(); + } + return null; } - return null; - } - - @SuppressWarnings("unchecked") - /** Extracts the certificate and private key paths from the JSON configuration file. */ - private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigPath) - throws IOException { - try (InputStream stream = Files.newInputStream(Paths.get(certConfigPath))) { - JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); - GenericJson config = parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class); - Object certConfigsObj = config.get("cert_configs"); - if (certConfigsObj instanceof Map) { - Map certConfigs = (Map) certConfigsObj; - Object workloadObj = certConfigs.get("workload"); - if (workloadObj instanceof Map) { - Map workload = (Map) workloadObj; - String certPath = null; - String keyPath = null; - if (workload.get("cert_path") instanceof String) { - certPath = (String) workload.get("cert_path"); - } - if (workload.get("key_path") instanceof String) { - keyPath = (String) workload.get("key_path"); - } - return new ResolvedCertAndKeyPaths(certPath, keyPath); + + @SuppressWarnings("unchecked") + /** Extracts the certificate and private key paths from the JSON configuration file. */ + private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigPath) + throws IOException { + try (InputStream stream = Files.newInputStream(Paths.get(certConfigPath))) { + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson config = + parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class); + Object certConfigsObj = config.get("cert_configs"); + if (certConfigsObj instanceof Map) { + Map certConfigs = (Map) certConfigsObj; + Object workloadObj = certConfigs.get("workload"); + if (workloadObj instanceof Map) { + Map workload = (Map) workloadObj; + String certPath = null; + String keyPath = null; + if (workload.get("cert_path") instanceof String) { + certPath = (String) workload.get("cert_path"); + } + if (workload.get("key_path") instanceof String) { + keyPath = (String) workload.get("key_path"); + } + return new ResolvedCertAndKeyPaths(certPath, keyPath); + } + } + } catch (java.nio.file.AccessDeniedException e) { + throw e; + } catch (Exception e) { + throw new IOException("Failed to parse Agent Identity config JSON", e); } - } - } catch (java.nio.file.AccessDeniedException e) { - throw e; - } catch (Exception e) { - throw new IOException("Failed to parse Agent Identity config JSON", e); + return null; } - return null; - } - - /** Parses the X509 certificate from the specified content string. */ - private static X509Certificate parseCertificateContent(String certContent) throws IOException { - try (InputStream stream = - new java.io.ByteArrayInputStream(certContent.getBytes(StandardCharsets.UTF_8))) { - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - return (X509Certificate) cf.generateCertificate(stream); - } catch (GeneralSecurityException e) { - throw new IOException( - "Failed to parse Agent Identity certificate for bound token request.", e); + + /** Parses the X509 certificate from the specified content string. */ + private static X509Certificate parseCertificateContent(String certContent) throws IOException { + try (InputStream stream = + new java.io.ByteArrayInputStream(certContent.getBytes(StandardCharsets.UTF_8))) { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate) cf.generateCertificate(stream); + } catch (GeneralSecurityException e) { + throw new IOException( + "Failed to parse Agent Identity certificate for bound token request.", e); + } } - } - - /** - * Determines if a bound token should be requested by checking if any of the certificate's Subject - * Alternative Names (SANs) match allowed SPIFFE patterns. - */ - static boolean shouldRequestBoundToken(X509Certificate cert) { - try { - Collection> sans = cert.getSubjectAlternativeNames(); - if (sans == null) { - return false; - } - // Iterate through all Subject Alternative Names - for (List san : sans) { - // Check if the SAN entry is a URI (type 6) - if (san.size() >= 2 - && san.get(0) instanceof Integer - && (Integer) san.get(0) == SAN_URI_TYPE) { - Object value = san.get(1); - if (value instanceof String) { - String uri = (String) value; - // Check if the URI starts with "spiffe://" - if (uri.startsWith(SPIFFE_SCHEME_PREFIX)) { - String withoutScheme = uri.substring(SPIFFE_SCHEME_PREFIX.length()); - int slashIndex = withoutScheme.indexOf('/'); - // Extract the trust domain (part before the first slash) - String trustDomain = - (slashIndex == -1) ? withoutScheme : withoutScheme.substring(0, slashIndex); - // Match the trust domain against allowed agent patterns - for (Pattern pattern : AGENT_IDENTITY_SPIFFE_PATTERNS) { - if (pattern.matcher(trustDomain).matches()) { - return true; + + /** + * Determines if a bound token should be requested by checking if any of the certificate's + * Subject Alternative Names (SANs) match allowed SPIFFE patterns. + */ + static boolean shouldRequestBoundToken(X509Certificate cert) { + try { + Collection> sans = cert.getSubjectAlternativeNames(); + if (sans == null) { + return false; + } + // Iterate through all Subject Alternative Names + for (List san : sans) { + // Check if the SAN entry is a URI (type 6) + if (san.size() >= 2 + && san.get(0) instanceof Integer + && (Integer) san.get(0) == SAN_URI_TYPE) { + Object value = san.get(1); + if (value instanceof String) { + String uri = (String) value; + // Check if the URI starts with "spiffe://" + if (uri.startsWith(SPIFFE_SCHEME_PREFIX)) { + String withoutScheme = uri.substring(SPIFFE_SCHEME_PREFIX.length()); + int slashIndex = withoutScheme.indexOf('/'); + // Extract the trust domain (part before the first slash) + String trustDomain = + (slashIndex == -1) + ? withoutScheme + : withoutScheme.substring(0, slashIndex); + // Match the trust domain against allowed agent patterns + for (Pattern pattern : AGENT_IDENTITY_SPIFFE_PATTERNS) { + if (pattern.matcher(trustDomain).matches()) { + return true; + } + } + } + } } - } } - } + } catch (CertificateParsingException e) { + LOGGER.warn("Failed to parse Subject Alternative Names from certificate", e); } - } - } catch (CertificateParsingException e) { - LOGGER.warn("Failed to parse Subject Alternative Names from certificate", e); + return false; + } + + @VisibleForTesting + public static void setEnvReader(EnvReader reader) { + envReader = reader; + } + + @VisibleForTesting + static void setTimeService(TimeService service) { + timeService = service; + } + + @VisibleForTesting + static void resetTimeService() { + timeService = + new TimeService() { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + + @Override + public void sleep(final long millis) throws InterruptedException { + Thread.sleep(millis); + } + }; } - return false; - } - - @VisibleForTesting - public static void setEnvReader(EnvReader reader) { - envReader = reader; - } - - @VisibleForTesting - static void setTimeService(TimeService service) { - timeService = service; - } - - @VisibleForTesting - static void resetTimeService() { - timeService = - new TimeService() { - @Override - public long currentTimeMillis() { - return System.currentTimeMillis(); - } - - @Override - public void sleep(long millis) throws InterruptedException { - Thread.sleep(millis); - } - }; - } } From cb719726cdfd0e3c2a0df1c110cbfe4e77061880 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Mon, 27 Jul 2026 15:53:19 +0000 Subject: [PATCH 14/25] Fix AgentIdentityUtilsTest compilation after checkstyle --- .../com/google/auth/oauth2/AgentIdentityUtilsTest.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java index d960aed73022..de93b8b52d7b 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java @@ -180,7 +180,7 @@ public void getAgentIdentityCertificate_happyPath_loadsCertificate() throws IOEx envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath()); AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); assertNotNull(info); - assertTrue(info.certificate.getIssuerDN().getName().contains("unit-tests")); + assertTrue(info.getCertificate().getIssuerDN().getName().contains("unit-tests")); } @Test @@ -256,7 +256,7 @@ public void getAgentIdentityCertInfo_fallbackPath_loadsCertificate() throws IOEx assertNotNull(info); assertEquals( new String(Files.readAllBytes(tempDir.resolve("certificates.pem")), StandardCharsets.UTF_8), - info.certContent); + info.getCertContent()); } @Test From a4b61925432824b04a5a68a5faf9f67df1c491e0 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Mon, 27 Jul 2026 21:44:33 +0000 Subject: [PATCH 15/25] test: fix missing InputStream import in tests --- .../com/google/auth/oauth2/ComputeEngineCredentialsTest.java | 1 + 1 file changed, 1 insertion(+) diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index abfc83602e1b..68a546abbed9 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -59,6 +59,7 @@ import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.DefaultCredentialsProviderTest.MockRequestCountingTransportFactory; import java.io.ByteArrayInputStream; +import java.io.InputStream; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; From 71cd2c1813b18789b215c8c3e5e905f6743e36a8 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 28 Jul 2026 19:31:14 +0000 Subject: [PATCH 16/25] Restore EnableAutoValue.txt for AutoValue annotation processor --- google-auth-library-java/oauth2_http/EnableAutoValue.txt | 0 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 google-auth-library-java/oauth2_http/EnableAutoValue.txt diff --git a/google-auth-library-java/oauth2_http/EnableAutoValue.txt b/google-auth-library-java/oauth2_http/EnableAutoValue.txt new file mode 100644 index 000000000000..e69de29bb2d1 From ea07ce4b1532e69429f66ee9f6ce43c1c1397273 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 28 Jul 2026 19:44:24 +0000 Subject: [PATCH 17/25] Restore accidentally deleted gradle-wrapper.jar --- .../gradle/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 59536 bytes 1 file changed, 0 insertions(+), 0 deletions(-) create mode 100644 rules_java_gapic/resources/gradle/gradle/wrapper/gradle-wrapper.jar diff --git a/rules_java_gapic/resources/gradle/gradle/wrapper/gradle-wrapper.jar b/rules_java_gapic/resources/gradle/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..7454180f2ae8848c63b8b4dea2cb829da983f2fa GIT binary patch literal 59536 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 QuEJmfE|#`Dqx|px0L@K;Y5)KL literal 0 HcmV?d00001 From 5b1c82b6368db8c117523f9a23ef0edf38deafc6 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Wed, 29 Jul 2026 02:33:16 +0000 Subject: [PATCH 18/25] style: fix formatting violations in oauth2_http --- .../auth/oauth2/AgentIdentityUtils.java | 1146 ++++++++--------- .../auth/oauth2/AgentIdentityUtilsTest.java | 9 +- .../oauth2/ComputeEngineCredentialsTest.java | 21 +- 3 files changed, 583 insertions(+), 593 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 1824a8079b32..f36c93a35557 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -36,10 +36,6 @@ import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; - import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; @@ -57,655 +53,643 @@ import java.util.List; import java.util.Map; import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; /** Utility class for Agent Identity token binding in Cloud Run. */ @InternalApi public final class AgentIdentityUtils { - /** Javadoc. */ - private static final Logger LOGGER = LoggerFactory.getLogger(AgentIdentityUtils.class); + /** Javadoc. */ + private static final Logger LOGGER = LoggerFactory.getLogger(AgentIdentityUtils.class); - // Environment variables - /** Javadoc. */ - static final String GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG"; + // Environment variables + /** Javadoc. */ + static final String GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG"; - /** Javadoc. */ - static final String GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES = - "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES"; + /** Javadoc. */ + static final String GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES = + "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES"; - /** Javadoc. */ - private static final List AGENT_IDENTITY_SPIFFE_PATTERNS = - ImmutableList.of( - Pattern.compile("^agents\\.global\\.org-\\d+\\.system\\.id\\.goog$"), - Pattern.compile("^agents\\.global\\.proj-\\d+\\.system\\.id\\.goog$")); + /** Javadoc. */ + private static final List AGENT_IDENTITY_SPIFFE_PATTERNS = + ImmutableList.of( + Pattern.compile("^agents\\.global\\.org-\\d+\\.system\\.id\\.goog$"), + Pattern.compile("^agents\\.global\\.proj-\\d+\\.system\\.id\\.goog$")); - /** Javadoc. */ - private static final int SAN_URI_TYPE = 6; + /** Javadoc. */ + private static final int SAN_URI_TYPE = 6; - /** Javadoc. */ - private static final String SPIFFE_SCHEME_PREFIX = "spiffe://"; + /** Javadoc. */ + private static final String SPIFFE_SCHEME_PREFIX = "spiffe://"; - /** Javadoc. */ - private static String wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/"; + /** Javadoc. */ + private static String wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/"; - @VisibleForTesting - static void setWellKnownDir(final String dir) { - wellKnownDir = dir; - } + @VisibleForTesting + static void setWellKnownDir(final String dir) { + wellKnownDir = dir; + } - // Polling configuration - /** Javadoc. */ - private static final int CERT_KEY_MATCH_RETRIES = 3; + // Polling configuration + /** Javadoc. */ + private static final int CERT_KEY_MATCH_RETRIES = 3; - /** Javadoc. */ - private static final long CERT_KEY_MATCH_RETRY_INTERVAL_MS = 100; + /** Javadoc. */ + private static final long CERT_KEY_MATCH_RETRY_INTERVAL_MS = 100; - /** Javadoc. */ - private static final int FAST_POLL_CYCLES = 50; + /** Javadoc. */ + private static final int FAST_POLL_CYCLES = 50; - /** Javadoc. */ - private static final long FAST_POLL_INTERVAL_MS = 100; // 0.1 seconds + /** Javadoc. */ + private static final long FAST_POLL_INTERVAL_MS = 100; // 0.1 seconds - /** Javadoc. */ - private static final long SLOW_POLL_INTERVAL_MS = 500; // 0.5 seconds + /** Javadoc. */ + private static final long SLOW_POLL_INTERVAL_MS = 500; // 0.5 seconds - /** Javadoc. */ - private static final long TOTAL_TIMEOUT_MS = 30000; // 30 seconds + /** Javadoc. */ + private static final long TOTAL_TIMEOUT_MS = 30000; // 30 seconds - private static final List POLLING_INTERVALS; + private static final List POLLING_INTERVALS; - static { - List intervals = new ArrayList<>(); - for (int i = 0; i < FAST_POLL_CYCLES; i++) { - intervals.add(FAST_POLL_INTERVAL_MS); - } - long remainingTime = TOTAL_TIMEOUT_MS - (FAST_POLL_CYCLES * FAST_POLL_INTERVAL_MS); - int slowPollCycles = (int) (remainingTime / SLOW_POLL_INTERVAL_MS); - for (int i = 0; i < slowPollCycles; i++) { - intervals.add(SLOW_POLL_INTERVAL_MS); - } - POLLING_INTERVALS = Collections.unmodifiableList(intervals); + static { + List intervals = new ArrayList<>(); + for (int i = 0; i < FAST_POLL_CYCLES; i++) { + intervals.add(FAST_POLL_INTERVAL_MS); } - - public interface EnvReader { - /** Javadoc. */ - String getEnv(String name); + long remainingTime = TOTAL_TIMEOUT_MS - (FAST_POLL_CYCLES * FAST_POLL_INTERVAL_MS); + int slowPollCycles = (int) (remainingTime / SLOW_POLL_INTERVAL_MS); + for (int i = 0; i < slowPollCycles; i++) { + intervals.add(SLOW_POLL_INTERVAL_MS); } + POLLING_INTERVALS = Collections.unmodifiableList(intervals); + } + public interface EnvReader { /** Javadoc. */ - private static EnvReader envReader = System::getenv; + String getEnv(String name); + } - @VisibleForTesting - interface TimeService { - long currentTimeMillis(); + /** Javadoc. */ + private static EnvReader envReader = System::getenv; - /** Javadoc. */ - void sleep(final long millis) throws InterruptedException; - } + @VisibleForTesting + interface TimeService { + long currentTimeMillis(); /** Javadoc. */ - private static TimeService timeService = - new TimeService() { - @Override - public long currentTimeMillis() { - return System.currentTimeMillis(); - } - - @Override - public void sleep(final long millis) throws InterruptedException { - Thread.sleep(millis); - } - }; - - private AgentIdentityUtils() {} - - static class CertInfo { - /** Javadoc. */ - private final X509Certificate certificate; - - /** Javadoc. */ - private final String certContent; - - CertInfo(final X509Certificate certificate, final String certContent) { - this.certificate = certificate; - this.certContent = certContent; + void sleep(final long millis) throws InterruptedException; + } + + /** Javadoc. */ + private static TimeService timeService = + new TimeService() { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); } - /** Javadoc. */ - public X509Certificate getCertificate() { - return certificate; + @Override + public void sleep(final long millis) throws InterruptedException { + Thread.sleep(millis); } + }; - /** Javadoc. */ - public String getCertContent() { - return certContent; - } - } + private AgentIdentityUtils() {} - static class ResolvedCertAndKeyPaths { - /** Javadoc. */ - private final String certPath; - - /** Javadoc. */ - private final String keyPath; - - ResolvedCertAndKeyPaths(final String certPath, final String keyPath) { - this.certPath = certPath; - this.keyPath = keyPath; - } + static class CertInfo { + /** Javadoc. */ + private final X509Certificate certificate; - /** Javadoc. */ - public String getCertPath() { - return certPath; - } + /** Javadoc. */ + private final String certContent; - /** Javadoc. */ - public String getKeyPath() { - return keyPath; - } + CertInfo(final X509Certificate certificate, final String certContent) { + this.certificate = certificate; + this.certContent = certContent; } - /** - * Retrieves the certificate and path for the Agent Identity. - * - *

This method attempts to load the certificate and private key for the agent identity. It - * first checks the location specified by the {@code GOOGLE_API_CERTIFICATE_CONFIG} environment - * variable. If not set, it falls back to well-known default locations. - * - *

To handle transient race conditions during certificate rotation on disk, this method - * employs a retry mechanism with backoff when reading the configuration and certificate files. - * - * @return A {@link CertInfo} object containing the loaded certificate and its path, or {@code - * null} if the agent identity features are disabled, opted out, or if no valid credentials - * could be loaded. - * @throws IOException If an I/O error occurs while reading the files, or if the key-pair - * verification fails after retries. - */ - static CertInfo getAgentIdentityCertInfo() throws IOException { - if (!isTokenBindingEnabled()) { - return null; - } - String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG); - boolean configExists = - !Strings.isNullOrEmpty(certConfigPath) && Files.exists(Paths.get(certConfigPath)); - - ResolvedCertAndKeyPaths paths = resolveCertAndKeyPaths(certConfigPath); - boolean certsPresent = !Strings.isNullOrEmpty(paths.getCertPath()); - - if (!shouldEnableMtls(certsPresent, configExists)) { - return null; - } - - return loadAndVerifyCredentials(paths.getCertPath(), paths.getKeyPath()); + /** Javadoc. */ + public X509Certificate getCertificate() { + return certificate; } - /** - * Resolves the paths for the certificate and private key based on the config path or well-known - * locations. - */ - static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(String certConfigPath) - throws IOException { - String certPath = null; - String keyPath = null; - - if (!Strings.isNullOrEmpty(certConfigPath)) { - java.nio.file.Path configPath = Paths.get(certConfigPath); - if (!Files.exists(configPath) && !Files.exists(Paths.get(wellKnownDir))) { - // Fail-fast if config doesn't exist and we are not in a workload environment - return new ResolvedCertAndKeyPaths(null, null); - } - // Read cert and key paths from config file. We use retry with backoff to handle - // transient - // race conditions where the config file might be being updated by a rotation process. - ResolvedCertAndKeyPaths paths = getPathsFromConfigWithRetry(certConfigPath); - if (paths != null) { - certPath = paths.getCertPath(); - keyPath = paths.getKeyPath(); - } - } else { - if (!Files.exists(Paths.get(wellKnownDir))) { - // Fail-fast if well-known dir doesn't exist (e.g. workstation) - return new ResolvedCertAndKeyPaths(null, null); - } - // Fallback to well-known locations. We use retry with backoff here as well to handle - // race conditions during file replacement by a rotation process. - certPath = getWellKnownCertificatePathWithRetry(); - if (certPath != null) { - if (certPath.endsWith("credentialbundle.pem")) { - keyPath = certPath; // Bundle contains both - } else if (certPath.endsWith("certificates.pem")) { - keyPath = Paths.get(wellKnownDir, "private_key.pem").toString(); - } - } - } - return new ResolvedCertAndKeyPaths(certPath, keyPath); + /** Javadoc. */ + public String getCertContent() { + return certContent; } + } - /** - * Loads the certificate and private key, and verifies that they match if they are separate - * files. - */ - static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws IOException { - X509Certificate cert = null; - PrivateKey privateKey = null; - String certContent = null; - - if (!Strings.isNullOrEmpty(certPath) - && !Strings.isNullOrEmpty(keyPath) - && !certPath.equals(keyPath) - && Files.exists(Paths.get(keyPath))) { - // Separate files, verify match with retry - int retries = 0; - boolean matched = false; - while (retries < CERT_KEY_MATCH_RETRIES) { - try { - certContent = readCertificateChain(certPath); - cert = parseCertificateContent(certContent); - privateKey = readPrivateKey(keyPath, cert.getPublicKey().getAlgorithm()); - - if (verifyKeyPair(cert, privateKey)) { - matched = true; - break; - } - LOGGER.warn("Cert and key mismatch, retrying..."); - } catch (java.nio.file.AccessDeniedException e) { - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - "Permission denied reading certificate or key files. Falling back to" - + " unbound token."); - return null; - } catch (Exception e) { - LOGGER.warn("Failed to read or verify cert/key, retrying...", e); - } - - retries++; - if (retries < CERT_KEY_MATCH_RETRIES) { - try { - timeService.sleep(CERT_KEY_MATCH_RETRY_INTERVAL_MS); // 0.1 seconds backoff - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("Interrupted while waiting for cert/key match.", e); - } - } - } + static class ResolvedCertAndKeyPaths { + /** Javadoc. */ + private final String certPath; - if (!matched) { - throw new IOException( - String.format( - "Agent Identity certificate and private key mismatch or read" - + " failure after %d retries.", - CERT_KEY_MATCH_RETRIES)); - } - } else if (!Strings.isNullOrEmpty(certPath)) { - // Bundle or only cert available - try { - certContent = readCertificateChain(certPath); - cert = parseCertificateContent(certContent); - } catch (java.nio.file.AccessDeniedException e) { - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - "Permission denied reading certificate files. Falling back to unbound" - + " token."); - return null; - } - } + /** Javadoc. */ + private final String keyPath; - return new CertInfo(cert, certContent); + ResolvedCertAndKeyPaths(final String certPath, final String keyPath) { + this.certPath = certPath; + this.keyPath = keyPath; } - /** Checks if a file exists, throwing AccessDeniedException if permission is denied. */ - private static boolean checkExistsOrAccessDenied(java.nio.file.Path path) - throws java.nio.file.AccessDeniedException { - try { - Files.readAttributes(path, java.nio.file.attribute.BasicFileAttributes.class); - return true; - } catch (java.nio.file.AccessDeniedException e) { - throw e; - } catch (IOException e) { - return false; - } + /** Javadoc. */ + public String getCertPath() { + return certPath; } - /** - * Checks if the user has disabled token binding by setting the environment variable to false. - */ - private static boolean isTokenBindingEnabled() { - String preventSharing = envReader.getEnv(GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES); - return !("false".equalsIgnoreCase(preventSharing)); + /** Javadoc. */ + public String getKeyPath() { + return keyPath; } - - /** - * Reads the certificate path from the config file with retry logic to handle rotation race - * conditions. - */ - private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(String certConfigPath) - throws IOException { - boolean warned = false; - for (long sleepInterval : POLLING_INTERVALS) { - try { - if (checkExistsOrAccessDenied(Paths.get(certConfigPath))) { - ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath); - if (paths != null - && !Strings.isNullOrEmpty(paths.getCertPath()) - && checkExistsOrAccessDenied(Paths.get(paths.getCertPath()))) { - return paths; - } - } - } catch (java.nio.file.AccessDeniedException e) { - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - "Permission denied reading certificate config file. Falling back to unbound" - + " token."); - return null; - } catch (IOException e) { - // Fall through to retry - } - if (!warned) { - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - String.format( - "Certificate config file not found or invalid at %s (from %s" - + " environment variable). Retrying for up to %d seconds.", - certConfigPath, - GOOGLE_API_CERTIFICATE_CONFIG, - TOTAL_TIMEOUT_MS / 1000)); - warned = true; - } - try { - timeService.sleep(sleepInterval); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException( - "Interrupted while waiting for Agent Identity certificate files for bound" - + " token request.", - e); - } - } - throw new IOException( - "Unable to find Agent Identity certificate config or file for bound token request" - + " after multiple retries. Token binding protection is failing. You can turn" - + " off this protection by setting " - + GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES - + " to false to fall back to unbound tokens."); + } + + /** + * Retrieves the certificate and path for the Agent Identity. + * + *

This method attempts to load the certificate and private key for the agent identity. It + * first checks the location specified by the {@code GOOGLE_API_CERTIFICATE_CONFIG} environment + * variable. If not set, it falls back to well-known default locations. + * + *

To handle transient race conditions during certificate rotation on disk, this method employs + * a retry mechanism with backoff when reading the configuration and certificate files. + * + * @return A {@link CertInfo} object containing the loaded certificate and its path, or {@code + * null} if the agent identity features are disabled, opted out, or if no valid credentials + * could be loaded. + * @throws IOException If an I/O error occurs while reading the files, or if the key-pair + * verification fails after retries. + */ + static CertInfo getAgentIdentityCertInfo() throws IOException { + if (!isTokenBindingEnabled()) { + return null; } + String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG); + boolean configExists = + !Strings.isNullOrEmpty(certConfigPath) && Files.exists(Paths.get(certConfigPath)); - /** Searches for certificates at well-known locations with retry logic. */ - private static String getWellKnownCertificatePathWithRetry() throws IOException { - String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString(); - String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString(); + ResolvedCertAndKeyPaths paths = resolveCertAndKeyPaths(certConfigPath); + boolean certsPresent = !Strings.isNullOrEmpty(paths.getCertPath()); - boolean warned = false; - for (long sleepInterval : POLLING_INTERVALS) { - try { - if (checkExistsOrAccessDenied(Paths.get(bundlePath))) { - return bundlePath; - } - if (checkExistsOrAccessDenied(Paths.get(certOnlyPath))) { - return certOnlyPath; - } - } catch (java.nio.file.AccessDeniedException e) { - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - "Permission denied reading well-known certificates. Falling back to unbound" - + " token."); - return null; - } catch (Exception e) { - // Fall through to retry - } - if (!warned) { - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - String.format( - "Well-known certificate file not found at %s. Retrying for up to %d" - + " seconds.", - wellKnownDir, TOTAL_TIMEOUT_MS / 1000)); - warned = true; - } - try { - timeService.sleep(sleepInterval); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException( - "Interrupted while waiting for well-known certificate files.", e); - } - } - throw new IOException( - "Unable to find well-known certificate file for bound token request after multiple" - + " retries."); + if (!shouldEnableMtls(certsPresent, configExists)) { + return null; } - /** Reads the full certificate chain from the specified path as a string. */ - static String readCertificateChain(String certPath) throws IOException { - return new String(Files.readAllBytes(Paths.get(certPath)), StandardCharsets.UTF_8); + return loadAndVerifyCredentials(paths.getCertPath(), paths.getKeyPath()); + } + + /** + * Resolves the paths for the certificate and private key based on the config path or well-known + * locations. + */ + static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(String certConfigPath) throws IOException { + String certPath = null; + String keyPath = null; + + if (!Strings.isNullOrEmpty(certConfigPath)) { + java.nio.file.Path configPath = Paths.get(certConfigPath); + if (!Files.exists(configPath) && !Files.exists(Paths.get(wellKnownDir))) { + // Fail-fast if config doesn't exist and we are not in a workload environment + return new ResolvedCertAndKeyPaths(null, null); + } + // Read cert and key paths from config file. We use retry with backoff to handle + // transient + // race conditions where the config file might be being updated by a rotation process. + ResolvedCertAndKeyPaths paths = getPathsFromConfigWithRetry(certConfigPath); + if (paths != null) { + certPath = paths.getCertPath(); + keyPath = paths.getKeyPath(); + } + } else { + if (!Files.exists(Paths.get(wellKnownDir))) { + // Fail-fast if well-known dir doesn't exist (e.g. workstation) + return new ResolvedCertAndKeyPaths(null, null); + } + // Fallback to well-known locations. We use retry with backoff here as well to handle + // race conditions during file replacement by a rotation process. + certPath = getWellKnownCertificatePathWithRetry(); + if (certPath != null) { + if (certPath.endsWith("credentialbundle.pem")) { + keyPath = certPath; // Bundle contains both + } else if (certPath.endsWith("certificates.pem")) { + keyPath = Paths.get(wellKnownDir, "private_key.pem").toString(); + } + } } - - /** - * Verifies that the private key corresponds to the public key in the certificate by performing - * a test signature and verification. - */ - static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { + return new ResolvedCertAndKeyPaths(certPath, keyPath); + } + + /** + * Loads the certificate and private key, and verifies that they match if they are separate files. + */ + static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws IOException { + X509Certificate cert = null; + PrivateKey privateKey = null; + String certContent = null; + + if (!Strings.isNullOrEmpty(certPath) + && !Strings.isNullOrEmpty(keyPath) + && !certPath.equals(keyPath) + && Files.exists(Paths.get(keyPath))) { + // Separate files, verify match with retry + int retries = 0; + boolean matched = false; + while (retries < CERT_KEY_MATCH_RETRIES) { try { - byte[] data = "verification-data".getBytes(StandardCharsets.UTF_8); - - String keyAlgorithm = cert.getPublicKey().getAlgorithm(); - String sigAlg; - if ("RSA".equals(keyAlgorithm)) { - sigAlg = "SHA256withRSA"; - } else if ("EC".equals(keyAlgorithm)) { - sigAlg = "SHA256withECDSA"; - } else { - throw new IllegalArgumentException("Unsupported key algorithm: " + keyAlgorithm); - } - - Signature signer = Signature.getInstance(sigAlg); - signer.initSign(privateKey); - signer.update(data); - byte[] signature = signer.sign(); - - Signature verifier = Signature.getInstance(sigAlg); - verifier.initVerify(cert.getPublicKey()); - verifier.update(data); - - return verifier.verify(signature); + certContent = readCertificateChain(certPath); + cert = parseCertificateContent(certContent); + privateKey = readPrivateKey(keyPath, cert.getPublicKey().getAlgorithm()); + + if (verifyKeyPair(cert, privateKey)) { + matched = true; + break; + } + LOGGER.warn("Cert and key mismatch, retrying..."); + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading certificate or key files. Falling back to" + + " unbound token."); + return null; } catch (Exception e) { - LOGGER.warn("Key pair verification failed", e); - return false; + LOGGER.warn("Failed to read or verify cert/key, retrying...", e); } - } - /** Reads the private key from the specified path using PKCS8 format. */ - static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOException { - String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); - OAuth2Utils.Pkcs8Algorithm pkcs8Alg = - "EC".equals(algorithm) - ? OAuth2Utils.Pkcs8Algorithm.EC - : OAuth2Utils.Pkcs8Algorithm.RSA; - return OAuth2Utils.privateKeyFromPkcs8(keyPem, pkcs8Alg); - } - - /** - * Determines if mTLS should be enabled based on environment variables and certificate presence. - */ - static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) throws IOException { - String useClientCert = envReader.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); - - // Case 1: Explicitly enabled via environment variable - if ("true".equalsIgnoreCase(useClientCert)) { - if (certsPresent) { - // Certs are available, enable mTLS - return true; - } - if (configExists) { - // Config exists but files are missing - fail fast - throw new IOException( - "Certificate intent established via config, but cert files are missing."); - } - // Neither exist, do not enable - return false; - } - // Case 2: Explicitly disabled via environment variable - else if ("false".equalsIgnoreCase(useClientCert)) { - if (certsPresent) { - // Warn that we are ignoring present certs because it was explicitly disabled - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - "Token binding protection is disabled because mTLS was explicitly disabled" - + " via GOOGLE_API_USE_CLIENT_CERTIFICATE."); - return false; - } - return false; - } - // Case 3: Environment variable is unset - else { - if (certsPresent) { - // Infer mTLS is enabled because certs are present - return true; - } - if (configExists) { - // Config exists but files are missing - fail fast - throw new IOException( - "Certificate intent inferred via config, but cert files are missing."); - } - // Neither cert-config nor certs exist, do not enable - return false; + retries++; + if (retries < CERT_KEY_MATCH_RETRIES) { + try { + timeService.sleep(CERT_KEY_MATCH_RETRY_INTERVAL_MS); // 0.1 seconds backoff + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for cert/key match.", e); + } } - } + } - /** Retrieves the bound token payload (certificate chain) if applicable. */ - static String getBoundTokenPayload() throws IOException { - CertInfo info = getAgentIdentityCertInfo(); - if (info != null && shouldRequestBoundToken(info.getCertificate())) { - return info.getCertContent(); - } + if (!matched) { + throw new IOException( + String.format( + "Agent Identity certificate and private key mismatch or read" + + " failure after %d retries.", + CERT_KEY_MATCH_RETRIES)); + } + } else if (!Strings.isNullOrEmpty(certPath)) { + // Bundle or only cert available + try { + certContent = readCertificateChain(certPath); + cert = parseCertificateContent(certContent); + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading certificate files. Falling back to unbound" + " token."); return null; + } } - @SuppressWarnings("unchecked") - /** Extracts the certificate and private key paths from the JSON configuration file. */ - private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigPath) - throws IOException { - try (InputStream stream = Files.newInputStream(Paths.get(certConfigPath))) { - JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); - GenericJson config = - parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class); - Object certConfigsObj = config.get("cert_configs"); - if (certConfigsObj instanceof Map) { - Map certConfigs = (Map) certConfigsObj; - Object workloadObj = certConfigs.get("workload"); - if (workloadObj instanceof Map) { - Map workload = (Map) workloadObj; - String certPath = null; - String keyPath = null; - if (workload.get("cert_path") instanceof String) { - certPath = (String) workload.get("cert_path"); - } - if (workload.get("key_path") instanceof String) { - keyPath = (String) workload.get("key_path"); - } - return new ResolvedCertAndKeyPaths(certPath, keyPath); - } - } - } catch (java.nio.file.AccessDeniedException e) { - throw e; - } catch (Exception e) { - throw new IOException("Failed to parse Agent Identity config JSON", e); + return new CertInfo(cert, certContent); + } + + /** Checks if a file exists, throwing AccessDeniedException if permission is denied. */ + private static boolean checkExistsOrAccessDenied(java.nio.file.Path path) + throws java.nio.file.AccessDeniedException { + try { + Files.readAttributes(path, java.nio.file.attribute.BasicFileAttributes.class); + return true; + } catch (java.nio.file.AccessDeniedException e) { + throw e; + } catch (IOException e) { + return false; + } + } + + /** Checks if the user has disabled token binding by setting the environment variable to false. */ + private static boolean isTokenBindingEnabled() { + String preventSharing = envReader.getEnv(GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES); + return !("false".equalsIgnoreCase(preventSharing)); + } + + /** + * Reads the certificate path from the config file with retry logic to handle rotation race + * conditions. + */ + private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(String certConfigPath) + throws IOException { + boolean warned = false; + for (long sleepInterval : POLLING_INTERVALS) { + try { + if (checkExistsOrAccessDenied(Paths.get(certConfigPath))) { + ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath); + if (paths != null + && !Strings.isNullOrEmpty(paths.getCertPath()) + && checkExistsOrAccessDenied(Paths.get(paths.getCertPath()))) { + return paths; + } } + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading certificate config file. Falling back to unbound" + + " token."); return null; + } catch (IOException e) { + // Fall through to retry + } + if (!warned) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + String.format( + "Certificate config file not found or invalid at %s (from %s" + + " environment variable). Retrying for up to %d seconds.", + certConfigPath, GOOGLE_API_CERTIFICATE_CONFIG, TOTAL_TIMEOUT_MS / 1000)); + warned = true; + } + try { + timeService.sleep(sleepInterval); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException( + "Interrupted while waiting for Agent Identity certificate files for bound" + + " token request.", + e); + } } - - /** Parses the X509 certificate from the specified content string. */ - private static X509Certificate parseCertificateContent(String certContent) throws IOException { - try (InputStream stream = - new java.io.ByteArrayInputStream(certContent.getBytes(StandardCharsets.UTF_8))) { - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - return (X509Certificate) cf.generateCertificate(stream); - } catch (GeneralSecurityException e) { - throw new IOException( - "Failed to parse Agent Identity certificate for bound token request.", e); + throw new IOException( + "Unable to find Agent Identity certificate config or file for bound token request" + + " after multiple retries. Token binding protection is failing. You can turn" + + " off this protection by setting " + + GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES + + " to false to fall back to unbound tokens."); + } + + /** Searches for certificates at well-known locations with retry logic. */ + private static String getWellKnownCertificatePathWithRetry() throws IOException { + String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString(); + String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString(); + + boolean warned = false; + for (long sleepInterval : POLLING_INTERVALS) { + try { + if (checkExistsOrAccessDenied(Paths.get(bundlePath))) { + return bundlePath; } - } - - /** - * Determines if a bound token should be requested by checking if any of the certificate's - * Subject Alternative Names (SANs) match allowed SPIFFE patterns. - */ - static boolean shouldRequestBoundToken(X509Certificate cert) { - try { - Collection> sans = cert.getSubjectAlternativeNames(); - if (sans == null) { - return false; - } - // Iterate through all Subject Alternative Names - for (List san : sans) { - // Check if the SAN entry is a URI (type 6) - if (san.size() >= 2 - && san.get(0) instanceof Integer - && (Integer) san.get(0) == SAN_URI_TYPE) { - Object value = san.get(1); - if (value instanceof String) { - String uri = (String) value; - // Check if the URI starts with "spiffe://" - if (uri.startsWith(SPIFFE_SCHEME_PREFIX)) { - String withoutScheme = uri.substring(SPIFFE_SCHEME_PREFIX.length()); - int slashIndex = withoutScheme.indexOf('/'); - // Extract the trust domain (part before the first slash) - String trustDomain = - (slashIndex == -1) - ? withoutScheme - : withoutScheme.substring(0, slashIndex); - // Match the trust domain against allowed agent patterns - for (Pattern pattern : AGENT_IDENTITY_SPIFFE_PATTERNS) { - if (pattern.matcher(trustDomain).matches()) { - return true; - } - } - } - } - } - } - } catch (CertificateParsingException e) { - LOGGER.warn("Failed to parse Subject Alternative Names from certificate", e); + if (checkExistsOrAccessDenied(Paths.get(certOnlyPath))) { + return certOnlyPath; } + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading well-known certificates. Falling back to unbound" + + " token."); + return null; + } catch (Exception e) { + // Fall through to retry + } + if (!warned) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + String.format( + "Well-known certificate file not found at %s. Retrying for up to %d" + " seconds.", + wellKnownDir, TOTAL_TIMEOUT_MS / 1000)); + warned = true; + } + try { + timeService.sleep(sleepInterval); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for well-known certificate files.", e); + } + } + throw new IOException( + "Unable to find well-known certificate file for bound token request after multiple" + + " retries."); + } + + /** Reads the full certificate chain from the specified path as a string. */ + static String readCertificateChain(String certPath) throws IOException { + return new String(Files.readAllBytes(Paths.get(certPath)), StandardCharsets.UTF_8); + } + + /** + * Verifies that the private key corresponds to the public key in the certificate by performing a + * test signature and verification. + */ + static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { + try { + byte[] data = "verification-data".getBytes(StandardCharsets.UTF_8); + + String keyAlgorithm = cert.getPublicKey().getAlgorithm(); + String sigAlg; + if ("RSA".equals(keyAlgorithm)) { + sigAlg = "SHA256withRSA"; + } else if ("EC".equals(keyAlgorithm)) { + sigAlg = "SHA256withECDSA"; + } else { + throw new IllegalArgumentException("Unsupported key algorithm: " + keyAlgorithm); + } + + Signature signer = Signature.getInstance(sigAlg); + signer.initSign(privateKey); + signer.update(data); + byte[] signature = signer.sign(); + + Signature verifier = Signature.getInstance(sigAlg); + verifier.initVerify(cert.getPublicKey()); + verifier.update(data); + + return verifier.verify(signature); + } catch (Exception e) { + LOGGER.warn("Key pair verification failed", e); + return false; + } + } + + /** Reads the private key from the specified path using PKCS8 format. */ + static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOException { + String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); + OAuth2Utils.Pkcs8Algorithm pkcs8Alg = + "EC".equals(algorithm) ? OAuth2Utils.Pkcs8Algorithm.EC : OAuth2Utils.Pkcs8Algorithm.RSA; + return OAuth2Utils.privateKeyFromPkcs8(keyPem, pkcs8Alg); + } + + /** + * Determines if mTLS should be enabled based on environment variables and certificate presence. + */ + static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) throws IOException { + String useClientCert = envReader.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); + + // Case 1: Explicitly enabled via environment variable + if ("true".equalsIgnoreCase(useClientCert)) { + if (certsPresent) { + // Certs are available, enable mTLS + return true; + } + if (configExists) { + // Config exists but files are missing - fail fast + throw new IOException( + "Certificate intent established via config, but cert files are missing."); + } + // Neither exist, do not enable + return false; + } + // Case 2: Explicitly disabled via environment variable + else if ("false".equalsIgnoreCase(useClientCert)) { + if (certsPresent) { + // Warn that we are ignoring present certs because it was explicitly disabled + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Token binding protection is disabled because mTLS was explicitly disabled" + + " via GOOGLE_API_USE_CLIENT_CERTIFICATE."); return false; + } + return false; } - - @VisibleForTesting - public static void setEnvReader(EnvReader reader) { - envReader = reader; + // Case 3: Environment variable is unset + else { + if (certsPresent) { + // Infer mTLS is enabled because certs are present + return true; + } + if (configExists) { + // Config exists but files are missing - fail fast + throw new IOException( + "Certificate intent inferred via config, but cert files are missing."); + } + // Neither cert-config nor certs exist, do not enable + return false; } + } - @VisibleForTesting - static void setTimeService(TimeService service) { - timeService = service; + /** Retrieves the bound token payload (certificate chain) if applicable. */ + static String getBoundTokenPayload() throws IOException { + CertInfo info = getAgentIdentityCertInfo(); + if (info != null && shouldRequestBoundToken(info.getCertificate())) { + return info.getCertContent(); } - - @VisibleForTesting - static void resetTimeService() { - timeService = - new TimeService() { - @Override - public long currentTimeMillis() { - return System.currentTimeMillis(); - } - - @Override - public void sleep(final long millis) throws InterruptedException { - Thread.sleep(millis); - } - }; + return null; + } + + @SuppressWarnings("unchecked") + /** Extracts the certificate and private key paths from the JSON configuration file. */ + private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigPath) + throws IOException { + try (InputStream stream = Files.newInputStream(Paths.get(certConfigPath))) { + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson config = parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class); + Object certConfigsObj = config.get("cert_configs"); + if (certConfigsObj instanceof Map) { + Map certConfigs = (Map) certConfigsObj; + Object workloadObj = certConfigs.get("workload"); + if (workloadObj instanceof Map) { + Map workload = (Map) workloadObj; + String certPath = null; + String keyPath = null; + if (workload.get("cert_path") instanceof String) { + certPath = (String) workload.get("cert_path"); + } + if (workload.get("key_path") instanceof String) { + keyPath = (String) workload.get("key_path"); + } + return new ResolvedCertAndKeyPaths(certPath, keyPath); + } + } + } catch (java.nio.file.AccessDeniedException e) { + throw e; + } catch (Exception e) { + throw new IOException("Failed to parse Agent Identity config JSON", e); + } + return null; + } + + /** Parses the X509 certificate from the specified content string. */ + private static X509Certificate parseCertificateContent(String certContent) throws IOException { + try (InputStream stream = + new java.io.ByteArrayInputStream(certContent.getBytes(StandardCharsets.UTF_8))) { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate) cf.generateCertificate(stream); + } catch (GeneralSecurityException e) { + throw new IOException( + "Failed to parse Agent Identity certificate for bound token request.", e); + } + } + + /** + * Determines if a bound token should be requested by checking if any of the certificate's Subject + * Alternative Names (SANs) match allowed SPIFFE patterns. + */ + static boolean shouldRequestBoundToken(X509Certificate cert) { + try { + Collection> sans = cert.getSubjectAlternativeNames(); + if (sans == null) { + return false; + } + // Iterate through all Subject Alternative Names + for (List san : sans) { + // Check if the SAN entry is a URI (type 6) + if (san.size() >= 2 + && san.get(0) instanceof Integer + && (Integer) san.get(0) == SAN_URI_TYPE) { + Object value = san.get(1); + if (value instanceof String) { + String uri = (String) value; + // Check if the URI starts with "spiffe://" + if (uri.startsWith(SPIFFE_SCHEME_PREFIX)) { + String withoutScheme = uri.substring(SPIFFE_SCHEME_PREFIX.length()); + int slashIndex = withoutScheme.indexOf('/'); + // Extract the trust domain (part before the first slash) + String trustDomain = + (slashIndex == -1) ? withoutScheme : withoutScheme.substring(0, slashIndex); + // Match the trust domain against allowed agent patterns + for (Pattern pattern : AGENT_IDENTITY_SPIFFE_PATTERNS) { + if (pattern.matcher(trustDomain).matches()) { + return true; + } + } + } + } + } + } + } catch (CertificateParsingException e) { + LOGGER.warn("Failed to parse Subject Alternative Names from certificate", e); } + return false; + } + + @VisibleForTesting + public static void setEnvReader(EnvReader reader) { + envReader = reader; + } + + @VisibleForTesting + static void setTimeService(TimeService service) { + timeService = service; + } + + @VisibleForTesting + static void resetTimeService() { + timeService = + new TimeService() { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + + @Override + public void sleep(final long millis) throws InterruptedException { + Thread.sleep(millis); + } + }; + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java index de93b8b52d7b..e1e6dea70fc7 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java @@ -116,14 +116,17 @@ public void shouldRequestBoundToken_invalidFormat_returnsFalse() throws Certific } @Test - public void shouldRequestBoundToken_certificateParsingException_returnsFalse() throws java.security.cert.CertificateParsingException { + public void shouldRequestBoundToken_certificateParsingException_returnsFalse() + throws java.security.cert.CertificateParsingException { X509Certificate mockCert = mock(X509Certificate.class); - when(mockCert.getSubjectAlternativeNames()).thenThrow(new java.security.cert.CertificateParsingException()); + when(mockCert.getSubjectAlternativeNames()) + .thenThrow(new java.security.cert.CertificateParsingException()); assertFalse(AgentIdentityUtils.shouldRequestBoundToken(mockCert)); } @Test - public void shouldRequestBoundToken_nonUriSan_returnsFalse() throws java.security.cert.CertificateParsingException { + public void shouldRequestBoundToken_nonUriSan_returnsFalse() + throws java.security.cert.CertificateParsingException { X509Certificate mockCert = mock(X509Certificate.class); List dnsSan = Arrays.asList(2, "www.example.com"); when(mockCert.getSubjectAlternativeNames()).thenReturn(Collections.>singleton(dnsSan)); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 68a546abbed9..d17efca074e2 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -59,10 +59,10 @@ import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.DefaultCredentialsProviderTest.MockRequestCountingTransportFactory; import java.io.ByteArrayInputStream; -import java.io.InputStream; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -1277,6 +1277,7 @@ InputStream readStream(File file) throws FileNotFoundException { assertTrue(isOnGce); assertEquals(1, transportFactory.transport.getRequestCount()); } + @Test void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); @@ -1312,10 +1313,11 @@ public void sleep(long millis) { private void setupCertAndKeyConfig() throws IOException { java.nio.file.Path certSource = null; try { - certSource = java.nio.file.Paths.get( - ComputeEngineCredentialsTest.class - .getResource("/agent/agent_spiffe_cert.pem") - .toURI()); + certSource = + java.nio.file.Paths.get( + ComputeEngineCredentialsTest.class + .getResource("/agent/agent_spiffe_cert.pem") + .toURI()); } catch (java.net.URISyntaxException e) { throw new IOException("Failed to load test resource", e); } @@ -1324,10 +1326,11 @@ private void setupCertAndKeyConfig() throws IOException { java.nio.file.Path keySource = null; try { - keySource = java.nio.file.Paths.get( - ComputeEngineCredentialsTest.class - .getResource("/agent/agent_spiffe_key.pem") - .toURI()); + keySource = + java.nio.file.Paths.get( + ComputeEngineCredentialsTest.class + .getResource("/agent/agent_spiffe_key.pem") + .toURI()); } catch (java.net.URISyntaxException e) { throw new IOException("Failed to load test resource", e); } From 0649e4920f629e4f9143a19a5948107a8073e952 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Wed, 29 Jul 2026 19:59:35 +0000 Subject: [PATCH 19/25] docs(oauth2): Make AgentIdentityUtils Javadoc comments proper and fulfill CI formatting rules --- .../auth/oauth2/AgentIdentityUtils.java | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index f36c93a35557..08f8ad62f119 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -127,7 +127,7 @@ static void setWellKnownDir(final String dir) { public interface EnvReader { /** Javadoc. */ - String getEnv(String name); + String getEnv(final String name); } /** Javadoc. */ @@ -241,7 +241,8 @@ static CertInfo getAgentIdentityCertInfo() throws IOException { * Resolves the paths for the certificate and private key based on the config path or well-known * locations. */ - static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(String certConfigPath) throws IOException { + static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(final String certConfigPath) + throws IOException { String certPath = null; String keyPath = null; @@ -281,7 +282,8 @@ static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(String certConfigPath) thr /** * Loads the certificate and private key, and verifies that they match if they are separate files. */ - static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws IOException { + static CertInfo loadAndVerifyCredentials(final String certPath, final String keyPath) + throws IOException { X509Certificate cert = null; PrivateKey privateKey = null; String certContent = null; @@ -353,7 +355,7 @@ static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws } /** Checks if a file exists, throwing AccessDeniedException if permission is denied. */ - private static boolean checkExistsOrAccessDenied(java.nio.file.Path path) + private static boolean checkExistsOrAccessDenied(final java.nio.file.Path path) throws java.nio.file.AccessDeniedException { try { Files.readAttributes(path, java.nio.file.attribute.BasicFileAttributes.class); @@ -375,7 +377,7 @@ private static boolean isTokenBindingEnabled() { * Reads the certificate path from the config file with retry logic to handle rotation race * conditions. */ - private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(String certConfigPath) + private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(final String certConfigPath) throws IOException { boolean warned = false; for (long sleepInterval : POLLING_INTERVALS) { @@ -476,7 +478,7 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException } /** Reads the full certificate chain from the specified path as a string. */ - static String readCertificateChain(String certPath) throws IOException { + static String readCertificateChain(final String certPath) throws IOException { return new String(Files.readAllBytes(Paths.get(certPath)), StandardCharsets.UTF_8); } @@ -484,7 +486,7 @@ static String readCertificateChain(String certPath) throws IOException { * Verifies that the private key corresponds to the public key in the certificate by performing a * test signature and verification. */ - static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { + static boolean verifyKeyPair(final X509Certificate cert, final PrivateKey privateKey) { try { byte[] data = "verification-data".getBytes(StandardCharsets.UTF_8); @@ -515,7 +517,8 @@ static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { } /** Reads the private key from the specified path using PKCS8 format. */ - static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOException { + static PrivateKey readPrivateKey(final String keyPath, final String algorithm) + throws IOException { String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); OAuth2Utils.Pkcs8Algorithm pkcs8Alg = "EC".equals(algorithm) ? OAuth2Utils.Pkcs8Algorithm.EC : OAuth2Utils.Pkcs8Algorithm.RSA; @@ -525,7 +528,8 @@ static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOExce /** * Determines if mTLS should be enabled based on environment variables and certificate presence. */ - static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) throws IOException { + static boolean shouldEnableMtls(final boolean certsPresent, final boolean configExists) + throws IOException { String useClientCert = envReader.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); // Case 1: Explicitly enabled via environment variable @@ -583,7 +587,7 @@ static String getBoundTokenPayload() throws IOException { @SuppressWarnings("unchecked") /** Extracts the certificate and private key paths from the JSON configuration file. */ - private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigPath) + private static ResolvedCertAndKeyPaths extractPathsFromConfig(final String certConfigPath) throws IOException { try (InputStream stream = Files.newInputStream(Paths.get(certConfigPath))) { JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); @@ -614,7 +618,8 @@ private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigP } /** Parses the X509 certificate from the specified content string. */ - private static X509Certificate parseCertificateContent(String certContent) throws IOException { + private static X509Certificate parseCertificateContent(final String certContent) + throws IOException { try (InputStream stream = new java.io.ByteArrayInputStream(certContent.getBytes(StandardCharsets.UTF_8))) { CertificateFactory cf = CertificateFactory.getInstance("X.509"); @@ -629,7 +634,7 @@ private static X509Certificate parseCertificateContent(String certContent) throw * Determines if a bound token should be requested by checking if any of the certificate's Subject * Alternative Names (SANs) match allowed SPIFFE patterns. */ - static boolean shouldRequestBoundToken(X509Certificate cert) { + static boolean shouldRequestBoundToken(final X509Certificate cert) { try { Collection> sans = cert.getSubjectAlternativeNames(); if (sans == null) { From 6181f6a37b6defd9185cb5b8f3ec7642dc94fb70 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 4 Aug 2026 20:23:17 +0000 Subject: [PATCH 20/25] fix(oauth2): address PR 13873 review findings for agent identity token binding - Prevent 30-second polling delay and IOException on standard GCE/container environments when well-known credentials directory exists without certificate files unless mTLS is explicitly enabled. - Fail fast on malformed certificate config JSON without retrying. - Fix URI resource path decoding in AgentIdentityUtilsTest to prevent FileNotFoundException when workspace paths contain spaces. - Copy matching private key in well-known fallback test to verify full key pair loading. - Clean up static wellKnownDir state and temporary directories in ComputeEngineCredentialsTest. - Correct opt-out environment variable value in test setup from 'true' to 'false'. --- .../auth/oauth2/AgentIdentityUtils.java | 58 +++++++- .../auth/oauth2/AgentIdentityUtilsTest.java | 129 ++++++++++++++++-- .../oauth2/ComputeEngineCredentialsTest.java | 5 +- .../com/google/auth/oauth2/LoggingTest.java | 24 +++- .../FTComputeEngineCredentialsTest.java | 2 +- 5 files changed, 196 insertions(+), 22 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 08f8ad62f119..e0e00dd3c2b9 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -71,6 +71,9 @@ public final class AgentIdentityUtils { static final String GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES = "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES"; + /** Javadoc. */ + static final String GOOGLE_API_USE_CLIENT_CERTIFICATE = "GOOGLE_API_USE_CLIENT_CERTIFICATE"; + /** Javadoc. */ private static final List AGENT_IDENTITY_SPIFFE_PATTERNS = ImmutableList.of( @@ -399,6 +402,10 @@ && checkExistsOrAccessDenied(Paths.get(paths.getCertPath()))) { + " token."); return null; } catch (IOException e) { + if (e.getMessage() != null + && e.getMessage().contains("Failed to parse Agent Identity config JSON")) { + throw e; // Fail fast on malformed JSON syntax errors + } // Fall through to retry } if (!warned) { @@ -435,6 +442,41 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString(); String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString(); + // 1) First check immediately without sleeping: + try { + if (checkExistsOrAccessDenied(Paths.get(bundlePath))) { + return bundlePath; + } + if (checkExistsOrAccessDenied(Paths.get(certOnlyPath))) { + return certOnlyPath; + } + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading well-known certificates. Falling back to unbound" + " token."); + return null; + } catch (Exception e) { + // Fall through + } + + // 2) If not found immediately, only enter retry loop if mTLS was explicitly enabled: + String useClientCert = envReader.getEnv(GOOGLE_API_USE_CLIENT_CERTIFICATE); + if (!"true".equalsIgnoreCase(useClientCert)) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.DEBUG, + Collections.emptyMap(), + String.format( + "Well-known certificate file not found at %s and %s is not" + + " explicitly enabled; falling back to unbound token without" + + " retrying.", + wellKnownDir, GOOGLE_API_USE_CLIENT_CERTIFICATE)); + return null; + } + + // 3) Retry loop for rotation/transient absence when explicitly enabled: boolean warned = false; for (long sleepInterval : POLLING_INTERVALS) { try { @@ -461,7 +503,7 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException org.slf4j.event.Level.WARN, Collections.emptyMap(), String.format( - "Well-known certificate file not found at %s. Retrying for up to %d" + " seconds.", + "Well-known certificate file not found at %s. Retrying for up to" + " %d seconds.", wellKnownDir, TOTAL_TIMEOUT_MS / 1000)); warned = true; } @@ -472,9 +514,15 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException throw new IOException("Interrupted while waiting for well-known certificate files.", e); } } - throw new IOException( - "Unable to find well-known certificate file for bound token request after multiple" - + " retries."); + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + String.format( + "Unable to find well-known certificate file at %s after retrying;" + + " falling back to unbound token.", + wellKnownDir)); + return null; } /** Reads the full certificate chain from the specified path as a string. */ @@ -530,7 +578,7 @@ static PrivateKey readPrivateKey(final String keyPath, final String algorithm) */ static boolean shouldEnableMtls(final boolean certsPresent, final boolean configExists) throws IOException { - String useClientCert = envReader.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); + String useClientCert = envReader.getEnv(GOOGLE_API_USE_CLIENT_CERTIFICATE); // Case 1: Explicitly enabled via environment variable if ("true".equalsIgnoreCase(useClientCert)) { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java index e1e6dea70fc7..062c2c3a4415 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java @@ -55,6 +55,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -150,11 +151,35 @@ private X509Certificate mockCertWithSanUri(String uri) throws CertificateExcepti @Test public void getAgentIdentityCertificate_optedOut_returnsNullImmediately() throws IOException { - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", "/non/existent/path"); assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); } + @Test + public void getAgentIdentityCertificate_preventTokenSharingTrue_doesNotOptOut() throws Exception { + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + + URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); + assertNotNull(certUrl, "Test resource agent/agent_spiffe_cert.pem not found"); + String certPath = Paths.get(certUrl.toURI()).toAbsolutePath().toString(); + Files.copy(Paths.get(certPath), tempDir.resolve("certificates.pem")); + + URL keyUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_key.pem"); + assertNotNull(keyUrl, "Test resource agent/agent_spiffe_key.pem not found"); + String keyPath = Paths.get(keyUrl.toURI()).toAbsolutePath().toString(); + Files.copy(Paths.get(keyPath), tempDir.resolve("private_key.pem")); + + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); + + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(info); + assertEquals( + new String(Files.readAllBytes(tempDir.resolve("certificates.pem")), StandardCharsets.UTF_8), + info.getCertContent()); + } + @Test public void getAgentIdentityCertificate_noConfigEnvVar_returnsNull() throws IOException { AgentIdentityUtils.setTimeService(new FakeTimeService()); @@ -162,10 +187,10 @@ public void getAgentIdentityCertificate_noConfigEnvVar_returnsNull() throws IOEx } @Test - public void getAgentIdentityCertificate_happyPath_loadsCertificate() throws IOException { + public void getAgentIdentityCertificate_happyPath_loadsCertificate() throws Exception { URL certUrl = getClass().getClassLoader().getResource("x509_leaf_certificate.pem"); assertNotNull(certUrl, "Test resource x509_leaf_certificate.pem not found"); - String certPath = new File(certUrl.getFile()).getAbsolutePath(); + String certPath = Paths.get(certUrl.toURI()).toAbsolutePath().toString(); File configFile = tempDir.resolve("config.json").toFile(); String configJson = "{" @@ -209,6 +234,26 @@ public void getAgentIdentityCertInfo_malformedJson_throwsIOException() throws IO } envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath()); AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + + IOException e = assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); + assertTrue(e.getMessage().contains("Failed to parse Agent Identity config JSON")); + assertEquals(0, fakeTime.getSleepCount()); + } + + @Test + public void getAgentIdentityCertInfo_configExists_certMissing_throwsIOExceptionAfterRetries() + throws IOException { + File configFile = tempDir.resolve("config.json").toFile(); + try (FileOutputStream fos = new FileOutputStream(configFile)) { + String json = + "{ \"cert_configs\": { \"workload\": { \"cert_path\": \"/non/existent/cert.pem\"," + + " \"key_path\": \"/non/existent/key.pem\" } } }"; + fos.write(json.getBytes(StandardCharsets.UTF_8)); + } + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath()); + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); AgentIdentityUtils.setTimeService(new FakeTimeService()); IOException e = assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); @@ -244,15 +289,43 @@ public void shouldEnableMtls_unset_certsPresent_returnsTrue() throws IOException } @Test - public void getAgentIdentityCertInfo_fallbackPath_loadsCertificate() throws IOException { - AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + public void shouldEnableMtls_true_noCertsNoConfig_returnsFalse() throws IOException { + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); + assertFalse(AgentIdentityUtils.shouldEnableMtls(false, false)); + } - URL certUrl = getClass().getClassLoader().getResource("x509_leaf_certificate.pem"); - assertNotNull(certUrl, "Test resource x509_leaf_certificate.pem not found"); - String certPath = new File(certUrl.getFile()).getAbsolutePath(); + @Test + public void shouldEnableMtls_false_noCertsNoConfig_returnsFalse() throws IOException { + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false"); + assertFalse(AgentIdentityUtils.shouldEnableMtls(false, false)); + } + @Test + public void shouldEnableMtls_unset_noCertsNoConfig_returnsFalse() throws IOException { + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", null); + assertFalse(AgentIdentityUtils.shouldEnableMtls(false, false)); + } + + @Test + public void shouldEnableMtls_unset_certsMissing_configExists_throwsIOException() { + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", null); + assertThrows(IOException.class, () -> AgentIdentityUtils.shouldEnableMtls(false, true)); + } + + @Test + public void getAgentIdentityCertInfo_fallbackPath_loadsCertificate() throws Exception { + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + + URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); + assertNotNull(certUrl, "Test resource agent/agent_spiffe_cert.pem not found"); + String certPath = Paths.get(certUrl.toURI()).toAbsolutePath().toString(); Files.copy(Paths.get(certPath), tempDir.resolve("certificates.pem")); + URL keyUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_key.pem"); + assertNotNull(keyUrl, "Test resource agent/agent_spiffe_key.pem not found"); + String keyPath = Paths.get(keyUrl.toURI()).toAbsolutePath().toString(); + Files.copy(Paths.get(keyPath), tempDir.resolve("private_key.pem")); + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); @@ -289,9 +362,9 @@ public void verifyKeyPair_mismatch_returnsFalse() throws Exception { @Test public void getAgentIdentityCertInfo_mismatch_throwsIOExceptionAfterRetries() throws Exception { - URL certUrl = getClass().getClassLoader().getResource("x509_leaf_certificate.pem"); - assertNotNull(certUrl, "Test resource x509_leaf_certificate.pem not found"); - String certPath = new File(certUrl.getFile()).getAbsolutePath(); + URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); + assertNotNull(certUrl, "Test resource agent/agent_spiffe_cert.pem not found"); + String certPath = Paths.get(certUrl.toURI()).toAbsolutePath().toString(); // Generate a random key that won't match the cert KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); @@ -339,8 +412,37 @@ public void getAgentIdentityCertInfo_mismatch_throwsIOExceptionAfterRetries() th assertEquals(200, fakeTime.currentTimeMillis()); // 2 retries * 100ms } + @Test + public void + getAgentIdentityCertInfo_wellKnownDirExistsNoFiles_notExplicitlyEnabled_returnsNullImmediately() + throws IOException { + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", null); + + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + assertEquals(0, fakeTime.getSleepCount()); + } + + @Test + public void + getAgentIdentityCertInfo_wellKnownDirExistsNoFiles_explicitlyEnabled_retriesAndReturnsNull() + throws IOException { + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); + + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + assertTrue(fakeTime.getSleepCount() > 0); + } + private static class FakeTimeService implements AgentIdentityUtils.TimeService { private final AtomicLong currentTime = new AtomicLong(0); + private final AtomicInteger sleepCount = new AtomicInteger(0); @Override public long currentTimeMillis() { @@ -349,8 +451,13 @@ public long currentTimeMillis() { @Override public void sleep(long millis) throws InterruptedException { + sleepCount.incrementAndGet(); currentTime.addAndGet(millis); } + + int getSleepCount() { + return sleepCount.get(); + } } private static class TestEnvironmentProvider { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index d17efca074e2..8959f21ce98a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -82,6 +82,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; /** Test case for {@link ComputeEngineCredentials}. */ class ComputeEngineCredentialsTest extends BaseSerializationTest { @@ -89,14 +90,13 @@ class ComputeEngineCredentialsTest extends BaseSerializationTest { private static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo"); private TestEnvironmentProvider envProvider; - private Path tempDir; + @TempDir private Path tempDir; @BeforeEach void setUp() throws IOException { envProvider = new TestEnvironmentProvider(); // Inject our test environment reader into AgentIdentityUtils AgentIdentityUtils.setEnvReader(envProvider::getEnv); - tempDir = Files.createTempDirectory("compute_engine_creds_test"); // Speed up polling in tests by using a fake time service that advances time immediately final AtomicLong currentTime = new AtomicLong(0); @@ -120,6 +120,7 @@ public void sleep(long millis) { void tearDown() { // Reset the mocks AgentIdentityUtils.resetTimeService(); + AgentIdentityUtils.setWellKnownDir("/var/run/secrets/workload-spiffe-credentials/"); AgentIdentityUtils.setEnvReader(System::getenv); } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java index 8a68dd6eebd5..09d44ad0456f 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java @@ -62,9 +62,11 @@ import com.google.gson.JsonParser; import com.google.gson.JsonSyntaxException; import java.io.IOException; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Map; +import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; @@ -86,22 +88,33 @@ void setUp() { AgentIdentityUtils.setEnvReader( name -> { if ("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES".equals(name)) { - return "true"; + return "false"; } return null; }); } + private final List modifiedLoggers = new ArrayList<>(); + private final List addedAppenders = new ArrayList<>(); + @AfterEach void tearDown() { AgentIdentityUtils.setEnvReader(System::getenv); + for (int i = 0; i < modifiedLoggers.size(); i++) { + modifiedLoggers.get(i).detachAppender(addedAppenders.get(i)); + } + modifiedLoggers.clear(); + addedAppenders.clear(); } private TestAppender setupTestLogger(Class clazz) { TestAppender testAppender = new TestAppender(); testAppender.start(); - Logger logger = LoggerFactory.getLogger(clazz); - ((ch.qos.logback.classic.Logger) logger).addAppender(testAppender); + ch.qos.logback.classic.Logger logger = + (ch.qos.logback.classic.Logger) LoggerFactory.getLogger(clazz); + logger.addAppender(testAppender); + modifiedLoggers.add(logger); + addedAppenders.add(testAppender); return testAppender; } @@ -113,6 +126,11 @@ static void setup() { LoggingUtils.setEnvironmentProvider(testEnvironmentProvider); } + @AfterAll + static void tearDownAll() { + LoggingUtils.setEnvironmentProvider(LoggingUtils.SystemEnvironmentProvider.getInstance()); + } + @Test void userCredentials_getRequestMetadata_fromRefreshToken_hasAccessToken() throws IOException { TestAppender testAppender = setupTestLogger(UserCredentials.class); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java index 52c23c1f14a1..3d0ed238bfc1 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java @@ -75,7 +75,7 @@ void setUp() { envProvider = new TestEnvironmentProvider(); AgentIdentityUtils.setEnvReader(envProvider::getEnv); // Opt out by default to avoid polling delays or file reads - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); } @AfterEach From a62afa4dd412e496f006c3f43db4451e715fb2c3 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 3 Sep 2026 16:24:58 +0000 Subject: [PATCH 21/25] fix(oauth2): address PR 13873 review feedback for agent identity token binding - Replace placeholder Javadoc comments across AgentIdentityUtils and CertInfo with descriptive documentation. - Inline polling interval calculations via getSleepIntervalMs(), removing the static POLLING_INTERVALS list. - Support GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN as primary environment variable with fallback to GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES. - Add early check for GOOGLE_API_USE_CLIENT_CERTIFICATE=false in getAgentIdentityCertInfo() to exit without polling. - Parse certificate and private key blocks separately using regex to strip private keys from HTTP payloads and enable cryptographic key-pair verification on combined bundle files. - Throw IOException on AccessDeniedException for explicit configs and gracefully return null for implicit well-known discovery. - Exit early without polling for non-workload configs (hasWorkloadConfig) and configs outside the well-known directory. - Add non-production SPIFFE trust domain patterns (agents-nonprod) matching Python. - Maintain IOException exception type on credential verification failures to preserve Credentials#refreshAccessToken interface contract. - Validate full certificate content in ComputeEngineCredentialsTest. - Use JUnit 5 @TempDir in AgentIdentityUtilsTest. --- .../auth/oauth2/AgentIdentityUtils.java | 315 +++++++++----- ...icateIdentityPoolSubjectTokenSupplier.java | 5 +- .../auth/oauth2/AgentIdentityUtilsTest.java | 391 +++++++++++++++++- .../oauth2/ComputeEngineCredentialsTest.java | 21 +- .../DefaultCredentialsProviderTest.java | 2 +- .../auth/oauth2/IdTokenCredentialsTest.java | 2 +- .../com/google/auth/oauth2/LoggingTest.java | 2 +- .../FTComputeEngineCredentialsTest.java | 2 +- 8 files changed, 612 insertions(+), 128 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index e0e00dd3c2b9..986e4e1115c8 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -40,6 +40,7 @@ import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; import java.security.GeneralSecurityException; import java.security.PrivateKey; @@ -47,11 +48,11 @@ import java.security.cert.CertificateFactory; import java.security.cert.CertificateParsingException; import java.security.cert.X509Certificate; -import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.regex.Matcher; import java.util.regex.Pattern; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -60,33 +61,53 @@ @InternalApi public final class AgentIdentityUtils { - /** Javadoc. */ private static final Logger LOGGER = LoggerFactory.getLogger(AgentIdentityUtils.class); // Environment variables - /** Javadoc. */ - static final String GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG"; + /** + * Environment variable pointing to the client certificate configuration file. + * + *

If set, certificate and key paths are resolved from the configuration file specified by this + * variable. + */ + public static final String GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG"; - /** Javadoc. */ - static final String GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES = - "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES"; + /** + * Environment variable to explicitly enable or disable runtime token binding. Defaults to true if + * unset. + */ + public static final String GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN = + "GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN"; - /** Javadoc. */ - static final String GOOGLE_API_USE_CLIENT_CERTIFICATE = "GOOGLE_API_USE_CLIENT_CERTIFICATE"; + /** + * Legacy Cloud Run environment variable to prevent agent token sharing for GCP services. Used as + * a fallback if {@link #GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN} is unset. + */ + public static final String GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES = + "GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES"; + + /** + * Environment variable to explicitly enable or disable client certificate authentication (mTLS). + * + *

When set to {@code "true"}, mTLS is enforced. When set to {@code "false"}, mTLS and token + * binding are disabled. + */ + public static final String GOOGLE_API_USE_CLIENT_CERTIFICATE = + "GOOGLE_API_USE_CLIENT_CERTIFICATE"; - /** Javadoc. */ + // Allowed SPIFFE trust domain patterns for agentic identities. private static final List AGENT_IDENTITY_SPIFFE_PATTERNS = ImmutableList.of( Pattern.compile("^agents\\.global\\.org-\\d+\\.system\\.id\\.goog$"), - Pattern.compile("^agents\\.global\\.proj-\\d+\\.system\\.id\\.goog$")); + Pattern.compile("^agents\\.global\\.proj-\\d+\\.system\\.id\\.goog$"), + Pattern.compile("^agents-nonprod\\.global\\.org-\\d+\\.system\\.id\\.goog$"), + Pattern.compile("^agents-nonprod\\.global\\.proj-\\d+\\.system\\.id\\.goog$")); - /** Javadoc. */ + // Subject Alternative Name (SAN) type for URI as defined in RFC 5280 Section 4.2.1.6. private static final int SAN_URI_TYPE = 6; - /** Javadoc. */ private static final String SPIFFE_SCHEME_PREFIX = "spiffe://"; - /** Javadoc. */ private static String wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/"; @VisibleForTesting @@ -94,57 +115,58 @@ static void setWellKnownDir(final String dir) { wellKnownDir = dir; } - // Polling configuration - /** Javadoc. */ + // Retries for verifying certificate and private key matching during atomic key rotation. private static final int CERT_KEY_MATCH_RETRIES = 3; - /** Javadoc. */ private static final long CERT_KEY_MATCH_RETRY_INTERVAL_MS = 100; - /** Javadoc. */ + // Polling configuration for initial container startup credential file readiness. + // Matches Python google-auth implementation (_agent_identity_utils.py) for Cloud Run + // asynchronous credential delivery (50 * 100ms + 50 * 500ms = 30 seconds total). private static final int FAST_POLL_CYCLES = 50; - /** Javadoc. */ private static final long FAST_POLL_INTERVAL_MS = 100; // 0.1 seconds - /** Javadoc. */ private static final long SLOW_POLL_INTERVAL_MS = 500; // 0.5 seconds - /** Javadoc. */ private static final long TOTAL_TIMEOUT_MS = 30000; // 30 seconds - private static final List POLLING_INTERVALS; + private static final int TOTAL_POLL_CYCLES = + FAST_POLL_CYCLES + + (int) + ((TOTAL_TIMEOUT_MS - (FAST_POLL_CYCLES * FAST_POLL_INTERVAL_MS)) + / SLOW_POLL_INTERVAL_MS); - static { - List intervals = new ArrayList<>(); - for (int i = 0; i < FAST_POLL_CYCLES; i++) { - intervals.add(FAST_POLL_INTERVAL_MS); - } - long remainingTime = TOTAL_TIMEOUT_MS - (FAST_POLL_CYCLES * FAST_POLL_INTERVAL_MS); - int slowPollCycles = (int) (remainingTime / SLOW_POLL_INTERVAL_MS); - for (int i = 0; i < slowPollCycles; i++) { - intervals.add(SLOW_POLL_INTERVAL_MS); - } - POLLING_INTERVALS = Collections.unmodifiableList(intervals); + private static long getSleepIntervalMs(final int cycle) { + return (cycle < FAST_POLL_CYCLES) ? FAST_POLL_INTERVAL_MS : SLOW_POLL_INTERVAL_MS; } + /** Functional interface for reading environment variables to facilitate testing. */ public interface EnvReader { - /** Javadoc. */ + /** + * Returns the value of the specified environment variable. + * + * @param name the environment variable name + * @return the value of the variable, or {@code null} if unset + */ String getEnv(final String name); } - /** Javadoc. */ private static EnvReader envReader = System::getenv; @VisibleForTesting interface TimeService { long currentTimeMillis(); - /** Javadoc. */ + /** + * Causes the currently executing thread to sleep for the specified number of milliseconds. + * + * @param millis the length of time to sleep in milliseconds + * @throws InterruptedException if interrupted while sleeping + */ void sleep(final long millis) throws InterruptedException; } - /** Javadoc. */ private static TimeService timeService = new TimeService() { @Override @@ -160,11 +182,15 @@ public void sleep(final long millis) throws InterruptedException { private AgentIdentityUtils() {} + /** + * Holds the parsed {@link X509Certificate} and cached PEM certificate chain content for token + * binding. + * + *

This class intentionally stores in-memory certificate content rather than file paths to + * prevent redundant filesystem reads and race conditions when binding tokens to requests. + */ static class CertInfo { - /** Javadoc. */ private final X509Certificate certificate; - - /** Javadoc. */ private final String certContent; CertInfo(final X509Certificate certificate, final String certContent) { @@ -172,42 +198,66 @@ static class CertInfo { this.certContent = certContent; } - /** Javadoc. */ + /** Returns the parsed {@link X509Certificate}. */ public X509Certificate getCertificate() { return certificate; } - /** Javadoc. */ + /** Returns the raw PEM certificate chain content. */ public String getCertContent() { return certContent; } } + /** Holds the resolved filesystem paths for the certificate and private key. */ static class ResolvedCertAndKeyPaths { - /** Javadoc. */ private final String certPath; - - /** Javadoc. */ private final String keyPath; + private final boolean hasWorkloadConfig; ResolvedCertAndKeyPaths(final String certPath, final String keyPath) { + this(certPath, keyPath, !Strings.isNullOrEmpty(certPath)); + } + + ResolvedCertAndKeyPaths( + final String certPath, final String keyPath, final boolean hasWorkloadConfig) { this.certPath = certPath; this.keyPath = keyPath; + this.hasWorkloadConfig = hasWorkloadConfig; } - /** Javadoc. */ + /** Returns the path to the certificate or bundle file. */ public String getCertPath() { return certPath; } - /** Javadoc. */ + /** Returns the path to the private key file, or bundle path if combined. */ public String getKeyPath() { return keyPath; } + + /** Returns whether a workload configuration was parsed from the certificate config file. */ + public boolean hasWorkloadConfig() { + return hasWorkloadConfig; + } + } + + /** Checks whether the given path resides within the well-known certificate directory. */ + static boolean isPathInWellKnownDir(final String pathStr) { + if (Strings.isNullOrEmpty(pathStr) || Strings.isNullOrEmpty(wellKnownDir)) { + return false; + } + try { + Path path = Paths.get(pathStr).toAbsolutePath().normalize(); + Path wellKnown = Paths.get(wellKnownDir).toAbsolutePath().normalize(); + return path.startsWith(wellKnown); + } catch (Exception e) { + return false; + } } /** - * Retrieves the certificate and path for the Agent Identity. + * Retrieves the certificate and raw PEM content for the Agent Identity. * *

This method attempts to load the certificate and private key for the agent identity. It * first checks the location specified by the {@code GOOGLE_API_CERTIFICATE_CONFIG} environment @@ -216,9 +266,9 @@ public String getKeyPath() { *

To handle transient race conditions during certificate rotation on disk, this method employs * a retry mechanism with backoff when reading the configuration and certificate files. * - * @return A {@link CertInfo} object containing the loaded certificate and its path, or {@code - * null} if the agent identity features are disabled, opted out, or if no valid credentials - * could be loaded. + * @return A {@link CertInfo} object containing the parsed {@link X509Certificate} and its raw PEM + * chain content, or {@code null} if the agent identity features are disabled, opted out, or + * if no valid credentials could be loaded. * @throws IOException If an I/O error occurs while reading the files, or if the key-pair * verification fails after retries. */ @@ -226,12 +276,23 @@ static CertInfo getAgentIdentityCertInfo() throws IOException { if (!isTokenBindingEnabled()) { return null; } + String useClientCert = envReader.getEnv(GOOGLE_API_USE_CLIENT_CERTIFICATE); + if ("false".equalsIgnoreCase(useClientCert)) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Token binding protection is disabled because mTLS was explicitly disabled" + + " via GOOGLE_API_USE_CLIENT_CERTIFICATE."); + return null; + } String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG); - boolean configExists = - !Strings.isNullOrEmpty(certConfigPath) && Files.exists(Paths.get(certConfigPath)); - ResolvedCertAndKeyPaths paths = resolveCertAndKeyPaths(certConfigPath); - boolean certsPresent = !Strings.isNullOrEmpty(paths.getCertPath()); + boolean configExists = paths != null && paths.hasWorkloadConfig(); + boolean certsPresent = + paths != null + && !Strings.isNullOrEmpty(paths.getCertPath()) + && Files.exists(Paths.get(paths.getCertPath())); if (!shouldEnableMtls(certsPresent, configExists)) { return null; @@ -246,44 +307,55 @@ static CertInfo getAgentIdentityCertInfo() throws IOException { */ static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(final String certConfigPath) throws IOException { - String certPath = null; - String keyPath = null; - if (!Strings.isNullOrEmpty(certConfigPath)) { java.nio.file.Path configPath = Paths.get(certConfigPath); - if (!Files.exists(configPath) && !Files.exists(Paths.get(wellKnownDir))) { - // Fail-fast if config doesn't exist and we are not in a workload environment - return new ResolvedCertAndKeyPaths(null, null); + try { + if (!checkExistsOrAccessDenied(configPath) && !isPathInWellKnownDir(certConfigPath)) { + // Fail-fast if config doesn't exist and is not in well-known directory + return new ResolvedCertAndKeyPaths(null, null, false); + } + } catch (java.nio.file.AccessDeniedException e) { + throw new IOException( + "Permission denied reading certificate config file: " + certConfigPath, e); } // Read cert and key paths from config file. We use retry with backoff to handle - // transient - // race conditions where the config file might be being updated by a rotation process. + // transient race conditions where the config file might be being updated by a rotation + // process. ResolvedCertAndKeyPaths paths = getPathsFromConfigWithRetry(certConfigPath); if (paths != null) { - certPath = paths.getCertPath(); - keyPath = paths.getKeyPath(); + return paths; } + return new ResolvedCertAndKeyPaths(null, null, false); } else { if (!Files.exists(Paths.get(wellKnownDir))) { // Fail-fast if well-known dir doesn't exist (e.g. workstation) - return new ResolvedCertAndKeyPaths(null, null); + return new ResolvedCertAndKeyPaths(null, null, false); } // Fallback to well-known locations. We use retry with backoff here as well to handle // race conditions during file replacement by a rotation process. - certPath = getWellKnownCertificatePathWithRetry(); + String certPath = getWellKnownCertificatePathWithRetry(); + String keyPath = null; if (certPath != null) { if (certPath.endsWith("credentialbundle.pem")) { keyPath = certPath; // Bundle contains both } else if (certPath.endsWith("certificates.pem")) { keyPath = Paths.get(wellKnownDir, "private_key.pem").toString(); } + return new ResolvedCertAndKeyPaths(certPath, keyPath, false); } + return new ResolvedCertAndKeyPaths(null, null, false); } - return new ResolvedCertAndKeyPaths(certPath, keyPath); } /** - * Loads the certificate and private key, and verifies that they match if they are separate files. + * Loads the certificate and private key, and verifies that they form a valid cryptographic + * key-pair, supporting both separate files and combined bundle files. + * + * @param certPath The path to the certificate or bundle file. + * @param keyPath The path to the private key or bundle file. + * @return A {@link CertInfo} object containing the parsed {@link X509Certificate} and the raw PEM + * certificate chain (with private keys stripped). + * @throws IOException If the files cannot be read or parsed, or if key-pair verification fails. */ static CertInfo loadAndVerifyCredentials(final String certPath, final String keyPath) throws IOException { @@ -293,9 +365,8 @@ static CertInfo loadAndVerifyCredentials(final String certPath, final String key if (!Strings.isNullOrEmpty(certPath) && !Strings.isNullOrEmpty(keyPath) - && !certPath.equals(keyPath) && Files.exists(Paths.get(keyPath))) { - // Separate files, verify match with retry + // Verify match with retry (handles both separate files and combined bundle files) int retries = 0; boolean matched = false; while (retries < CERT_KEY_MATCH_RETRIES) { @@ -310,6 +381,10 @@ static CertInfo loadAndVerifyCredentials(final String certPath, final String key } LOGGER.warn("Cert and key mismatch, retrying..."); } catch (java.nio.file.AccessDeniedException e) { + if (!Strings.isNullOrEmpty(envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG))) { + throw new IOException( + "Permission denied reading certificate or key files for Agent Identity.", e); + } Slf4jUtils.log( LOGGER, org.slf4j.event.Level.WARN, @@ -345,6 +420,10 @@ static CertInfo loadAndVerifyCredentials(final String certPath, final String key certContent = readCertificateChain(certPath); cert = parseCertificateContent(certContent); } catch (java.nio.file.AccessDeniedException e) { + if (!Strings.isNullOrEmpty(envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG))) { + throw new IOException( + "Permission denied reading certificate files for Agent Identity.", e); + } Slf4jUtils.log( LOGGER, org.slf4j.event.Level.WARN, @@ -370,10 +449,24 @@ private static boolean checkExistsOrAccessDenied(final java.nio.file.Path path) } } - /** Checks if the user has disabled token binding by setting the environment variable to false. */ + /** + * Checks if runtime token binding is enabled. + * + *

Checks {@link #GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN} first; if unset, falls back to the + * legacy {@link #GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES}. Defaults to {@code + * true} if neither is set. + */ private static boolean isTokenBindingEnabled() { - String preventSharing = envReader.getEnv(GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES); - return !("false".equalsIgnoreCase(preventSharing)); + String enableRuntimeBoundToken = envReader.getEnv(GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN); + if (!Strings.isNullOrEmpty(enableRuntimeBoundToken)) { + return !"false".equalsIgnoreCase(enableRuntimeBoundToken.trim()); + } + String legacyPreventSharing = + envReader.getEnv(GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES); + if (!Strings.isNullOrEmpty(legacyPreventSharing)) { + return !"false".equalsIgnoreCase(legacyPreventSharing.trim()); + } + return true; } /** @@ -382,30 +475,43 @@ private static boolean isTokenBindingEnabled() { */ private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(final String certConfigPath) throws IOException { + boolean shouldPoll = isPathInWellKnownDir(certConfigPath); + int maxCycles = shouldPoll ? TOTAL_POLL_CYCLES : 1; boolean warned = false; - for (long sleepInterval : POLLING_INTERVALS) { + + for (int cycle = 0; cycle < maxCycles; cycle++) { try { if (checkExistsOrAccessDenied(Paths.get(certConfigPath))) { ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath); - if (paths != null - && !Strings.isNullOrEmpty(paths.getCertPath()) - && checkExistsOrAccessDenied(Paths.get(paths.getCertPath()))) { - return paths; + if (paths != null) { + if (!paths.hasWorkloadConfig()) { + // Valid non-workload config (e.g. enterprise certs) - exit early without polling! + return paths; + } + if (!Strings.isNullOrEmpty(paths.getCertPath()) + && checkExistsOrAccessDenied(Paths.get(paths.getCertPath()))) { + return paths; + } + if (!shouldPoll) { + // Cert file not ready and not in well-known dir - exit early + return paths; + } } + } else if (!shouldPoll) { + // Config file doesn't exist and not in well-known dir - exit early + return new ResolvedCertAndKeyPaths(null, null, false); } } catch (java.nio.file.AccessDeniedException e) { - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - "Permission denied reading certificate config file. Falling back to unbound" - + " token."); - return null; + throw new IOException( + "Permission denied reading certificate config file: " + certConfigPath, e); } catch (IOException e) { if (e.getMessage() != null && e.getMessage().contains("Failed to parse Agent Identity config JSON")) { throw e; // Fail fast on malformed JSON syntax errors } + if (!shouldPoll) { + return new ResolvedCertAndKeyPaths(null, null, false); + } // Fall through to retry } if (!warned) { @@ -420,7 +526,7 @@ && checkExistsOrAccessDenied(Paths.get(paths.getCertPath()))) { warned = true; } try { - timeService.sleep(sleepInterval); + timeService.sleep(getSleepIntervalMs(cycle)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException( @@ -433,7 +539,7 @@ && checkExistsOrAccessDenied(Paths.get(paths.getCertPath()))) { "Unable to find Agent Identity certificate config or file for bound token request" + " after multiple retries. Token binding protection is failing. You can turn" + " off this protection by setting " - + GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES + + GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN + " to false to fall back to unbound tokens."); } @@ -478,7 +584,7 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException // 3) Retry loop for rotation/transient absence when explicitly enabled: boolean warned = false; - for (long sleepInterval : POLLING_INTERVALS) { + for (int cycle = 0; cycle < TOTAL_POLL_CYCLES; cycle++) { try { if (checkExistsOrAccessDenied(Paths.get(bundlePath))) { return bundlePath; @@ -508,7 +614,7 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException warned = true; } try { - timeService.sleep(sleepInterval); + timeService.sleep(getSleepIntervalMs(cycle)); } catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException("Interrupted while waiting for well-known certificate files.", e); @@ -525,9 +631,25 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException return null; } - /** Reads the full certificate chain from the specified path as a string. */ + /** + * Reads the full certificate chain from the specified path as a PEM string. + * + *

Extracts only the {@code -----BEGIN CERTIFICATE-----} blocks using {@link + * CertificateIdentityPoolSubjectTokenSupplier#PEM_CERT_PATTERN}, stripping any private keys or + * non-certificate data that may be present in a combined bundle file. + */ static String readCertificateChain(final String certPath) throws IOException { - return new String(Files.readAllBytes(Paths.get(certPath)), StandardCharsets.UTF_8); + byte[] certData = Files.readAllBytes(Paths.get(certPath)); + String content = new String(certData, StandardCharsets.UTF_8); + Matcher matcher = CertificateIdentityPoolSubjectTokenSupplier.PEM_CERT_PATTERN.matcher(content); + StringBuilder certChain = new StringBuilder(); + while (matcher.find()) { + certChain.append(matcher.group(0)).append("\n"); + } + if (certChain.length() == 0) { + throw new IOException("No PEM certificates found in certificate file: " + certPath); + } + return certChain.toString(); } /** @@ -654,15 +776,17 @@ private static ResolvedCertAndKeyPaths extractPathsFromConfig(final String certC if (workload.get("key_path") instanceof String) { keyPath = (String) workload.get("key_path"); } - return new ResolvedCertAndKeyPaths(certPath, keyPath); + return new ResolvedCertAndKeyPaths(certPath, keyPath, true); } + // Valid cert_configs object but not a workload config (e.g. enterprise certificates) + return new ResolvedCertAndKeyPaths(null, null, false); } + return new ResolvedCertAndKeyPaths(null, null, false); } catch (java.nio.file.AccessDeniedException e) { throw e; } catch (Exception e) { throw new IOException("Failed to parse Agent Identity config JSON", e); } - return null; } /** Parses the X509 certificate from the specified content string. */ @@ -720,16 +844,19 @@ static boolean shouldRequestBoundToken(final X509Certificate cert) { return false; } + /** Sets the environment variable reader for testing. */ @VisibleForTesting public static void setEnvReader(EnvReader reader) { envReader = reader; } + /** Sets the time and sleep service for testing. */ @VisibleForTesting static void setTimeService(TimeService service) { timeService = service; } + /** Resets the time and sleep service back to default system implementation. */ @VisibleForTesting static void resetTimeService() { timeService = diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java index 5b2ad1169ed0..a3edbb708390 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java @@ -66,7 +66,7 @@ public class CertificateIdentityPoolSubjectTokenSupplier private final IdentityPoolCredentialSource credentialSource; - private static final Pattern PEM_CERT_PATTERN = + static final Pattern PEM_CERT_PATTERN = Pattern.compile("-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", Pattern.DOTALL); CertificateIdentityPoolSubjectTokenSupplier(IdentityPoolCredentialSource credentialSource) { @@ -222,7 +222,8 @@ private void populateCertChainFromTrustChain( // elsewhere in the chain. if (encodedCurrentCert.equals(encodedLeafCert)) { throw new IllegalArgumentException( - "The leaf certificate should only appear at the beginning of the trust chain file, or be omitted entirely."); + "The leaf certificate should only appear at the beginning of the trust chain file, or" + + " be omitted entirely."); } // Add the current certificate to the chain. diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java index 062c2c3a4415..4e267719c0f8 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java @@ -60,6 +60,7 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; class AgentIdentityUtilsTest { @@ -67,18 +68,21 @@ class AgentIdentityUtilsTest { "spiffe://agents.global.org-12345.system.id.goog/path/to/resource"; private static final String VALID_SPIFFE_PROJ = "spiffe://agents.global.proj-98765.system.id.goog/another/path"; + private static final String VALID_SPIFFE_NONPROD_ORG = + "spiffe://agents-nonprod.global.org-12345.system.id.goog/path/to/resource"; + private static final String VALID_SPIFFE_NONPROD_PROJ = + "spiffe://agents-nonprod.global.proj-98765.system.id.goog/another/path"; private static final String INVALID_SPIFFE_DOMAIN = "spiffe://example.com/workload"; private static final String INVALID_SPIFFE_FORMAT = "spiffe://agents.global.org-INVALID.system.id.goog/path"; private TestEnvironmentProvider envProvider; - private Path tempDir; + @TempDir private Path tempDir; @BeforeEach void setUp() throws IOException { envProvider = new TestEnvironmentProvider(); AgentIdentityUtils.setEnvReader(envProvider::getEnv); - tempDir = Files.createTempDirectory("agent_identity_test"); } @AfterEach @@ -86,12 +90,6 @@ void tearDown() throws IOException { AgentIdentityUtils.resetTimeService(); AgentIdentityUtils.setWellKnownDir("/var/run/secrets/workload-spiffe-credentials/"); AgentIdentityUtils.setEnvReader(System::getenv); - if (tempDir != null) { - Files.walk(tempDir) - .sorted(java.util.Comparator.reverseOrder()) - .map(Path::toFile) - .forEach(File::delete); - } } @Test @@ -104,6 +102,20 @@ public void shouldRequestBoundToken_validProjSpiffe_returnsTrue() throws Certifi assertTrue(AgentIdentityUtils.shouldRequestBoundToken(mockCertWithSanUri(VALID_SPIFFE_PROJ))); } + @Test + public void shouldRequestBoundToken_validNonprodOrgSpiffe_returnsTrue() + throws CertificateException { + assertTrue( + AgentIdentityUtils.shouldRequestBoundToken(mockCertWithSanUri(VALID_SPIFFE_NONPROD_ORG))); + } + + @Test + public void shouldRequestBoundToken_validNonprodProjSpiffe_returnsTrue() + throws CertificateException { + assertTrue( + AgentIdentityUtils.shouldRequestBoundToken(mockCertWithSanUri(VALID_SPIFFE_NONPROD_PROJ))); + } + @Test public void shouldRequestBoundToken_invalidDomain_returnsFalse() throws CertificateException { assertFalse( @@ -150,28 +162,56 @@ private X509Certificate mockCertWithSanUri(String uri) throws CertificateExcepti } @Test - public void getAgentIdentityCertificate_optedOut_returnsNullImmediately() throws IOException { - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); - envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", "/non/existent/path"); + public void getAgentIdentityCertificate_enableRuntimeBoundTokenFalse_returnsNullImmediately() + throws IOException { + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "false"); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, "/non/existent/path"); + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + } + + @Test + public void getAgentIdentityCertificate_legacyPreventSharingFalse_returnsNullImmediately() + throws IOException { + envProvider.setEnv( + AgentIdentityUtils.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, "false"); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, "/non/existent/path"); assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); } @Test - public void getAgentIdentityCertificate_preventTokenSharingTrue_doesNotOptOut() throws Exception { - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); + public void getAgentIdentityCertificate_useClientCertFalse_returnsNullImmediatelyWithoutPolling() + throws Exception { + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_USE_CLIENT_CERTIFICATE, "false"); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, "/non/existent/path"); AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); - URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); - assertNotNull(certUrl, "Test resource agent/agent_spiffe_cert.pem not found"); - String certPath = Paths.get(certUrl.toURI()).toAbsolutePath().toString(); - Files.copy(Paths.get(certPath), tempDir.resolve("certificates.pem")); + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + assertEquals(0, fakeTime.getSleepCount()); + } - URL keyUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_key.pem"); - assertNotNull(keyUrl, "Test resource agent/agent_spiffe_key.pem not found"); - String keyPath = Paths.get(keyUrl.toURI()).toAbsolutePath().toString(); - Files.copy(Paths.get(keyPath), tempDir.resolve("private_key.pem")); + @Test + public void getAgentIdentityCertificate_modernVarOverridesLegacyVar() throws Exception { + setupValidAgentCredentialsInTempDir(); + // Modern is true, Legacy is false -> should NOT opt out + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true"); + envProvider.setEnv( + AgentIdentityUtils.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, "false"); + assertNotNull(AgentIdentityUtils.getAgentIdentityCertInfo()); - envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); + // Modern is false, Legacy is true -> should opt out + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "false"); + envProvider.setEnv( + AgentIdentityUtils.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, "true"); + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + } + + @Test + public void getAgentIdentityCertificate_enableRuntimeBoundTokenTrue_doesNotOptOut() + throws Exception { + setupValidAgentCredentialsInTempDir(); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true"); AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); assertNotNull(info); @@ -180,6 +220,53 @@ public void getAgentIdentityCertificate_preventTokenSharingTrue_doesNotOptOut() info.getCertContent()); } + @Test + public void getAgentIdentityCertificate_legacyPreventSharingTrue_doesNotOptOut() + throws Exception { + setupValidAgentCredentialsInTempDir(); + envProvider.setEnv( + AgentIdentityUtils.GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES, "true"); + + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(info); + assertEquals( + new String(Files.readAllBytes(tempDir.resolve("certificates.pem")), StandardCharsets.UTF_8), + info.getCertContent()); + } + + @Test + public void getAgentIdentityCertificate_bothUnset_defaultsToEnabled() throws Exception { + setupValidAgentCredentialsInTempDir(); + + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(info); + assertEquals( + new String(Files.readAllBytes(tempDir.resolve("certificates.pem")), StandardCharsets.UTF_8), + info.getCertContent()); + } + + private void setupValidAgentCredentialsInTempDir() throws Exception { + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + + URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); + assertNotNull(certUrl, "Test resource agent/agent_spiffe_cert.pem not found"); + String certPath = Paths.get(certUrl.toURI()).toAbsolutePath().toString(); + Files.copy( + Paths.get(certPath), + tempDir.resolve("certificates.pem"), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + + URL keyUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_key.pem"); + assertNotNull(keyUrl, "Test resource agent/agent_spiffe_key.pem not found"); + String keyPath = Paths.get(keyUrl.toURI()).toAbsolutePath().toString(); + Files.copy( + Paths.get(keyPath), + tempDir.resolve("private_key.pem"), + java.nio.file.StandardCopyOption.REPLACE_EXISTING); + + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, null); + } + @Test public void getAgentIdentityCertificate_noConfigEnvVar_returnsNull() throws IOException { AgentIdentityUtils.setTimeService(new FakeTimeService()); @@ -440,6 +527,266 @@ public void getAgentIdentityCertInfo_mismatch_throwsIOExceptionAfterRetries() th assertTrue(fakeTime.getSleepCount() > 0); } + @Test + public void readCertificateChain_combinedBundle_stripsPrivateKeyAndRetainsCertificates() + throws Exception { + URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); + assertNotNull(certUrl); + String certPem = + new String(Files.readAllBytes(Paths.get(certUrl.toURI())), StandardCharsets.UTF_8); + + URL keyUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_key.pem"); + assertNotNull(keyUrl); + String keyPem = + new String(Files.readAllBytes(Paths.get(keyUrl.toURI())), StandardCharsets.UTF_8); + + // Create a bundle file with cert then private key then cert again + String bundleContent = certPem + "\n" + keyPem + "\n" + certPem; + Path bundleFile = tempDir.resolve("bundle.pem"); + Files.write(bundleFile, bundleContent.getBytes(StandardCharsets.UTF_8)); + + String extractedChain = AgentIdentityUtils.readCertificateChain(bundleFile.toString()); + + // Verify certificates are retained + assertTrue(extractedChain.contains("BEGIN CERTIFICATE")); + assertTrue(extractedChain.contains("END CERTIFICATE")); + // Verify private key is stripped + assertFalse(extractedChain.contains("PRIVATE KEY")); + assertFalse(extractedChain.contains("BEGIN PRIVATE KEY")); + assertFalse(extractedChain.contains("END PRIVATE KEY")); + } + + @Test + public void readCertificateChain_noCertificates_throwsIOException() throws Exception { + URL keyUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_key.pem"); + assertNotNull(keyUrl); + String keyPem = + new String(Files.readAllBytes(Paths.get(keyUrl.toURI())), StandardCharsets.UTF_8); + + Path keyOnlyFile = tempDir.resolve("key_only.pem"); + Files.write(keyOnlyFile, keyPem.getBytes(StandardCharsets.UTF_8)); + + IOException e = + assertThrows( + IOException.class, + () -> AgentIdentityUtils.readCertificateChain(keyOnlyFile.toString())); + assertTrue(e.getMessage().contains("No PEM certificates found in certificate file")); + } + + @Test + public void loadAndVerifyCredentials_validCombinedBundle_verifiesKeyAndStripsPrivateKey() + throws Exception { + URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); + assertNotNull(certUrl); + String certPem = + new String(Files.readAllBytes(Paths.get(certUrl.toURI())), StandardCharsets.UTF_8); + + URL keyUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_key.pem"); + assertNotNull(keyUrl); + String keyPem = + new String(Files.readAllBytes(Paths.get(keyUrl.toURI())), StandardCharsets.UTF_8); + + String bundleContent = certPem + "\n" + keyPem; + Path bundleFile = tempDir.resolve("credentialbundle.pem"); + Files.write(bundleFile, bundleContent.getBytes(StandardCharsets.UTF_8)); + + AgentIdentityUtils.CertInfo info = + AgentIdentityUtils.loadAndVerifyCredentials(bundleFile.toString(), bundleFile.toString()); + assertNotNull(info); + assertNotNull(info.getCertificate()); + assertTrue(AgentIdentityUtils.shouldRequestBoundToken(info.getCertificate())); + assertTrue(info.getCertContent().contains("BEGIN CERTIFICATE")); + assertFalse(info.getCertContent().contains("PRIVATE KEY")); + } + + @Test + public void loadAndVerifyCredentials_bundleWithMismatchedKey_failsVerification() + throws Exception { + URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); + assertNotNull(certUrl); + String certPem = + new String(Files.readAllBytes(Paths.get(certUrl.toURI())), StandardCharsets.UTF_8); + + // Generate a random key that does not match the cert + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + String mismatchedKeyPem = + "-----BEGIN PRIVATE KEY-----\n" + + java.util.Base64.getMimeEncoder().encodeToString(kp.getPrivate().getEncoded()) + + "\n-----END PRIVATE KEY-----"; + + String bundleContent = certPem + "\n" + mismatchedKeyPem; + Path bundleFile = tempDir.resolve("credentialbundle.pem"); + Files.write(bundleFile, bundleContent.getBytes(StandardCharsets.UTF_8)); + + AgentIdentityUtils.setTimeService(new FakeTimeService()); + + IOException e = + assertThrows( + IOException.class, + () -> + AgentIdentityUtils.loadAndVerifyCredentials( + bundleFile.toString(), bundleFile.toString())); + assertTrue( + e.getMessage() + .contains( + "Agent Identity certificate and private key mismatch or read failure after 3" + + " retries.")); + } + + @Test + public void getAgentIdentityCertInfo_wellKnownBundleFile_verifiesAndStripsPrivateKey() + throws Exception { + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + + URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); + assertNotNull(certUrl); + String certPem = + new String(Files.readAllBytes(Paths.get(certUrl.toURI())), StandardCharsets.UTF_8); + + URL keyUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_key.pem"); + assertNotNull(keyUrl); + String keyPem = + new String(Files.readAllBytes(Paths.get(keyUrl.toURI())), StandardCharsets.UTF_8); + + String bundleContent = certPem + "\n" + keyPem; + Path bundleFile = tempDir.resolve("credentialbundle.pem"); + Files.write(bundleFile, bundleContent.getBytes(StandardCharsets.UTF_8)); + + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, null); + + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(info); + assertNotNull(info.getCertificate()); + assertTrue(AgentIdentityUtils.shouldRequestBoundToken(info.getCertificate())); + assertTrue(info.getCertContent().contains("BEGIN CERTIFICATE")); + assertFalse(info.getCertContent().contains("PRIVATE KEY")); + } + + @Test + public void getAgentIdentityCertInfo_explicitConfigDirectoryAccessDenied_throwsIOException() + throws Exception { + Path restrictedDir = tempDir.resolve("restricted_dir"); + Files.createDirectory(restrictedDir); + Path configFile = restrictedDir.resolve("config.json"); + Files.write(configFile, "{}".getBytes(StandardCharsets.UTF_8)); + + restrictedDir.toFile().setReadable(false, false); + restrictedDir.toFile().setExecutable(false, false); + + try { + envProvider.setEnv( + AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, configFile.toAbsolutePath().toString()); + assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); + } finally { + restrictedDir.toFile().setReadable(true, false); + restrictedDir.toFile().setExecutable(true, false); + } + } + + @Test + public void getAgentIdentityCertInfo_explicitConfigFileNotReadable_throwsIOException() + throws Exception { + Path configFile = tempDir.resolve("unreadable_config.json"); + Files.write(configFile, "{}".getBytes(StandardCharsets.UTF_8)); + configFile.toFile().setReadable(false, false); + + try { + envProvider.setEnv( + AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, configFile.toAbsolutePath().toString()); + assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); + } finally { + configFile.toFile().setReadable(true, false); + } + } + + @Test + public void getAgentIdentityCertInfo_implicitWellKnownAccessDenied_returnsNull() + throws Exception { + Path restrictedDir = tempDir.resolve("restricted_well_known"); + Files.createDirectory(restrictedDir); + Path bundleFile = restrictedDir.resolve("credentialbundle.pem"); + Files.write(bundleFile, "test".getBytes(StandardCharsets.UTF_8)); + + restrictedDir.toFile().setReadable(false, false); + restrictedDir.toFile().setExecutable(false, false); + + try { + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, null); + AgentIdentityUtils.setWellKnownDir(restrictedDir.toAbsolutePath().toString() + "/"); + + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + } finally { + restrictedDir.toFile().setReadable(true, false); + restrictedDir.toFile().setExecutable(true, false); + } + } + + @Test + public void getAgentIdentityCertInfo_enterpriseConfig_returnsNullImmediatelyWithoutPolling() + throws Exception { + File configFile = tempDir.resolve("enterprise_config.json").toFile(); + try (FileOutputStream fos = new FileOutputStream(configFile)) { + String json = + "{\n" + + " \"cert_configs\": {\n" + + " \"enterprise\": {\n" + + " \"cert_provider_command\": [\"/bin/echo\", \"dummy\"]\n" + + " }\n" + + " }\n" + + "}"; + fos.write(json.getBytes(StandardCharsets.UTF_8)); + } + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath()); + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNull(info); + assertEquals(0, fakeTime.getSleepCount()); + } + + @Test + public void getAgentIdentityCertInfo_configWithoutWorkload_returnsNullImmediatelyWithoutPolling() + throws Exception { + File configFile = tempDir.resolve("empty_cert_configs.json").toFile(); + try (FileOutputStream fos = new FileOutputStream(configFile)) { + String json = "{ \"cert_configs\": {} }"; + fos.write(json.getBytes(StandardCharsets.UTF_8)); + } + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath()); + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNull(info); + assertEquals(0, fakeTime.getSleepCount()); + } + + @Test + public void + getAgentIdentityCertInfo_missingConfigOutsideWellKnownDir_returnsNullImmediatelyWithoutPolling() + throws Exception { + Path outsideDir = Files.createTempDirectory("outside_well_known"); + Path missingConfigFile = outsideDir.resolve("missing_config.json"); + try { + envProvider.setEnv( + "GOOGLE_API_CERTIFICATE_CONFIG", missingConfigFile.toAbsolutePath().toString()); + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNull(info); + assertEquals(0, fakeTime.getSleepCount()); + } finally { + Files.deleteIfExists(outsideDir); + } + } + private static class FakeTimeService implements AgentIdentityUtils.TimeService { private final AtomicLong currentTime = new AtomicLong(0); private final AtomicInteger sleepCount = new AtomicInteger(0); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 8959f21ce98a..3e94886a52bc 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -48,6 +48,7 @@ import com.google.api.client.http.HttpTransport; import com.google.api.client.http.LowLevelHttpRequest; import com.google.api.client.http.LowLevelHttpResponse; +import com.google.api.client.json.GenericJson; import com.google.api.client.json.webtoken.JsonWebToken.Payload; import com.google.api.client.testing.http.MockLowLevelHttpRequest; import com.google.api.client.testing.http.MockLowLevelHttpResponse; @@ -113,7 +114,7 @@ public void sleep(long millis) { } }); // Opt out of bound tokens by default in tests to avoid polling delays - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "false"); } @AfterEach @@ -1281,7 +1282,7 @@ InputStream readStream(File file) throws FileNotFoundException { @Test void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true"); envProvider.setEnv( AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, tempDir.resolve("missing_config.json").toAbsolutePath().toString()); @@ -1358,7 +1359,7 @@ private void setupCertAndKeyConfig() throws IOException { void refreshAccessToken_withValidCertAndKey_requestsBoundToken() throws IOException { setupCertAndKeyConfig(); envProvider.setEnv( - "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token + AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true"); // Enable bound token MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); @@ -1372,14 +1373,18 @@ void refreshAccessToken_withValidCertAndKey_requestsBoundToken() throws IOExcept transportFactory.transport.getRequest(); assertEquals("POST", transportFactory.transport.getRequestMethod()); String body = request.getContentAsString(); - assertTrue(body.contains("certificate_chain")); + GenericJson bodyJson = OAuth2Utils.JSON_FACTORY.fromString(body, GenericJson.class); + String expectedCert = + new String(Files.readAllBytes(tempDir.resolve("certificates.pem")), StandardCharsets.UTF_8) + .trim(); + assertEquals(expectedCert, ((String) bodyJson.get("certificate_chain")).trim()); } @Test void idTokenWithAudience_withValidCertAndKey_requestsBoundToken() throws IOException { setupCertAndKeyConfig(); envProvider.setEnv( - "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token + AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true"); // Enable bound token MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); transportFactory.transport.setIdToken(STANDARD_ID_TOKEN); @@ -1393,7 +1398,11 @@ void idTokenWithAudience_withValidCertAndKey_requestsBoundToken() throws IOExcep transportFactory.transport.getRequest(); assertEquals("POST", transportFactory.transport.getRequestMethod()); String body = request.getContentAsString(); - assertTrue(body.contains("certificate_chain")); + GenericJson bodyJson = OAuth2Utils.JSON_FACTORY.fromString(body, GenericJson.class); + String expectedCert = + new String(Files.readAllBytes(tempDir.resolve("certificates.pem")), StandardCharsets.UTF_8) + .trim(); + assertEquals(expectedCert, ((String) bodyJson.get("certificate_chain")).trim()); } private static class TestEnvironmentProvider { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java index 7a5a50c4dd64..4e454f123948 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java @@ -79,7 +79,7 @@ void setUp() { // Isolate tests and opt out of bound tokens by default to avoid polling delays AgentIdentityUtils.setEnvReader( name -> { - if ("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES".equals(name)) { + if (AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN.equals(name)) { return "false"; // Triggers isTokenBindingEnabled() = false } return null; diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java index 7a22aeaeb9b6..7d62c4433490 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java @@ -61,7 +61,7 @@ String getEnv(String key) { void setUp() { envProvider = new TestEnvironmentProvider(); AgentIdentityUtils.setEnvReader(envProvider::getEnv); - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "false"); } @AfterEach diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java index 09d44ad0456f..905bb05ac0fc 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/LoggingTest.java @@ -87,7 +87,7 @@ void setUp() { // Opt out of bound tokens by default to avoid polling delays AgentIdentityUtils.setEnvReader( name -> { - if ("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES".equals(name)) { + if (AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN.equals(name)) { return "false"; } return null; diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java index 3d0ed238bfc1..c94fe968f0a4 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java @@ -75,7 +75,7 @@ void setUp() { envProvider = new TestEnvironmentProvider(); AgentIdentityUtils.setEnvReader(envProvider::getEnv); // Opt out by default to avoid polling delays or file reads - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "false"); } @AfterEach From 7742a7902e2d11d71868e8521b17cb9536538ed2 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 3 Sep 2026 18:35:09 +0000 Subject: [PATCH 22/25] docs(oauth2): make AgentIdentityUtils class Javadoc runtime-agnostic --- .../java/com/google/auth/oauth2/AgentIdentityUtils.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 986e4e1115c8..92f5cc97dc5d 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -57,7 +57,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** Utility class for Agent Identity token binding in Cloud Run. */ +/** Utility class for Agent Identity runtime certificate discovery and token binding. */ @InternalApi public final class AgentIdentityUtils { @@ -121,7 +121,7 @@ static void setWellKnownDir(final String dir) { private static final long CERT_KEY_MATCH_RETRY_INTERVAL_MS = 100; // Polling configuration for initial container startup credential file readiness. - // Matches Python google-auth implementation (_agent_identity_utils.py) for Cloud Run + // Matches Python google-auth implementation (_agent_identity_utils.py) for initial // asynchronous credential delivery (50 * 100ms + 50 * 500ms = 30 seconds total). private static final int FAST_POLL_CYCLES = 50; From 09da90aa4ef595c3acf8d69cc78538169420986b Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 3 Sep 2026 20:40:37 +0000 Subject: [PATCH 23/25] docs(oauth2): clarify certificate-only fallback comment in AgentIdentityUtils --- .../java/com/google/auth/oauth2/AgentIdentityUtils.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 92f5cc97dc5d..5f34a852cd20 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -415,7 +415,7 @@ static CertInfo loadAndVerifyCredentials(final String certPath, final String key CERT_KEY_MATCH_RETRIES)); } } else if (!Strings.isNullOrEmpty(certPath)) { - // Bundle or only cert available + // Only certificate available (no private key specified or found) try { certContent = readCertificateChain(certPath); cert = parseCertificateContent(certContent); From b95a5daa8aa260595ded31d6bd15550a73755de2 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Wed, 16 Sep 2026 14:12:36 +0000 Subject: [PATCH 24/25] fix(oauth2): address PR 13873 review feedback on polling, caching, and key validation - Check isPathInWellKnownDir(paths.getCertPath()) after parsing workload config so startup polling is enabled even when GOOGLE_API_CERTIFICATE_CONFIG resides outside the well-known directory. - Avoid throwing early on malformed config JSON when polling is active to handle non-atomic container startup writes. - Record startup polling timeout (startupPollTimedOut) so subsequent token refreshes fail fast or fall back immediately without repeating 30-second sleep loops. - Require a valid matching private key whenever loading Agent Identity credentials for token binding, removing the cert-only fallback branch. - Check shouldRequestBoundToken(cert) immediately after parsing certificate content before reading private keys or executing signature verification. - Cache verified CertInfo (and negative non-agent SPIFFE results) across token refreshes based on file metadata (mtime, size, fileKey), invalidating on file rotation. - Make static fields (wellKnownDir, envReader, timeService) volatile and restrict EnvReader and setEnvReader visibility to package-private. - Use JUnit 5 @TempDir parameter in AgentIdentityUtilsTest and add unit tests covering all new behaviors. --- .../auth/oauth2/AgentIdentityUtils.java | 386 ++++++++++++------ .../auth/oauth2/AgentIdentityUtilsTest.java | 351 +++++++++++++++- .../FTComputeEngineCredentialsTest.java | 32 -- 3 files changed, 598 insertions(+), 171 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 5f34a852cd20..1d441fc99bc2 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -42,6 +42,8 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.attribute.BasicFileAttributes; +import java.nio.file.attribute.FileTime; import java.security.GeneralSecurityException; import java.security.PrivateKey; import java.security.Signature; @@ -52,6 +54,7 @@ import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.regex.Matcher; import java.util.regex.Pattern; import org.slf4j.Logger; @@ -108,11 +111,13 @@ public final class AgentIdentityUtils { private static final String SPIFFE_SCHEME_PREFIX = "spiffe://"; - private static String wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/"; + private static volatile String wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/"; @VisibleForTesting static void setWellKnownDir(final String dir) { wellKnownDir = dir; + cachedCredentials = null; + initialStartupCompleted = false; } // Retries for verifying certificate and private key matching during atomic key rotation. @@ -142,7 +147,8 @@ private static long getSleepIntervalMs(final int cycle) { } /** Functional interface for reading environment variables to facilitate testing. */ - public interface EnvReader { + @VisibleForTesting + interface EnvReader { /** * Returns the value of the specified environment variable. * @@ -152,7 +158,7 @@ public interface EnvReader { String getEnv(final String name); } - private static EnvReader envReader = System::getenv; + private static volatile EnvReader envReader = System::getenv; @VisibleForTesting interface TimeService { @@ -167,7 +173,7 @@ interface TimeService { void sleep(final long millis) throws InterruptedException; } - private static TimeService timeService = + private static volatile TimeService timeService = new TimeService() { @Override public long currentTimeMillis() { @@ -180,6 +186,67 @@ public void sleep(final long millis) throws InterruptedException { } }; + // Tracks whether initial container startup discovery has completed. The 30-second + // polling loop (TOTAL_POLL_CYCLES) is strictly limited to initial startup so subsequent + // token refreshes never block for 30 seconds. + private static volatile boolean initialStartupCompleted = false; + + // In-memory cache of verified credentials to avoid redundant disk reads, X.509/PKCS#8 parsing, + // and cryptographic signature verification on every token refresh when files are unchanged. + private static volatile CachedCredentials cachedCredentials; + + private static final class FileMetadata { + private final String path; + private final FileTime lastModifiedTime; + private final long size; + private final Object fileKey; + + private FileMetadata( + final String path, final FileTime lastModifiedTime, final long size, final Object fileKey) { + this.path = path; + this.lastModifiedTime = lastModifiedTime; + this.size = size; + this.fileKey = fileKey; + } + + static FileMetadata of(final String pathStr) throws IOException { + if (Strings.isNullOrEmpty(pathStr)) { + return null; + } + Path path = Paths.get(pathStr); + BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class); + return new FileMetadata(pathStr, attrs.lastModifiedTime(), attrs.size(), attrs.fileKey()); + } + + boolean matches(final FileMetadata other) { + if (other == null) { + return false; + } + return Objects.equals(this.path, other.path) + && Objects.equals(this.lastModifiedTime, other.lastModifiedTime) + && this.size == other.size + && Objects.equals(this.fileKey, other.fileKey); + } + } + + private static final class CachedCredentials { + private final FileMetadata certMetadata; + private final FileMetadata keyMetadata; + private final boolean shouldRequestBoundToken; + private final CertInfo certInfo; + + CachedCredentials( + final FileMetadata certMetadata, + final FileMetadata keyMetadata, + final boolean shouldRequestBoundToken, + final CertInfo certInfo) { + this.certMetadata = certMetadata; + this.keyMetadata = keyMetadata; + this.shouldRequestBoundToken = shouldRequestBoundToken; + this.certInfo = certInfo; + } + } + private AgentIdentityUtils() {} /** @@ -307,43 +374,47 @@ static CertInfo getAgentIdentityCertInfo() throws IOException { */ static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(final String certConfigPath) throws IOException { - if (!Strings.isNullOrEmpty(certConfigPath)) { - java.nio.file.Path configPath = Paths.get(certConfigPath); - try { - if (!checkExistsOrAccessDenied(configPath) && !isPathInWellKnownDir(certConfigPath)) { - // Fail-fast if config doesn't exist and is not in well-known directory - return new ResolvedCertAndKeyPaths(null, null, false); + try { + if (!Strings.isNullOrEmpty(certConfigPath)) { + java.nio.file.Path configPath = Paths.get(certConfigPath); + try { + if (!checkExistsOrAccessDenied(configPath) && !isPathInWellKnownDir(certConfigPath)) { + // Fail-fast if config doesn't exist and is not in well-known directory + return new ResolvedCertAndKeyPaths(null, null, false); + } + } catch (java.nio.file.AccessDeniedException e) { + throw new IOException( + "Permission denied reading certificate config file: " + certConfigPath, e); + } + // Read cert and key paths from config file. We use retry with backoff to handle + // transient race conditions where the config file might be being updated by a rotation + // process. + ResolvedCertAndKeyPaths paths = getPathsFromConfigWithRetry(certConfigPath); + if (paths != null) { + return paths; } - } catch (java.nio.file.AccessDeniedException e) { - throw new IOException( - "Permission denied reading certificate config file: " + certConfigPath, e); - } - // Read cert and key paths from config file. We use retry with backoff to handle - // transient race conditions where the config file might be being updated by a rotation - // process. - ResolvedCertAndKeyPaths paths = getPathsFromConfigWithRetry(certConfigPath); - if (paths != null) { - return paths; - } - return new ResolvedCertAndKeyPaths(null, null, false); - } else { - if (!Files.exists(Paths.get(wellKnownDir))) { - // Fail-fast if well-known dir doesn't exist (e.g. workstation) return new ResolvedCertAndKeyPaths(null, null, false); - } - // Fallback to well-known locations. We use retry with backoff here as well to handle - // race conditions during file replacement by a rotation process. - String certPath = getWellKnownCertificatePathWithRetry(); - String keyPath = null; - if (certPath != null) { - if (certPath.endsWith("credentialbundle.pem")) { - keyPath = certPath; // Bundle contains both - } else if (certPath.endsWith("certificates.pem")) { - keyPath = Paths.get(wellKnownDir, "private_key.pem").toString(); + } else { + if (!Files.exists(Paths.get(wellKnownDir))) { + // Fail-fast if well-known dir doesn't exist (e.g. workstation) + return new ResolvedCertAndKeyPaths(null, null, false); + } + // Fallback to well-known locations. We use retry with backoff here as well to handle + // race conditions during file replacement by a rotation process. + String certPath = getWellKnownCertificatePathWithRetry(); + String keyPath = null; + if (certPath != null) { + if (certPath.endsWith("credentialbundle.pem")) { + keyPath = certPath; // Bundle contains both + } else if (certPath.endsWith("certificates.pem")) { + keyPath = Paths.get(wellKnownDir, "private_key.pem").toString(); + } + return new ResolvedCertAndKeyPaths(certPath, keyPath, false); } - return new ResolvedCertAndKeyPaths(certPath, keyPath, false); + return new ResolvedCertAndKeyPaths(null, null, false); } - return new ResolvedCertAndKeyPaths(null, null, false); + } finally { + initialStartupCompleted = true; } } @@ -351,89 +422,133 @@ static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(final String certConfigPat * Loads the certificate and private key, and verifies that they form a valid cryptographic * key-pair, supporting both separate files and combined bundle files. * + *

Checks {@link #shouldRequestBoundToken(X509Certificate)} right after parsing the certificate + * content before reading the private key or running signature verification. Caches verified + * results in memory so unchanged files are not re-read and re-verified on every token refresh. + * * @param certPath The path to the certificate or bundle file. * @param keyPath The path to the private key or bundle file. * @return A {@link CertInfo} object containing the parsed {@link X509Certificate} and the raw PEM - * certificate chain (with private keys stripped). - * @throws IOException If the files cannot be read or parsed, or if key-pair verification fails. + * certificate chain (with private keys stripped), or {@code null} if the certificate does not + * match agent SPIFFE patterns or permission is denied on implicit discovery. + * @throws IOException If the files cannot be read or parsed, if the private key is missing for an + * agent certificate, or if key-pair verification fails after retries. */ static CertInfo loadAndVerifyCredentials(final String certPath, final String keyPath) throws IOException { - X509Certificate cert = null; - PrivateKey privateKey = null; - String certContent = null; - - if (!Strings.isNullOrEmpty(certPath) - && !Strings.isNullOrEmpty(keyPath) - && Files.exists(Paths.get(keyPath))) { - // Verify match with retry (handles both separate files and combined bundle files) - int retries = 0; - boolean matched = false; - while (retries < CERT_KEY_MATCH_RETRIES) { - try { - certContent = readCertificateChain(certPath); - cert = parseCertificateContent(certContent); - privateKey = readPrivateKey(keyPath, cert.getPublicKey().getAlgorithm()); + if (Strings.isNullOrEmpty(certPath)) { + return null; + } - if (verifyKeyPair(cert, privateKey)) { - matched = true; - break; + // Check in-memory cache fast-path before reading files or verifying keys + CachedCredentials cached = cachedCredentials; + if (cached != null) { + try { + FileMetadata currentCertMeta = FileMetadata.of(certPath); + if (currentCertMeta != null && currentCertMeta.matches(cached.certMetadata)) { + if (!cached.shouldRequestBoundToken) { + return null; } - LOGGER.warn("Cert and key mismatch, retrying..."); - } catch (java.nio.file.AccessDeniedException e) { - if (!Strings.isNullOrEmpty(envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG))) { - throw new IOException( - "Permission denied reading certificate or key files for Agent Identity.", e); + if (!Strings.isNullOrEmpty(keyPath)) { + FileMetadata currentKeyMeta = FileMetadata.of(keyPath); + if (currentKeyMeta != null && currentKeyMeta.matches(cached.keyMetadata)) { + return cached.certInfo; + } + } else if (cached.keyMetadata == null) { + return cached.certInfo; + } + } + } catch (IOException ignored) { + // File might be mid-rotation or temporarily unreadable; fall through to retry loop + } + } + + int retries = 0; + while (retries < CERT_KEY_MATCH_RETRIES) { + try { + FileMetadata certMeta = FileMetadata.of(certPath); + String certContent = readCertificateChain(certPath); + X509Certificate cert = parseCertificateContent(certContent); + + if (!shouldRequestBoundToken(cert)) { + if (certMeta != null && certMeta.matches(FileMetadata.of(certPath))) { + cachedCredentials = new CachedCredentials(certMeta, null, false, null); } - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - "Permission denied reading certificate or key files. Falling back to" - + " unbound token."); return null; - } catch (Exception e) { - LOGGER.warn("Failed to read or verify cert/key, retrying...", e); } - retries++; - if (retries < CERT_KEY_MATCH_RETRIES) { - try { - timeService.sleep(CERT_KEY_MATCH_RETRY_INTERVAL_MS); // 0.1 seconds backoff - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("Interrupted while waiting for cert/key match.", e); + if (Strings.isNullOrEmpty(keyPath)) { + // Certificate-only configuration (e.g. sidecar/proxy mTLS termination where the + // private key is isolated from the application container). + CertInfo info = new CertInfo(cert, certContent); + if (certMeta != null && certMeta.matches(FileMetadata.of(certPath))) { + cachedCredentials = new CachedCredentials(certMeta, null, true, info); } + return info; } - } - if (!matched) { - throw new IOException( - String.format( - "Agent Identity certificate and private key mismatch or read" - + " failure after %d retries.", - CERT_KEY_MATCH_RETRIES)); - } - } else if (!Strings.isNullOrEmpty(certPath)) { - // Only certificate available (no private key specified or found) - try { - certContent = readCertificateChain(certPath); - cert = parseCertificateContent(certContent); + FileMetadata keyMeta = FileMetadata.of(keyPath); + PrivateKey privateKey = readPrivateKey(keyPath, cert.getPublicKey().getAlgorithm()); + + if (verifyKeyPair(cert, privateKey)) { + CertInfo info = new CertInfo(cert, certContent); + if (certMeta != null + && keyMeta != null + && certMeta.matches(FileMetadata.of(certPath)) + && keyMeta.matches(FileMetadata.of(keyPath))) { + cachedCredentials = new CachedCredentials(certMeta, keyMeta, true, info); + } + return info; + } + LOGGER.warn("Cert and key mismatch, retrying..."); } catch (java.nio.file.AccessDeniedException e) { - if (!Strings.isNullOrEmpty(envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG))) { + if (!Strings.isNullOrEmpty(envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG)) + || "true".equalsIgnoreCase(envReader.getEnv(GOOGLE_API_USE_CLIENT_CERTIFICATE))) { throw new IOException( - "Permission denied reading certificate files for Agent Identity.", e); + "Permission denied reading certificate or key files for Agent Identity.", e); } Slf4jUtils.log( LOGGER, org.slf4j.event.Level.WARN, Collections.emptyMap(), - "Permission denied reading certificate files. Falling back to unbound" + " token."); + "Permission denied reading certificate or key files. Falling back to" + + " unbound token."); return null; + } catch (Exception e) { + LOGGER.warn("Failed to read or verify cert/key, retrying...", e); + } + + retries++; + if (retries < CERT_KEY_MATCH_RETRIES) { + try { + timeService.sleep(CERT_KEY_MATCH_RETRY_INTERVAL_MS); // 0.1 seconds backoff + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for cert/key match.", e); + } } } - return new CertInfo(cert, certContent); + boolean explicitConfigOrMtls = + !Strings.isNullOrEmpty(envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG)) + || "true".equalsIgnoreCase(envReader.getEnv(GOOGLE_API_USE_CLIENT_CERTIFICATE)); + if (!explicitConfigOrMtls) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + String.format( + "Agent Identity certificate and private key mismatch or read failure after %d" + + " retries. Falling back to unbound token.", + CERT_KEY_MATCH_RETRIES)); + return null; + } + + throw new IOException( + String.format( + "Agent Identity certificate and private key mismatch or read" + + " failure after %d retries.", + CERT_KEY_MATCH_RETRIES)); } /** Checks if a file exists, throwing AccessDeniedException if permission is denied. */ @@ -475,11 +590,11 @@ private static boolean isTokenBindingEnabled() { */ private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(final String certConfigPath) throws IOException { - boolean shouldPoll = isPathInWellKnownDir(certConfigPath); - int maxCycles = shouldPoll ? TOTAL_POLL_CYCLES : 1; + boolean shouldPoll = !initialStartupCompleted && isPathInWellKnownDir(certConfigPath); boolean warned = false; + IOException lastParseException = null; - for (int cycle = 0; cycle < maxCycles; cycle++) { + for (int cycle = 0; cycle < TOTAL_POLL_CYCLES; cycle++) { try { if (checkExistsOrAccessDenied(Paths.get(certConfigPath))) { ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath); @@ -488,31 +603,42 @@ private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(final String // Valid non-workload config (e.g. enterprise certs) - exit early without polling! return paths; } - if (!Strings.isNullOrEmpty(paths.getCertPath()) - && checkExistsOrAccessDenied(Paths.get(paths.getCertPath()))) { + if (!initialStartupCompleted + && (isPathInWellKnownDir(paths.getCertPath()) + || isPathInWellKnownDir(paths.getKeyPath()))) { + shouldPoll = true; + } + boolean certReady = + !Strings.isNullOrEmpty(paths.getCertPath()) + && checkExistsOrAccessDenied(Paths.get(paths.getCertPath())); + boolean keyReady = + Strings.isNullOrEmpty(paths.getKeyPath()) + || checkExistsOrAccessDenied(Paths.get(paths.getKeyPath())); + if (certReady && keyReady) { return paths; } if (!shouldPoll) { - // Cert file not ready and not in well-known dir - exit early + // Cert file not ready and polling not active - exit early return paths; } } } else if (!shouldPoll) { - // Config file doesn't exist and not in well-known dir - exit early + // Config file doesn't exist and polling not active - exit early return new ResolvedCertAndKeyPaths(null, null, false); } } catch (java.nio.file.AccessDeniedException e) { throw new IOException( "Permission denied reading certificate config file: " + certConfigPath, e); } catch (IOException e) { - if (e.getMessage() != null - && e.getMessage().contains("Failed to parse Agent Identity config JSON")) { - throw e; // Fail fast on malformed JSON syntax errors - } if (!shouldPoll) { + if (e.getMessage() != null + && e.getMessage().contains("Failed to parse Agent Identity config JSON")) { + throw e; // Fail fast on malformed JSON syntax errors when not polling + } return new ResolvedCertAndKeyPaths(null, null, false); } - // Fall through to retry + lastParseException = e; + // When polling is active, fall through to retry (handles partial/empty JSON during startup) } if (!warned) { Slf4jUtils.log( @@ -540,13 +666,18 @@ && checkExistsOrAccessDenied(Paths.get(paths.getCertPath()))) { + " after multiple retries. Token binding protection is failing. You can turn" + " off this protection by setting " + GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN - + " to false to fall back to unbound tokens."); + + " to false to fall back to unbound tokens.", + lastParseException); } /** Searches for certificates at well-known locations with retry logic. */ private static String getWellKnownCertificatePathWithRetry() throws IOException { String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString(); String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString(); + String keyOnlyPath = Paths.get(wellKnownDir, "private_key.pem").toString(); + + String useClientCert = envReader.getEnv(GOOGLE_API_USE_CLIENT_CERTIFICATE); + boolean shouldPoll = "true".equalsIgnoreCase(useClientCert) && !initialStartupCompleted; // 1) First check immediately without sleeping: try { @@ -554,7 +685,9 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException return bundlePath; } if (checkExistsOrAccessDenied(Paths.get(certOnlyPath))) { - return certOnlyPath; + if (checkExistsOrAccessDenied(Paths.get(keyOnlyPath)) || !shouldPoll) { + return certOnlyPath; + } } } catch (java.nio.file.AccessDeniedException e) { Slf4jUtils.log( @@ -567,29 +700,33 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException // Fall through } - // 2) If not found immediately, only enter retry loop if mTLS was explicitly enabled: - String useClientCert = envReader.getEnv(GOOGLE_API_USE_CLIENT_CERTIFICATE); - if (!"true".equalsIgnoreCase(useClientCert)) { + // 2) If not found immediately, only enter retry loop if mTLS was explicitly enabled on initial + // startup: + if (!shouldPoll) { Slf4jUtils.log( LOGGER, org.slf4j.event.Level.DEBUG, Collections.emptyMap(), String.format( - "Well-known certificate file not found at %s and %s is not" - + " explicitly enabled; falling back to unbound token without" - + " retrying.", - wellKnownDir, GOOGLE_API_USE_CLIENT_CERTIFICATE)); + "Well-known certificate file not found at %s and polling is not active" + + " (%s=%s, initialStartupCompleted=%s); falling back to unbound token" + + " without retrying.", + wellKnownDir, + GOOGLE_API_USE_CLIENT_CERTIFICATE, + useClientCert, + initialStartupCompleted)); return null; } - // 3) Retry loop for rotation/transient absence when explicitly enabled: + // 3) Retry loop for initial container startup delivery when explicitly enabled: boolean warned = false; for (int cycle = 0; cycle < TOTAL_POLL_CYCLES; cycle++) { try { if (checkExistsOrAccessDenied(Paths.get(bundlePath))) { return bundlePath; } - if (checkExistsOrAccessDenied(Paths.get(certOnlyPath))) { + if (checkExistsOrAccessDenied(Paths.get(certOnlyPath)) + && checkExistsOrAccessDenied(Paths.get(keyOnlyPath))) { return certOnlyPath; } } catch (java.nio.file.AccessDeniedException e) { @@ -620,6 +757,13 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException throw new IOException("Interrupted while waiting for well-known certificate files.", e); } } + try { + if (checkExistsOrAccessDenied(Paths.get(certOnlyPath))) { + return certOnlyPath; + } + } catch (Exception ignored) { + // Fall through + } Slf4jUtils.log( LOGGER, org.slf4j.event.Level.WARN, @@ -846,14 +990,18 @@ static boolean shouldRequestBoundToken(final X509Certificate cert) { /** Sets the environment variable reader for testing. */ @VisibleForTesting - public static void setEnvReader(EnvReader reader) { + static void setEnvReader(EnvReader reader) { envReader = reader; + cachedCredentials = null; + initialStartupCompleted = false; } /** Sets the time and sleep service for testing. */ @VisibleForTesting static void setTimeService(TimeService service) { timeService = service; + cachedCredentials = null; + initialStartupCompleted = false; } /** Resets the time and sleep service back to default system implementation. */ @@ -871,5 +1019,7 @@ public void sleep(final long millis) throws InterruptedException { Thread.sleep(millis); } }; + cachedCredentials = null; + initialStartupCompleted = false; } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java index 4e267719c0f8..47916b78eff0 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java @@ -33,7 +33,9 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNotSame; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import static org.mockito.Mockito.mock; @@ -275,9 +277,14 @@ public void getAgentIdentityCertificate_noConfigEnvVar_returnsNull() throws IOEx @Test public void getAgentIdentityCertificate_happyPath_loadsCertificate() throws Exception { - URL certUrl = getClass().getClassLoader().getResource("x509_leaf_certificate.pem"); - assertNotNull(certUrl, "Test resource x509_leaf_certificate.pem not found"); + URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); + assertNotNull(certUrl, "Test resource agent/agent_spiffe_cert.pem not found"); String certPath = Paths.get(certUrl.toURI()).toAbsolutePath().toString(); + + URL keyUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_key.pem"); + assertNotNull(keyUrl, "Test resource agent/agent_spiffe_key.pem not found"); + String keyPath = Paths.get(keyUrl.toURI()).toAbsolutePath().toString(); + File configFile = tempDir.resolve("config.json").toFile(); String configJson = "{" @@ -285,6 +292,9 @@ public void getAgentIdentityCertificate_happyPath_loadsCertificate() throws Exce + " \"workload\": {" + " \"cert_path\": \"" + certPath.replace("\\", "\\\\") + + "\"," + + " \"key_path\": \"" + + keyPath.replace("\\", "\\\\") + "\"" + " }" + " }" @@ -295,7 +305,7 @@ public void getAgentIdentityCertificate_happyPath_loadsCertificate() throws Exce envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath()); AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); assertNotNull(info); - assertTrue(info.getCertificate().getIssuerDN().getName().contains("unit-tests")); + assertTrue(AgentIdentityUtils.shouldRequestBoundToken(info.getCertificate())); } @Test @@ -314,8 +324,9 @@ public void getAgentIdentityCertificate_timeout_throwsIOException() { } @Test - public void getAgentIdentityCertInfo_malformedJson_throwsIOException() throws IOException { - File configFile = tempDir.resolve("config.json").toFile(); + public void getAgentIdentityCertInfo_malformedJson_throwsIOException(@TempDir Path outsideDir) + throws IOException { + File configFile = outsideDir.resolve("config.json").toFile(); try (FileOutputStream fos = new FileOutputStream(configFile)) { fos.write("{ \"cert_configs\": invalid json} ".getBytes(StandardCharsets.UTF_8)); } @@ -620,6 +631,7 @@ public void loadAndVerifyCredentials_bundleWithMismatchedKey_failsVerification() Path bundleFile = tempDir.resolve("credentialbundle.pem"); Files.write(bundleFile, bundleContent.getBytes(StandardCharsets.UTF_8)); + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); AgentIdentityUtils.setTimeService(new FakeTimeService()); IOException e = @@ -635,6 +647,34 @@ public void loadAndVerifyCredentials_bundleWithMismatchedKey_failsVerification() + " retries.")); } + @Test + public void loadAndVerifyCredentials_implicitDiscovery_bundleWithMismatchedKey_returnsNull() + throws Exception { + URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); + assertNotNull(certUrl); + String certPem = + new String(Files.readAllBytes(Paths.get(certUrl.toURI())), StandardCharsets.UTF_8); + + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + String mismatchedKeyPem = + "-----BEGIN PRIVATE KEY-----\n" + + java.util.Base64.getMimeEncoder().encodeToString(kp.getPrivate().getEncoded()) + + "\n-----END PRIVATE KEY-----"; + + String bundleContent = certPem + "\n" + mismatchedKeyPem; + Path bundleFile = tempDir.resolve("credentialbundle.pem"); + Files.write(bundleFile, bundleContent.getBytes(StandardCharsets.UTF_8)); + + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", null); + AgentIdentityUtils.setTimeService(new FakeTimeService()); + + assertNull( + AgentIdentityUtils.loadAndVerifyCredentials(bundleFile.toString(), bundleFile.toString())); + } + @Test public void getAgentIdentityCertInfo_wellKnownBundleFile_verifiesAndStripsPrivateKey() throws Exception { @@ -768,28 +808,294 @@ public void getAgentIdentityCertInfo_configWithoutWorkload_returnsNullImmediatel @Test public void - getAgentIdentityCertInfo_missingConfigOutsideWellKnownDir_returnsNullImmediatelyWithoutPolling() - throws Exception { - Path outsideDir = Files.createTempDirectory("outside_well_known"); + getAgentIdentityCertInfo_missingConfigOutsideWellKnownDir_returnsNullImmediatelyWithoutPolling( + @TempDir Path outsideDir) throws Exception { Path missingConfigFile = outsideDir.resolve("missing_config.json"); - try { - envProvider.setEnv( - "GOOGLE_API_CERTIFICATE_CONFIG", missingConfigFile.toAbsolutePath().toString()); - AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); - FakeTimeService fakeTime = new FakeTimeService(); - AgentIdentityUtils.setTimeService(fakeTime); - - AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); - assertNull(info); - assertEquals(0, fakeTime.getSleepCount()); - } finally { - Files.deleteIfExists(outsideDir); - } + envProvider.setEnv( + "GOOGLE_API_CERTIFICATE_CONFIG", missingConfigFile.toAbsolutePath().toString()); + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNull(info); + assertEquals(0, fakeTime.getSleepCount()); + } + + @Test + public void + getAgentIdentityCertInfo_configOutsideWellKnownDir_targetCertInWellKnownDir_pollsUntilReady( + @TempDir Path outsideDir) throws Exception { + Path targetCertPath = tempDir.resolve("certificates.pem"); + Path targetKeyPath = tempDir.resolve("private_key.pem"); + + File configFile = outsideDir.resolve("certificate_config.json").toFile(); + String configJson = + "{" + + " \"cert_configs\": {" + + " \"workload\": {" + + " \"cert_path\": \"" + + targetCertPath.toAbsolutePath().toString().replace("\\", "\\\\") + + "\"," + + " \"key_path\": \"" + + targetKeyPath.toAbsolutePath().toString().replace("\\", "\\\\") + + "\"" + + " }" + + " }" + + "}"; + Files.write(configFile.toPath(), configJson.getBytes(StandardCharsets.UTF_8)); + + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath()); + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + + FakeTimeService fakeTime = new FakeTimeService(); + fakeTime.setOnSleepCallback( + () -> { + if (fakeTime.getSleepCount() == 2) { + try { + URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); + URL keyUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_key.pem"); + Files.copy(Paths.get(certUrl.toURI()), targetCertPath); + Files.copy(Paths.get(keyUrl.toURI()), targetKeyPath); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }); + AgentIdentityUtils.setTimeService(fakeTime); + + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(info); + assertEquals(2, fakeTime.getSleepCount()); + } + + @Test + public void getAgentIdentityCertInfo_partialJsonDuringStartupPoll_retriesAndSucceeds() + throws Exception { + Path targetCertPath = tempDir.resolve("certificates.pem"); + Path targetKeyPath = tempDir.resolve("private_key.pem"); + URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); + URL keyUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_key.pem"); + Files.copy(Paths.get(certUrl.toURI()), targetCertPath); + Files.copy(Paths.get(keyUrl.toURI()), targetKeyPath); + + Path configFile = tempDir.resolve("config.json"); + // Write partial/incomplete JSON simulating non-atomic write at startup + Files.write(configFile, "{ \"cert_configs\": ".getBytes(StandardCharsets.UTF_8)); + + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.toAbsolutePath().toString()); + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + + String validJson = + "{" + + " \"cert_configs\": {" + + " \"workload\": {" + + " \"cert_path\": \"" + + targetCertPath.toAbsolutePath().toString().replace("\\", "\\\\") + + "\"," + + " \"key_path\": \"" + + targetKeyPath.toAbsolutePath().toString().replace("\\", "\\\\") + + "\"" + + " }" + + " }" + + "}"; + + FakeTimeService fakeTime = new FakeTimeService(); + fakeTime.setOnSleepCallback( + () -> { + if (fakeTime.getSleepCount() == 1) { + try { + Files.write(configFile, validJson.getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + }); + AgentIdentityUtils.setTimeService(fakeTime); + + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(info); + assertEquals(1, fakeTime.getSleepCount()); + } + + @Test + public void getAgentIdentityCertInfo_startupPollTimeout_subsequentRefreshDoesNotPoll() + throws Exception { + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); + + // Initial call polls for all 100 cycles (30s) and times out + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + assertEquals(100, fakeTime.getSleepCount()); + + // Subsequent token refresh falls back immediately without sleeping + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + assertEquals(100, fakeTime.getSleepCount()); + } + + @Test + public void getAgentIdentityCertInfo_startupSucceeded_subsequentRefreshMissingFileDoesNotPoll() + throws Exception { + setupValidAgentCredentialsInTempDir(); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); + + // Initial startup discovery succeeds immediately without polling + AgentIdentityUtils.CertInfo info1 = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(info1); + assertEquals(0, fakeTime.getSleepCount()); + + // Simulate certificate file disappearing on disk after initial startup + Files.delete(tempDir.resolve("certificates.pem")); + + // Subsequent token refresh falls back immediately (0 sleeps) instead of freezing for 30s + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + assertEquals(0, fakeTime.getSleepCount()); + } + + @Test + public void + getAgentIdentityCertInfo_implicitDiscovery_missingPrivateKey_returnsNullAndFallsBackToUnbound() + throws Exception { + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); + assertNotNull(certUrl); + Files.copy(Paths.get(certUrl.toURI()), tempDir.resolve("certificates.pem")); + // Intentionally do NOT copy private_key.pem + + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", null); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + + // Implicit discovery refuses to issue an unverified bound token, retries 3 times (2 sleeps), + // and falls back to null (unbound token) without crashing ADC. + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + assertEquals(2, fakeTime.getSleepCount()); + } + + @Test + public void getAgentIdentityCertInfo_explicitMtlsEnabled_missingPrivateKey_throwsIOException() + throws Exception { + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); + assertNotNull(certUrl); + Files.copy(Paths.get(certUrl.toURI()), tempDir.resolve("certificates.pem")); + // Intentionally do NOT copy private_key.pem + + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); + AgentIdentityUtils.setTimeService(new FakeTimeService()); + + IOException e = assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); + assertTrue( + e.getMessage() + .contains( + "Agent Identity certificate and private key mismatch or read failure after 3" + + " retries.")); + } + + @Test + public void getAgentIdentityCertInfo_certificateOnlyConfig_omittedKeyPath_returnsCertInfo() + throws Exception { + URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); + assertNotNull(certUrl); + String certPath = Paths.get(certUrl.toURI()).toAbsolutePath().toString(); + + File configFile = tempDir.resolve("config.json").toFile(); + String configJson = + "{" + + " \"cert_configs\": {" + + " \"workload\": {" + + " \"cert_path\": \"" + + certPath.replace("\\", "\\\\") + + "\"" + + " }" + + " }" + + "}"; + Files.write(configFile.toPath(), configJson.getBytes(StandardCharsets.UTF_8)); + + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath()); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + + AgentIdentityUtils.CertInfo info1 = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(info1); + assertEquals(0, fakeTime.getSleepCount()); + + // Subsequent call returns cached instance + AgentIdentityUtils.CertInfo info2 = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertSame(info1, info2); + } + + @Test + public void getAgentIdentityCertInfo_nonAgentSpiffeCert_returnsNullWithoutReadingKey() + throws Exception { + URL certUrl = getClass().getClassLoader().getResource("x509_leaf_certificate.pem"); + assertNotNull(certUrl); + String certPath = Paths.get(certUrl.toURI()).toAbsolutePath().toString(); + + File configFile = tempDir.resolve("config.json").toFile(); + String configJson = + "{" + + " \"cert_configs\": {" + + " \"workload\": {" + + " \"cert_path\": \"" + + certPath.replace("\\", "\\\\") + + "\"," + + " \"key_path\": \"/non/existent/private_key.pem\"" + + " }" + + " }" + + "}"; + Files.write(configFile.toPath(), configJson.getBytes(StandardCharsets.UTF_8)); + + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath()); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + + // Should return null without attempting to read the missing private key or sleeping + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + assertEquals(0, fakeTime.getSleepCount()); + assertNull(AgentIdentityUtils.getBoundTokenPayload()); + } + + @Test + public void getAgentIdentityCertInfo_cachesVerifiedCertInfoAndInvalidatesOnRotation() + throws Exception { + setupValidAgentCredentialsInTempDir(); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + + AgentIdentityUtils.CertInfo info1 = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(info1); + + // Second call with unchanged files on disk must return the exact same cached instance + AgentIdentityUtils.CertInfo info2 = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertSame(info1, info2); + + // Simulate certificate rotation on disk by modifying the file content/size + Path certFile = tempDir.resolve("certificates.pem"); + String originalPem = new String(Files.readAllBytes(certFile), StandardCharsets.UTF_8); + Files.write(certFile, (originalPem + "\n").getBytes(StandardCharsets.UTF_8)); + + AgentIdentityUtils.CertInfo info3 = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(info3); + assertNotSame(info1, info3); } private static class FakeTimeService implements AgentIdentityUtils.TimeService { private final AtomicLong currentTime = new AtomicLong(0); private final AtomicInteger sleepCount = new AtomicInteger(0); + private Runnable onSleepCallback; + + void setOnSleepCallback(Runnable callback) { + this.onSleepCallback = callback; + } @Override public long currentTimeMillis() { @@ -800,6 +1106,9 @@ public long currentTimeMillis() { public void sleep(long millis) throws InterruptedException { sleepCount.incrementAndGet(); currentTime.addAndGet(millis); + if (onSleepCallback != null) { + onSleepCallback.run(); + } } int getSleepCount() { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java index c94fe968f0a4..1a27d3cdc98a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/functional/FTComputeEngineCredentialsTest.java @@ -39,50 +39,18 @@ import com.google.api.client.json.gson.GsonFactory; import com.google.api.client.json.webtoken.JsonWebSignature; import com.google.auth.oauth2.AccessToken; -import com.google.auth.oauth2.AgentIdentityUtils; import com.google.auth.oauth2.ComputeEngineCredentials; import com.google.auth.oauth2.GoogleCredentials; import com.google.auth.oauth2.IdToken; import com.google.auth.oauth2.IdTokenCredentials; import com.google.auth.oauth2.IdTokenProvider; import com.google.auth.oauth2.OAuth2Utils; -import java.util.HashMap; -import java.util.Map; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; final class FTComputeEngineCredentialsTest { private final String computeUrl = "https://compute.googleapis.com/compute/v1/projects/gcloud-devel/zones/us-central1-a/instances"; - private static class TestEnvironmentProvider { - private final Map env = new HashMap<>(); - - void setEnv(String key, String value) { - env.put(key, value); - } - - String getEnv(String key) { - return env.get(key); - } - } - - private TestEnvironmentProvider envProvider; - - @BeforeEach - void setUp() { - envProvider = new TestEnvironmentProvider(); - AgentIdentityUtils.setEnvReader(envProvider::getEnv); - // Opt out by default to avoid polling delays or file reads - envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "false"); - } - - @AfterEach - void tearDown() { - AgentIdentityUtils.setEnvReader(System::getenv); - } - @Test void RefreshCredentials() throws Exception { final ComputeEngineCredentials credentials = ComputeEngineCredentials.create(); From 5c444d13294bb69ee797ecc8be44462df41ed2d4 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Wed, 16 Sep 2026 21:26:11 +0000 Subject: [PATCH 25/25] fix(oauth2): strictly require key_path and verifyKeyPair for bound token credentials --- .../auth/oauth2/AgentIdentityUtils.java | 27 +++++++++++++------ .../auth/oauth2/AgentIdentityUtilsTest.java | 14 +++++----- 2 files changed, 26 insertions(+), 15 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 1d441fc99bc2..8af2ba135414 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -454,8 +454,6 @@ static CertInfo loadAndVerifyCredentials(final String certPath, final String key if (currentKeyMeta != null && currentKeyMeta.matches(cached.keyMetadata)) { return cached.certInfo; } - } else if (cached.keyMetadata == null) { - return cached.certInfo; } } } catch (IOException ignored) { @@ -478,13 +476,21 @@ static CertInfo loadAndVerifyCredentials(final String certPath, final String key } if (Strings.isNullOrEmpty(keyPath)) { - // Certificate-only configuration (e.g. sidecar/proxy mTLS termination where the - // private key is isolated from the application container). - CertInfo info = new CertInfo(cert, certContent); - if (certMeta != null && certMeta.matches(FileMetadata.of(certPath))) { - cachedCredentials = new CachedCredentials(certMeta, null, true, info); + boolean explicitConfigOrMtls = + !Strings.isNullOrEmpty(envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG)) + || "true".equalsIgnoreCase(envReader.getEnv(GOOGLE_API_USE_CLIENT_CERTIFICATE)); + if (explicitConfigOrMtls) { + throw new IOException( + "Private key is required for Agent Identity bound token request, but key path is" + + " missing."); } - return info; + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Private key path is missing for Agent Identity certificate. Falling back to unbound" + + " token."); + return null; } FileMetadata keyMeta = FileMetadata.of(keyPath); @@ -514,6 +520,11 @@ static CertInfo loadAndVerifyCredentials(final String certPath, final String key "Permission denied reading certificate or key files. Falling back to" + " unbound token."); return null; + } catch (IOException e) { + if (Strings.isNullOrEmpty(keyPath)) { + throw e; + } + LOGGER.warn("Failed to read or verify cert/key, retrying...", e); } catch (Exception e) { LOGGER.warn("Failed to read or verify cert/key, retrying...", e); } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java index 47916b78eff0..43574998d2a9 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java @@ -1001,7 +1001,7 @@ public void getAgentIdentityCertInfo_explicitMtlsEnabled_missingPrivateKey_throw } @Test - public void getAgentIdentityCertInfo_certificateOnlyConfig_omittedKeyPath_returnsCertInfo() + public void getAgentIdentityCertInfo_certificateOnlyConfig_omittedKeyPath_throwsIOException() throws Exception { URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); assertNotNull(certUrl); @@ -1024,13 +1024,13 @@ public void getAgentIdentityCertInfo_certificateOnlyConfig_omittedKeyPath_return FakeTimeService fakeTime = new FakeTimeService(); AgentIdentityUtils.setTimeService(fakeTime); - AgentIdentityUtils.CertInfo info1 = AgentIdentityUtils.getAgentIdentityCertInfo(); - assertNotNull(info1); + IOException e = assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); + assertTrue( + e.getMessage() + .contains( + "Private key is required for Agent Identity bound token request, but key path is" + + " missing.")); assertEquals(0, fakeTime.getSleepCount()); - - // Subsequent call returns cached instance - AgentIdentityUtils.CertInfo info2 = AgentIdentityUtils.getAgentIdentityCertInfo(); - assertSame(info1, info2); } @Test