From fd40ea97ec7d3df1d517ab6f3daf26df049ca1e3 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Fri, 17 Apr 2026 16:26:18 -0700 Subject: [PATCH 1/5] Port Agent Identities feature for Cloud Run --- .../auth/oauth2/AgentIdentityUtils.java | 249 ++++++++++++++++++ .../auth/oauth2/ComputeEngineCredentials.java | 15 +- .../auth/oauth2/AgentIdentityUtilsTest.java | 180 +++++++++++++ .../oauth2/ComputeEngineCredentialsTest.java | 141 ++++++++++ .../DefaultCredentialsProviderTest.java | 14 + .../oauth2_http/testresources/agent_cert.pem | 19 ++ .../testresources/agent_spiffe_cert.pem | 20 ++ 7 files changed, 636 insertions(+), 2 deletions(-) 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 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 ad5fb8e7dcf3..618a9bcd5cea 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; @@ -418,8 +419,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 82240171d9af..ab1c217dd20f 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,18 @@ import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.DefaultCredentialsProviderTest.MockRequestCountingTransportFactory; import java.io.IOException; +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; @@ -77,6 +85,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"; @@ -1177,6 +1203,121 @@ void getProjectId_explicitSet_noMDsCall() { assertEquals(0, transportFactory.transport.getRequestCount()); } + @Test + 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 { MockMetadataServerTransport transport = 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 d0447871b01a..9cd88c0f0b5d 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----- From c636b0802311547e8985ff144549935fecfa10a7 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Thu, 7 May 2026 17:47:04 -0700 Subject: [PATCH 2/5] 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. --- .../auth/oauth2/AgentIdentityUtils.java | 166 +++++++++++++++++- .../auth/oauth2/ComputeEngineCredentials.java | 55 ++++-- .../auth/oauth2/AgentIdentityUtilsTest.java | 84 ++++++++- .../oauth2/ComputeEngineCredentialsTest.java | 137 ++++++++------- .../DefaultCredentialsProviderTest.java | 9 +- .../oauth2/MockMetadataServerTransport.java | 6 + 6 files changed, 356 insertions(+), 101 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 618a9bcd5cea..81dbbb39d18d 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; @@ -318,7 +319,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; @@ -377,7 +378,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( @@ -421,16 +422,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( @@ -498,8 +500,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 ab1c217dd20f..a3e26475155e 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 @@ -94,6 +94,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 @@ -1203,69 +1219,11 @@ void getProjectId_explicitSet_noMDsCall() { assertEquals(0, transportFactory.transport.getRequestCount()); } - @Test - 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()); @@ -1292,22 +1250,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 9cd88c0f0b5d..2f824ead8221 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 1b218b73ef45..e927b0a28f9f 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 @@ -71,6 +71,7 @@ public class MockMetadataServerTransport extends MockHttpTransport { private boolean emptyContent; private MockLowLevelHttpRequest request; + private String requestMethod; public MockMetadataServerTransport() {} @@ -123,8 +124,13 @@ public MockLowLevelHttpRequest getRequest() { return request; } + public String getRequestMethod() { + return requestMethod; + } + @Override public LowLevelHttpRequest buildRequest(String method, String url) throws IOException { + this.requestMethod = method; if (url.startsWith(ComputeEngineCredentials.getTokenServerEncodedUrl())) { this.request = getMockRequestForTokenEndpoint(url); return this.request; From 432a9804adc3684041a869f501c20c1e60db63c1 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Mon, 11 May 2026 16:39:43 -0700 Subject: [PATCH 3/5] Added logic to verify key and cert to ensure no mismatch along with retry logic. Nit fixes. --- .../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 81dbbb39d18d..f833cc297a23 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; @@ -378,7 +377,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( @@ -424,12 +424,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); } @@ -502,16 +504,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) { @@ -541,15 +546,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 a3e26475155e..02042c333d9b 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,7 +59,6 @@ import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.DefaultCredentialsProviderTest.MockRequestCountingTransportFactory; import java.io.IOException; -import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -67,17 +66,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}. */ @@ -94,7 +93,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( @@ -103,6 +102,7 @@ void setUp() throws IOException { public long currentTimeMillis() { return currentTime.get(); } + @Override public void sleep(long millis) { currentTime.addAndGet(millis); @@ -1219,8 +1219,6 @@ void getProjectId_explicitSet_noMDsCall() { assertEquals(0, transportFactory.transport.getRequestCount()); } - - @Test void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); @@ -1234,6 +1232,7 @@ void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { public long currentTimeMillis() { return currentTime.get(); } + @Override public void sleep(long millis) { currentTime.addAndGet(millis); @@ -1250,25 +1249,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()); } @@ -1276,17 +1277,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")); @@ -1295,17 +1298,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")); @@ -1313,9 +1318,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 2f824ead8221..59217b9cf3a7 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 c30b10cdef9bc717e09cbdf1006de6753fdc167d Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Mon, 11 May 2026 18:17:11 -0700 Subject: [PATCH 4/5] 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 02042c333d9b..af49109b6b04 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 @@ -109,7 +109,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 @@ -1221,7 +1221,7 @@ void getProjectId_explicitSet_noMDsCall() { @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()); @@ -1250,11 +1250,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); @@ -1278,7 +1280,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); @@ -1299,7 +1301,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 59217b9cf3a7..292679942ace 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 668696f55721d3adb604b3f7706f6d5baeb4ef22 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Mon, 11 May 2026 18:24:04 -0700 Subject: [PATCH 5/5] 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;