diff --git a/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java new file mode 100644 index 000000000..476fc3d6e --- /dev/null +++ b/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -0,0 +1,335 @@ +/* + * Copyright 2025 Google LLC + * + * 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 LLC 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; +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.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.*; +import java.util.logging.Level; +import java.util.logging.Logger; +import java.util.regex.Pattern; + +/** Internal utility class for handling Agent Identity certificate-bound access tokens. */ +final class AgentIdentityUtils { + private static final Logger LOGGER = Logger.getLogger(AgentIdentityUtils.class.getName()); + + 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; + + // Pre-calculates the sequence of 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); + // Integer division is sufficient here as we want full cycles + 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 to allow mocking System.getenv for tests without exposing it publicly. + interface EnvReader { + String getEnv(String name); + } + + private static EnvReader envReader = System::getenv; + + /** + * Internal interface to allow mocking time and sleep for tests. This is used to prevent tests + * from running for long periods of time when polling is involved. + */ + @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() {} + + /** + * Gets the Agent Identity certificate if certificate is available and agent token sharing is not + * disabled. + * + * @return The X509Certificate if found and Agent Identities are enabled, null otherwise. + * @throws IOException If there is an error reading the certificate file after retries. + */ + 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); + } + + /** + * Checks if Agent Identity token sharing is disabled via an environment variable. + * + * @return {@code true} if the {@link #GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES} + * variable is set to {@code "false"}, otherwise returns {@code false}. + */ + private static boolean isOptedOut() { + String optOut = envReader.getEnv(GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES); + return optOut != null && "false".equalsIgnoreCase(optOut); + } + + /** + * Polls for the certificate config file and the certificate file it references, and returns the + * certificate's path. + * + *

This method will retry for a total of {@link #TOTAL_TIMEOUT_MS} milliseconds before failing. + * + * @param certConfigPath The path to the certificate configuration JSON file. + * @return The path to the certificate file extracted from the config. + * @throws IOException If the files cannot be found after the timeout, or if the thread is + * interrupted while waiting. + */ + private static String getCertificatePathWithRetry(String certConfigPath) throws IOException { + boolean warned = false; + + // Deterministic polling loop based on pre-calculated intervals. + 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) { + // Do not log here to prevent noise in the logs per iteration. + // Fall through to the sleep logic to retry. + } + + // If we are here, we failed to find the certificate, log a warning only once. + if (!warned) { + LOGGER.warning( + 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; + } + + // Sleep before the next attempt. + 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); + } + } + + // If the loop completes without returning, we have timed out. + 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."); + } + + /** + * Parses the certificate configuration JSON file and extracts the path to the certificate. + * + * @param certConfigPath The path to the certificate configuration JSON file. + * @return The certificate file path, or {@code null} if not found in the config. + * @throws IOException If the configuration file cannot be read. + */ + @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; + } + + /** + * Parses an X.509 certificate from the given file path. + * + * @param certPath The path to the certificate file. + * @return The parsed {@link X509Certificate}. + * @throws IOException If the certificate file cannot be read or parsed. + */ + 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); + } + } + + /** Checks if the certificate belongs to an Agent Identity by inspecting SANs. */ + static boolean shouldRequestBoundToken(X509Certificate cert) { + try { + Collection> sans = cert.getSubjectAlternativeNames(); + if (sans == null) { + return false; + } + for (List san : sans) { + // SAN entry is a list where first element is the type (Integer) and second is value (mostly + // String) + 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)) { + // Extract trust domain: spiffe:///... + 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.log(Level.WARNING, "Failed to parse Subject Alternative Names from certificate", e); + } + return false; + } + + /** Calculates the SHA-256 fingerprint of the certificate, Base64Url encoded without padding. */ + 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(); + return BaseEncoding.base64Url().omitPadding().encode(digest); + } 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/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java index 48a6fbe6b..ef6cc0d64 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java @@ -62,6 +62,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; @@ -344,8 +345,18 @@ private String getUniverseDomainFromMetadata() throws IOException { /** 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/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java new file mode 100644 index 000000000..69ed8866b --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java @@ -0,0 +1,233 @@ +package com.google.auth.oauth2; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertThrows; +import static org.junit.Assert.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.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public 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; + + @Before + public void setUp() throws IOException { + envProvider = new TestEnvironmentProvider(); + AgentIdentityUtils.setEnvReader(envProvider::getEnv); + tempDir = Files.createTempDirectory("agent_identity_test"); + } + + @After + public void tearDown() throws IOException { + // Reset the time service to default after each test + AgentIdentityUtils.resetTimeService(); + + // Clean up temp files + if (tempDir != null) { + Files.walk(tempDir) + .sorted(java.util.Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + } + + // --- 1. SPIFFE ID Validation Tests --- + + @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)); + } + + // Helper to create a mock cert with a specific URI in SAN + private X509Certificate mockCertWithSanUri(String uri) throws CertificateException { + X509Certificate mockCert = mock(X509Certificate.class); + // SAN entry type 6 is URI + List spiffeEntry = Arrays.asList(6, uri); + Collection> sans = Collections.singletonList(spiffeEntry); + when(mockCert.getSubjectAlternativeNames()).thenReturn(sans); + return mockCert; + } + + // --- 2. Fingerprint Calculation Tests --- + + @Test + public void calculateCertificateFingerprint_knownInput_returnsExpectedOutput() throws Exception { + // We mock the getEncoded() method to return a fixed byte array to guarantee a stable hash + // regardless of the actual certificate implementation details. + X509Certificate mockCert = mock(X509Certificate.class); + byte[] fakeDer = new byte[] {0x01, 0x02, 0x03, 0x04, (byte) 0xFF}; + when(mockCert.getEncoded()).thenReturn(fakeDer); + + // SHA-256 of {0x01, 0x02, 0x03, 0x04, 0xFF} is: + // fc402e5e4d71483c6d537984a30c2b4c8b065539a4bd1b026c6112926ba52793 + // Base64Url (no padding) of that hash is: + // _EAuXk1xSDxtU3mEowwrTIsGVTmkvRsCbGESkmulJ5M + String expectedFingerprint = "_EAuXk1xSDxtU3mEowwrTIsGVTmkvRsCbGESkmulJ5M"; + + String actualFingerprint = AgentIdentityUtils.calculateCertificateFingerprint(mockCert); + assertEquals(expectedFingerprint, actualFingerprint); + } + + // --- 3. Environmental Control Tests --- + + @Test + public void getAgentIdentityCertificate_optedOut_returnsNullImmediately() throws IOException { + envProvider.setEnv("GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); + // Set config to a non-existent path; if it tried to load it, it would fail or retry. + // Returning null immediately proves it respected the opt-out. + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", "/non/existent/path"); + + assertNull(AgentIdentityUtils.getAgentIdentityCertificate()); + } + + @Test + public void getAgentIdentityCertificate_noConfigEnvVar_returnsNull() throws IOException { + // Default opt-in is true, but no config env var is set. + assertNull(AgentIdentityUtils.getAgentIdentityCertificate()); + } + + // --- 4. Certificate Loading & Polling Tests --- + + @Test + public void getAgentIdentityCertificate_happyPath_loadsCertificate() throws IOException { + // Setup: Get the absolute path of the test resource. + URL certUrl = getClass().getClassLoader().getResource("x509_leaf_certificate.pem"); + assertNotNull("Test resource x509_leaf_certificate.pem not found", certUrl); + String certPath = new File(certUrl.getFile()).getAbsolutePath(); + + // Create config file pointing to the cert. + // We still need a temp file for the config because it must contain the absolute path + // to the certificate, which varies by machine. + 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)); + } + + // Configure environment + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath()); + + // Execute + X509Certificate cert = AgentIdentityUtils.getAgentIdentityCertificate(); + + // Verify + assertNotNull(cert); + // Basic verification that it loaded OUR cert + assertTrue(cert.getIssuerDN().getName().contains("unit-tests")); + } + + @Test + public void getAgentIdentityCertificate_timeout_throwsIOException() { + // Setup: Set config path to something that doesn't exist + envProvider.setEnv( + "GOOGLE_API_CERTIFICATE_CONFIG", + tempDir.resolve("missing.json").toAbsolutePath().toString()); + + // Use a fake time service that advances time rapidly when sleep is called. + // This allows the 30s timeout loop to complete instantly in test execution time. + AgentIdentityUtils.setTimeService(new FakeTimeService()); + + // Execute & Verify + 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.")); + } + + // Fake time service that advances time when sleep is requested. + 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 { + // Instead of actually sleeping, just advance the fake clock. + currentTime.addAndGet(millis); + } + } + + // A helper class to mock System.getenv for testing purposes within this file. + 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/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 4b1f9c1ca..3f88115af 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -58,18 +58,26 @@ 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.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.After; import org.junit.Assert; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -128,6 +136,24 @@ public class ComputeEngineCredentialsTest extends BaseSerializationTest { }) .collect(Collectors.toMap(data -> data[0], data -> data[1])); + private TestEnvironmentProvider envProvider; + private Path tempDir; + + @Before + public 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"); + } + + @After + public void tearDown() { + // Reset the mocks + AgentIdentityUtils.resetTimeService(); + AgentIdentityUtils.setEnvReader(System::getenv); + } + @Test public void buildTokenUrlWithScopes_null_scopes() { ComputeEngineCredentials credentials = @@ -1146,6 +1172,149 @@ public void idTokenWithAudience_503StatusCode() { GoogleAuthException.class, () -> credentials.idTokenWithAudience("Audience", null)); } + @Test + public void refreshAccessToken_noAgentConfig_requestsNormalToken() throws IOException { + // No Agent Identity config in environment + 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()); + + // Verify NO fingerprint was sent + String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); + assertFalse(requestUrl.contains("bindCertificateFingerprint")); + } + + @Test + public void refreshAccessToken_withStandardCert_requestsNormalToken() throws IOException { + setupCertConfig("x509_leaf_certificate.pem"); // Standard cert, no SPIFFE + + 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()); + + // Verify NO fingerprint was sent because cert didn't match SPIFFE pattern + String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); + assertFalse(requestUrl.contains("bindCertificateFingerprint")); + } + + @Test + public void refreshAccessToken_withAgentCert_requestsBoundToken() throws IOException { + setupCertConfig("agent_spiffe_cert.pem"); // Valid Agent Identity cert + + 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()); + + // Verify fingerprint WAS sent + String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); + assertTrue(requestUrl.contains("bindCertificateFingerprint")); + } + + @Test + public 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()); + + // Verify NO fingerprint due to opt-out + String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); + assertFalse(requestUrl.contains("bindCertificateFingerprint")); + } + + @Test + public void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { + + // Point config to a non-existent file. + envProvider.setEnv( + AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, + tempDir.resolve("missing_config.json").toAbsolutePath().toString()); + + // Use a mock TimeService to avoid actual sleeping and control time flow. + 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 { + // Copy cert resource to temp file + Path certPath = tempDir.resolve("cert.pem"); + try (InputStream certStream = + getClass().getClassLoader().getResourceAsStream(certResourceName)) { + assertNotNull("Test resource " + certResourceName + " not found", certStream); + Files.copy(certStream, certPath); + } + + // Create config file pointing to cert + 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)); + + // Set env var + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configPath.toAbsolutePath().toString()); + } + static class MockMetadataServerTransportFactory implements HttpTransportFactory { MockMetadataServerTransport transport = @@ -1156,4 +1325,17 @@ public HttpTransport create() { return transport; } } + + // A helper class to mock System.getenv for testing purposes within this file. + 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/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java b/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java index cb6f5a8cc..6b526a8ae 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java @@ -65,6 +65,8 @@ import java.util.logging.Level; import java.util.logging.LogRecord; import java.util.logging.Logger; +import org.junit.After; +import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; @@ -73,6 +75,18 @@ @RunWith(JUnit4.class) public class DefaultCredentialsProviderTest { + @Before + public void setUp() { + // Isolate tests from user's GOOGLE_API_CERTIFICATE_CONFIG environment variable. + AgentIdentityUtils.setEnvReader(name -> null); + } + + @After + public 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/oauth2_http/testresources/agent_cert.pem b/oauth2_http/testresources/agent_cert.pem new file mode 100644 index 000000000..4219c2971 --- /dev/null +++ b/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----- \ No newline at end of file diff --git a/oauth2_http/testresources/agent_spiffe_cert.pem b/oauth2_http/testresources/agent_spiffe_cert.pem new file mode 100644 index 000000000..79a5f36fc --- /dev/null +++ b/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 +1c1WstvBXb24PSSQQdy9un72ZG6f9fSVQrko6hchv8Rg6yyBTFE8APPkeMR/EJtV +cnXg4CgsQIPHxJGQrhNvQhF7VLZePlTass4bqTqTYXwAte2jX/KW3qlW/t/v4AJe +/q+pcXmNIvwRpT8zYA5tJHIDVJ+v9pWZA+nhoD9Qtr7FVHfB4mdNuFv7bMPoXN0+ +mCPzP08MnjgbX7zRETVlblrx +-----END CERTIFICATE-----