From 0af6032cc5c40ba26eb9adb362414bec6dfa985a Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Fri, 17 Apr 2026 16:26:18 -0700 Subject: [PATCH 01/19] Port Agent Identities feature for Cloud Run --- .../oauth2_http/EnableAutoValue.txt | 0 .../auth/oauth2/AgentIdentityUtils.java | 249 ++++++++++++++++++ .../auth/oauth2/ComputeEngineCredentials.java | 15 +- .../auth/oauth2/AgentIdentityUtilsTest.java | 180 +++++++++++++ .../oauth2/ComputeEngineCredentialsTest.java | 137 ++++++++++ .../DefaultCredentialsProviderTest.java | 14 + .../oauth2_http/testresources/agent_cert.pem | 19 ++ .../testresources/agent_spiffe_cert.pem | 20 ++ .../gradle/gradle/wrapper/gradle-wrapper.jar | Bin 59536 -> 0 bytes 9 files changed, 632 insertions(+), 2 deletions(-) delete mode 100644 google-auth-library-java/oauth2_http/EnableAutoValue.txt create mode 100644 google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java create mode 100644 google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java create mode 100644 google-auth-library-java/oauth2_http/testresources/agent_cert.pem create mode 100644 google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem delete mode 100644 rules_java_gapic/resources/gradle/gradle/wrapper/gradle-wrapper.jar diff --git a/google-auth-library-java/oauth2_http/EnableAutoValue.txt b/google-auth-library-java/oauth2_http/EnableAutoValue.txt deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java new file mode 100644 index 000000000000..b66ef8a2679a --- /dev/null +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -0,0 +1,249 @@ +package com.google.auth.oauth2; + +import com.google.api.client.json.GenericJson; +import com.google.api.client.json.JsonObjectParser; +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableList; +import com.google.common.io.BaseEncoding; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.cert.CertificateFactory; +import java.security.cert.CertificateParsingException; +import java.security.cert.X509Certificate; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.regex.Pattern; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Utility class for Agent Identity token binding in Cloud Run. + */ +class AgentIdentityUtils { + + private static final Logger LOGGER = LoggerFactory.getLogger(AgentIdentityUtils.class); + + // Environment variables + static final String GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG"; + static final String GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES = + "GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES"; + + private static final List AGENT_IDENTITY_SPIFFE_PATTERNS = + ImmutableList.of( + Pattern.compile("^agents\\.global\\.org-\\d+\\.system\\.id\\.goog$"), + Pattern.compile("^agents\\.global\\.proj-\\d+\\.system\\.id\\.goog$")); + + private static final int SAN_URI_TYPE = 6; + private static final String SPIFFE_SCHEME_PREFIX = "spiffe://"; + + // Polling configuration + private static final int FAST_POLL_CYCLES = 50; + private static final long FAST_POLL_INTERVAL_MS = 100; // 0.1 seconds + private static final long SLOW_POLL_INTERVAL_MS = 500; // 0.5 seconds + private static final long TOTAL_TIMEOUT_MS = 30000; // 30 seconds + private static final List POLLING_INTERVALS; + + static { + List intervals = new ArrayList<>(); + for (int i = 0; i < FAST_POLL_CYCLES; i++) { + intervals.add(FAST_POLL_INTERVAL_MS); + } + long remainingTime = TOTAL_TIMEOUT_MS - (FAST_POLL_CYCLES * FAST_POLL_INTERVAL_MS); + int slowPollCycles = (int) (remainingTime / SLOW_POLL_INTERVAL_MS); + for (int i = 0; i < slowPollCycles; i++) { + intervals.add(SLOW_POLL_INTERVAL_MS); + } + POLLING_INTERVALS = Collections.unmodifiableList(intervals); + } + + interface EnvReader { + String getEnv(String name); + } + + private static EnvReader envReader = System::getenv; + + @VisibleForTesting + interface TimeService { + long currentTimeMillis(); + void sleep(long millis) throws InterruptedException; + } + + private static TimeService timeService = + new TimeService() { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + + @Override + public void sleep(long millis) throws InterruptedException { + Thread.sleep(millis); + } + }; + + private AgentIdentityUtils() {} + + static X509Certificate getAgentIdentityCertificate() throws IOException { + if (isOptedOut()) { + return null; + } + String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG); + if (Strings.isNullOrEmpty(certConfigPath)) { + return null; + } + String certPath = getCertificatePathWithRetry(certConfigPath); + return parseCertificate(certPath); + } + + private static boolean isOptedOut() { + String optOut = envReader.getEnv(GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES); + return "false".equalsIgnoreCase(optOut); + } + + private static String getCertificatePathWithRetry(String certConfigPath) throws IOException { + boolean warned = false; + for (long sleepInterval : POLLING_INTERVALS) { + try { + if (Files.exists(Paths.get(certConfigPath))) { + String certPath = extractCertPathFromConfig(certConfigPath); + if (!Strings.isNullOrEmpty(certPath) && Files.exists(Paths.get(certPath))) { + return certPath; + } + } + } catch (IOException e) { + // Fall through to retry + } + if (!warned) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + String.format( + "Certificate config file not found at %s (from %s environment variable). Retrying for up to %d seconds.", + certConfigPath, GOOGLE_API_CERTIFICATE_CONFIG, TOTAL_TIMEOUT_MS / 1000)); + warned = true; + } + try { + timeService.sleep(sleepInterval); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException( + "Interrupted while waiting for Agent Identity certificate files for bound token request.", + e); + } + } + throw new IOException( + "Unable to find Agent Identity certificate config or file for bound token request after multiple retries. Token binding protection is failing. You can turn off this protection by setting " + + GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES + + " to false to fall back to unbound tokens."); + } + + @SuppressWarnings("unchecked") + private static String extractCertPathFromConfig(String certConfigPath) throws IOException { + try (InputStream stream = new FileInputStream(certConfigPath)) { + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson config = parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class); + Map certConfigs = (Map) config.get("cert_configs"); + if (certConfigs != null) { + Map workload = (Map) certConfigs.get("workload"); + if (workload != null) { + return (String) workload.get("cert_path"); + } + } + } + return null; + } + + private static X509Certificate parseCertificate(String certPath) throws IOException { + try (InputStream stream = new FileInputStream(certPath)) { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate) cf.generateCertificate(stream); + } catch (GeneralSecurityException e) { + throw new IOException( + "Failed to parse Agent Identity certificate for bound token request.", e); + } + } + + static boolean shouldRequestBoundToken(X509Certificate cert) { + try { + Collection> sans = cert.getSubjectAlternativeNames(); + if (sans == null) { + return false; + } + for (List san : sans) { + if (san.size() >= 2 + && san.get(0) instanceof Integer + && (Integer) san.get(0) == SAN_URI_TYPE) { + Object value = san.get(1); + if (value instanceof String) { + String uri = (String) value; + if (uri.startsWith(SPIFFE_SCHEME_PREFIX)) { + String withoutScheme = uri.substring(SPIFFE_SCHEME_PREFIX.length()); + int slashIndex = withoutScheme.indexOf('/'); + String trustDomain = + (slashIndex == -1) ? withoutScheme : withoutScheme.substring(0, slashIndex); + for (Pattern pattern : AGENT_IDENTITY_SPIFFE_PATTERNS) { + if (pattern.matcher(trustDomain).matches()) { + return true; + } + } + } + } + } + } + } catch (CertificateParsingException e) { + LOGGER.warn("Failed to parse Subject Alternative Names from certificate", e); + } + return false; + } + + static String calculateCertificateFingerprint(X509Certificate cert) throws IOException { + try { + MessageDigest md = MessageDigest.getInstance("SHA-256"); + byte[] der = cert.getEncoded(); + md.update(der); + byte[] digest = md.digest(); + String base64Fingerprint = BaseEncoding.base64().omitPadding().encode(digest); + return URLEncoder.encode(base64Fingerprint, "UTF-8"); + } catch (GeneralSecurityException e) { + throw new IOException("Failed to calculate fingerprint for Agent Identity certificate.", e); + } + } + + @VisibleForTesting + static void setEnvReader(EnvReader reader) { + envReader = reader; + } + + @VisibleForTesting + static void setTimeService(TimeService service) { + timeService = service; + } + + @VisibleForTesting + static void resetTimeService() { + timeService = + new TimeService() { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + + @Override + public void sleep(long millis) throws InterruptedException { + Thread.sleep(millis); + } + }; + } +} diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java index d1933126d629..553eb7da0cd5 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java @@ -61,6 +61,7 @@ import java.io.ObjectInputStream; import java.net.SocketTimeoutException; import java.net.UnknownHostException; +import java.security.cert.X509Certificate; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; @@ -421,8 +422,18 @@ private String getProjectIdFromMetadata() { /** Refresh the access token by getting it from the GCE metadata server */ @Override public AccessToken refreshAccessToken() throws IOException { - HttpResponse response = - getMetadataResponse(createTokenUrlWithScopes(), RequestType.ACCESS_TOKEN_REQUEST, true); + String tokenUrl = createTokenUrlWithScopes(); + + // Checks whether access token has to be bound to certificate for agent identity. + X509Certificate cert = AgentIdentityUtils.getAgentIdentityCertificate(); + if (cert != null && AgentIdentityUtils.shouldRequestBoundToken(cert)) { + String fingerprint = AgentIdentityUtils.calculateCertificateFingerprint(cert); + GenericUrl url = new GenericUrl(tokenUrl); + url.set("bindCertificateFingerprint", fingerprint); + tokenUrl = url.build(); + } + + HttpResponse response = getMetadataResponse(tokenUrl, RequestType.ACCESS_TOKEN_REQUEST, true); int statusCode = response.getStatusCode(); if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { throw new IOException( diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java new file mode 100644 index 000000000000..2c9ec9879f57 --- /dev/null +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java @@ -0,0 +1,180 @@ +package com.google.auth.oauth2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.net.URL; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.security.cert.CertificateException; +import java.security.cert.X509Certificate; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +class AgentIdentityUtilsTest { + + private static final String VALID_SPIFFE_ORG = + "spiffe://agents.global.org-12345.system.id.goog/path/to/resource"; + private static final String VALID_SPIFFE_PROJ = + "spiffe://agents.global.proj-98765.system.id.goog/another/path"; + private static final String INVALID_SPIFFE_DOMAIN = "spiffe://example.com/workload"; + private static final String INVALID_SPIFFE_FORMAT = + "spiffe://agents.global.org-INVALID.system.id.goog/path"; + + private TestEnvironmentProvider envProvider; + private Path tempDir; + + @BeforeEach + void setUp() throws IOException { + envProvider = new TestEnvironmentProvider(); + AgentIdentityUtils.setEnvReader(envProvider::getEnv); + tempDir = Files.createTempDirectory("agent_identity_test"); + } + + @AfterEach + void tearDown() throws IOException { + AgentIdentityUtils.resetTimeService(); + if (tempDir != null) { + Files.walk(tempDir) + .sorted(java.util.Comparator.reverseOrder()) + .map(Path::toFile) + .forEach(File::delete); + } + } + + @Test + public void shouldRequestBoundToken_validOrgSpiffe_returnsTrue() throws CertificateException { + assertTrue(AgentIdentityUtils.shouldRequestBoundToken(mockCertWithSanUri(VALID_SPIFFE_ORG))); + } + + @Test + public void shouldRequestBoundToken_validProjSpiffe_returnsTrue() throws CertificateException { + assertTrue(AgentIdentityUtils.shouldRequestBoundToken(mockCertWithSanUri(VALID_SPIFFE_PROJ))); + } + + @Test + public void shouldRequestBoundToken_invalidDomain_returnsFalse() throws CertificateException { + assertFalse( + AgentIdentityUtils.shouldRequestBoundToken(mockCertWithSanUri(INVALID_SPIFFE_DOMAIN))); + } + + @Test + public void shouldRequestBoundToken_invalidFormat_returnsFalse() throws CertificateException { + assertFalse( + AgentIdentityUtils.shouldRequestBoundToken(mockCertWithSanUri(INVALID_SPIFFE_FORMAT))); + } + + @Test + public void shouldRequestBoundToken_noSan_returnsFalse() throws CertificateException { + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getSubjectAlternativeNames()).thenReturn(null); + assertFalse(AgentIdentityUtils.shouldRequestBoundToken(mockCert)); + } + + private X509Certificate mockCertWithSanUri(String uri) throws CertificateException { + X509Certificate mockCert = mock(X509Certificate.class); + List spiffeEntry = Arrays.asList(6, uri); + Collection> sans = Collections.singletonList(spiffeEntry); + when(mockCert.getSubjectAlternativeNames()).thenReturn(sans); + return mockCert; + } + + @Test + public void calculateCertificateFingerprint_knownInput_returnsExpectedOutput() throws Exception { + X509Certificate mockCert = mock(X509Certificate.class); + byte[] fakeDer = new byte[] {0x01, 0x02, 0x03, 0x04, (byte) 0xFF}; + when(mockCert.getEncoded()).thenReturn(fakeDer); + String expectedFingerprint = "%2FEAuXk1xSDxtU3mEowwrTIsGVTmkvRsCbGESkmulJ5M"; + String actualFingerprint = AgentIdentityUtils.calculateCertificateFingerprint(mockCert); + assertEquals(expectedFingerprint, actualFingerprint); + } + + @Test + public void getAgentIdentityCertificate_optedOut_returnsNullImmediately() throws IOException { + envProvider.setEnv("GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", "/non/existent/path"); + assertNull(AgentIdentityUtils.getAgentIdentityCertificate()); + } + + @Test + public void getAgentIdentityCertificate_noConfigEnvVar_returnsNull() throws IOException { + assertNull(AgentIdentityUtils.getAgentIdentityCertificate()); + } + + @Test + public void getAgentIdentityCertificate_happyPath_loadsCertificate() throws IOException { + URL certUrl = getClass().getClassLoader().getResource("x509_leaf_certificate.pem"); + assertNotNull(certUrl, "Test resource x509_leaf_certificate.pem not found"); + String certPath = new File(certUrl.getFile()).getAbsolutePath(); + File configFile = tempDir.resolve("config.json").toFile(); + String configJson = + "{" + + " \"cert_configs\": {" + + " \"workload\": {" + + " \"cert_path\": \"" + + certPath.replace("\\", "\\\\") + + "\"" + + " }" + + " }" + + "}"; + try (FileOutputStream fos = new FileOutputStream(configFile)) { + fos.write(configJson.getBytes(StandardCharsets.UTF_8)); + } + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.getAbsolutePath()); + X509Certificate cert = AgentIdentityUtils.getAgentIdentityCertificate(); + assertNotNull(cert); + assertTrue(cert.getIssuerDN().getName().contains("unit-tests")); + } + + @Test + public void getAgentIdentityCertificate_timeout_throwsIOException() { + envProvider.setEnv( + "GOOGLE_API_CERTIFICATE_CONFIG", + tempDir.resolve("missing.json").toAbsolutePath().toString()); + AgentIdentityUtils.setTimeService(new FakeTimeService()); + IOException e = + assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertificate); + assertTrue( + e.getMessage() + .contains( + "Unable to find Agent Identity certificate config or file for bound token request after multiple retries.")); + } + + private static class FakeTimeService implements AgentIdentityUtils.TimeService { + private final AtomicLong currentTime = new AtomicLong(0); + @Override + public long currentTimeMillis() { + return currentTime.get(); + } + @Override + public void sleep(long millis) throws InterruptedException { + currentTime.addAndGet(millis); + } + } + + private static class TestEnvironmentProvider { + private final java.util.Map env = new java.util.HashMap<>(); + void setEnv(String key, String value) { + env.put(key, value); + } + String getEnv(String key) { + return env.get(key); + } + } +} diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 17184032e94a..640434dc1e2f 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -65,9 +65,15 @@ import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; import java.util.ArrayDeque; import java.util.Arrays; import java.util.Collection; +import java.util.HashMap; +import java.util.concurrent.atomic.AtomicLong; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import java.util.Collections; import java.util.List; import java.util.Map; @@ -82,6 +88,24 @@ class ComputeEngineCredentialsTest extends BaseSerializationTest { private static final URI CALL_URI = URI.create("http://googleapis.com/testapi/v1/foo"); + private TestEnvironmentProvider envProvider; + private Path tempDir; + + @BeforeEach + void setUp() throws IOException { + envProvider = new TestEnvironmentProvider(); + // Inject our test environment reader into AgentIdentityUtils + AgentIdentityUtils.setEnvReader(envProvider::getEnv); + tempDir = Files.createTempDirectory("compute_engine_creds_test"); + } + + @AfterEach + void tearDown() { + // Reset the mocks + AgentIdentityUtils.resetTimeService(); + AgentIdentityUtils.setEnvReader(System::getenv); + } + private static final String TOKEN_URL = "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token"; @@ -1234,6 +1258,119 @@ InputStream readStream(File file) throws FileNotFoundException { boolean isOnGce = ComputeEngineCredentials.isOnGce(transportFactory, provider); assertTrue(isOnGce); assertEquals(1, transportFactory.transport.getRequestCount()); + + void refreshAccessToken_noAgentConfig_requestsNormalToken() throws IOException { + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + AccessToken token = credentials.refreshAccessToken(); + assertNotNull(token); + assertEquals(ACCESS_TOKEN, token.getTokenValue()); + String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); + assertFalse(requestUrl.contains("bindCertificateFingerprint")); + } + + @Test + void refreshAccessToken_withStandardCert_requestsNormalToken() throws IOException { + setupCertConfig("x509_leaf_certificate.pem"); + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + AccessToken token = credentials.refreshAccessToken(); + assertNotNull(token); + assertEquals(ACCESS_TOKEN, token.getTokenValue()); + String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); + assertFalse(requestUrl.contains("bindCertificateFingerprint")); + } + + @Test + void refreshAccessToken_withAgentCert_requestsBoundToken() throws IOException { + setupCertConfig("agent_spiffe_cert.pem"); + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + AccessToken token = credentials.refreshAccessToken(); + assertNotNull(token); + assertEquals(ACCESS_TOKEN, token.getTokenValue()); + String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); + assertTrue(requestUrl.contains("bindCertificateFingerprint")); + } + + @Test + void refreshAccessToken_withAgentCert_optedOut_requestsNormalToken() throws IOException { + setupCertConfig("agent_spiffe_cert.pem"); + envProvider.setEnv("GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + AccessToken token = credentials.refreshAccessToken(); + assertNotNull(token); + assertEquals(ACCESS_TOKEN, token.getTokenValue()); + String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); + assertFalse(requestUrl.contains("bindCertificateFingerprint")); + } + + @Test + void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { + envProvider.setEnv( + AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, + tempDir.resolve("missing_config.json").toAbsolutePath().toString()); + final AtomicLong currentTime = new AtomicLong(0); + AgentIdentityUtils.setTimeService( + new AgentIdentityUtils.TimeService() { + @Override + public long currentTimeMillis() { + return currentTime.get(); + } + @Override + public void sleep(long millis) { + currentTime.addAndGet(millis); + } + }); + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + IOException e = assertThrows(IOException.class, credentials::refreshAccessToken); + assertTrue( + e.getMessage() + .contains( + "Unable to find Agent Identity certificate config or file for bound token request after multiple retries.")); + } + + private void setupCertConfig(String certResourceName) throws IOException { + Path certPath = tempDir.resolve("cert.pem"); + try (InputStream certStream = + getClass().getClassLoader().getResourceAsStream(certResourceName)) { + assertNotNull(certStream, "Test resource " + certResourceName + " not found"); + Files.copy(certStream, certPath); + } + Path configPath = tempDir.resolve("config.json"); + String configContent = + "{\"cert_configs\": {\"workload\": {\"cert_path\": \"" + + certPath.toAbsolutePath().toString().replace("\\", "\\\\") + + "\"}}}"; + Files.write(configPath, configContent.getBytes(StandardCharsets.UTF_8)); + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configPath.toAbsolutePath().toString()); + } + + private static class TestEnvironmentProvider { + private final Map env = new HashMap<>(); + void setEnv(String key, String value) { + env.put(key, value); + } + String getEnv(String key) { + return env.get(key); + } } static class MockMetadataServerTransportFactory implements HttpTransportFactory { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java index dbfb70ea0038..d641f9b18a3d 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java @@ -67,11 +67,25 @@ import java.util.logging.Level; import java.util.logging.LogRecord; import java.util.logging.Logger; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; /** Test case for {@link DefaultCredentialsProvider}. */ class DefaultCredentialsProviderTest { + @BeforeEach + void setUp() { + // Isolate tests from user's GOOGLE_API_CERTIFICATE_CONFIG environment variable. + AgentIdentityUtils.setEnvReader(name -> null); + } + + @AfterEach + void tearDown() { + // Reset to default behavior. + AgentIdentityUtils.setEnvReader(System::getenv); + } + private static final String USER_CLIENT_SECRET = "jakuaL9YyieakhECKL2SwZcu"; private static final String USER_CLIENT_ID = "ya29.1.AADtN_UtlxN3PuGAxrN2XQnZTVRvDyVWnYq4I6dws"; private static final String GCLOUDSDK_CLIENT_ID = diff --git a/google-auth-library-java/oauth2_http/testresources/agent_cert.pem b/google-auth-library-java/oauth2_http/testresources/agent_cert.pem new file mode 100644 index 000000000000..7af6ca3f9314 --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/agent_cert.pem @@ -0,0 +1,19 @@ +-----BEGIN CERTIFICATE----- +MIIDIzCCAgugAwIBAgIJAMfISuBQ5m+5MA0GCSqGSIb3DQEBBQUAMBUxEzARBgNV +BAMTCnVuaXQtdGVzdHMwHhcNMTExMjA2MTYyNjAyWhcNMjExMjAzMTYyNjAyWjAV +MRMwEQYDVQQDEwp1bml0LXRlc3RzMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB +CgKCAQEA4ej0p7bQ7L/r4rVGUz9RN4VQWoej1Bg1mYWIDYslvKrk1gpj7wZgkdmM +7oVK2OfgrSj/FCTkInKPqaCR0gD7K80q+mLBrN3PUkDrJQZpvRZIff3/xmVU1Wer +uQLFJjnFb2dqu0s/FY/2kWiJtBCakXvXEOb7zfbINuayL+MSsCGSdVYsSliS5qQp +gyDap+8b5fpXZVJkq92hrcNtbkg7hCYUJczt8n9hcCTJCfUpApvaFQ18pe+zpyl4 ++WzkP66I28hniMQyUlA1hBiskT7qiouq0m8IOodhv2fagSZKjOTTU2xkSBc//fy3 +ZpsL7WqgsZS7Q+0VRK8gKfqkxg5OYQIDAQABo3YwdDAdBgNVHQ4EFgQU2RQ8yO+O +gN8oVW2SW7RLrfYd9jEwRQYDVR0jBD4wPIAU2RQ8yO+OgN8oVW2SW7RLrfYd9jGh +GaQXMBUxEzARBgNVBAMTCnVuaXQtdGVzdHOCCQDHyErgUOZvuTAMBgNVHRMEBTAD +AQH/MA0GCSqGSIb3DQEBBQUAA4IBAQBRv+M/6+FiVu7KXNjFI5pSN17OcW5QUtPr +odJMlWrJBtynn/TA1oJlYu3yV5clc/71Vr/AxuX5xGP+IXL32YDF9lTUJXG/uUGk ++JETpKmQviPbRsvzYhz4pf6ZIOZMc3/GIcNq92ECbseGO+yAgyWUVKMmZM0HqXC9 +ovNslqe0M8C1sLm1zAR5z/h/litE7/8O2ietija3Q/qtl2TOXJdCA6sgjJX2WUql +ybrC55ct18NKf3qhpcEkGQvFU40rVYApJpi98DiZPYFdx1oBDp/f4uZ3ojpxRVFT +cDwcJLfNRCPUhormsY7fDS9xSyThiHsW9mjJYdcaKQkwYZ0F11yB +-----END CERTIFICATE----- diff --git a/google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem b/google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem new file mode 100644 index 000000000000..d0a0ed1a883d --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem @@ -0,0 +1,20 @@ +-----BEGIN CERTIFICATE----- +MIIDPjCCAiagAwIBAgIUCYeV4dwM29T5yucwWrSWlOC9wwYwDQYJKoZIhvcNAQEL +BQAwIjEgMB4GA1UEAwwXVGVzdCBTUElGRkUgQ2VydGlmaWNhdGUwHhcNMjUxMTA3 +MDEyMjQ4WhcNMzUxMTA1MDEyMjQ4WjAiMSAwHgYDVQQDDBdUZXN0IFNQSUZGRSBD +ZXJ0aWZpY2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDr1Bzo +KtzIZB35acQ+mpk6yScf59AnwHjjgNCMbC7kq2DSUfQzTlu9Kd0uUB6O7DmJ73D8 +Pge4XLE/Q1B6dI6DzJx7lhPoC1BiQFUGJ4Cu+TbbdlK3RiXNAZYjIj9UKP7DejCY +WRgFB+PYyLczEkByvU9cy7Z9Uuufsn6LnYu7qOG+DcRSE41ThurZxQ14OWvLfjZm +lhZXam4VBBli8Qku8qFIALe78kpy+hp2YCRnK84amATwPpGprRACp9WVka2JDYKD +LY0OoYlyAQel6960aS11N3/2v0cvx03/LM5+Yj+DTvdyb2Mk/NVeRIKo8cM5YwPn +sTLCf1cdxJvseRMCAwEAAaNsMGowSQYDVR0RBEIwQIY+c3BpZmZlOi8vYWdlbnRz +Lmdsb2JhbC5wcm9qLTEyMzQ1LnN5c3RlbS5pZC5nb29nL3Rlc3Qtd29ya2xvYWQw +HQYDVR0OBBYEFPvn+KXBcrYCmAMopkghUczUx/IkMA0GCSqGSIb3DQEBCwUAA4IB +AQCbwd9RMFkr1C9AEgnLMWd1l9ciBbK0t1Sydu3eA0SNm2w6E58ih8O+huo6eGsM +7z0E4i7YuaHnTdah/lPMqd75YRO57GSRbvi2g+yPyw6XdFl9HCHwF4WARdTF4Nkf +1c1WstvBXb24PSSQQdy9un72ZG6f9fSVQrko6hchd8Rg6yyBTFE8APPkeMR/EJtV +cnXg4CgsQIPHxJGQrhNvQhF7VLZePlTass4bqTqTYXwAte2jX/KW3qlW/t/v4AJe +/q+pcXmNIvwRpT8zYA5tJHIDVJ+v9pWZA+nhoD9Qtr7FVHfB4mdNuFv7bMPoXN0+ +mCPzP08MnjgbX7zRETVlblrx +-----END CERTIFICATE----- diff --git a/rules_java_gapic/resources/gradle/gradle/wrapper/gradle-wrapper.jar b/rules_java_gapic/resources/gradle/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 7454180f2ae8848c63b8b4dea2cb829da983f2fa..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 59536 zcma&NbC71ylI~qywr$(CZQJHswz}-9F59+k+g;UV+cs{`J?GrGXYR~=-ydruB3JCa zB64N^cILAcWk5iofq)<(fq;O7{th4@;QxID0)qN`mJ?GIqLY#rX8-|G{5M0pdVW5^ zzXk$-2kQTAC?_N@B`&6-N-rmVFE=$QD?>*=4<|!MJu@}isLc4AW#{m2if&A5T5g&~ ziuMQeS*U5sL6J698wOd)K@oK@1{peP5&Esut<#VH^u)gp`9H4)`uE!2$>RTctN+^u z=ASkePDZA-X8)rp%D;p*~P?*a_=*Kwc<^>QSH|^<0>o37lt^+Mj1;4YvJ(JR-Y+?%Nu}JAYj5 z_Qc5%Ao#F?q32i?ZaN2OSNhWL;2oDEw_({7ZbgUjna!Fqn3NzLM@-EWFPZVmc>(fZ z0&bF-Ch#p9C{YJT9Rcr3+Y_uR^At1^BxZ#eo>$PLJF3=;t_$2|t+_6gg5(j{TmjYU zK12c&lE?Eh+2u2&6Gf*IdKS&6?rYbSEKBN!rv{YCm|Rt=UlPcW9j`0o6{66#y5t9C zruFA2iKd=H%jHf%ypOkxLnO8#H}#Zt{8p!oi6)7#NqoF({t6|J^?1e*oxqng9Q2Cc zg%5Vu!em)}Yuj?kaP!D?b?(C*w!1;>R=j90+RTkyEXz+9CufZ$C^umX^+4|JYaO<5 zmIM3#dv`DGM;@F6;(t!WngZSYzHx?9&$xEF70D1BvfVj<%+b#)vz)2iLCrTeYzUcL z(OBnNoG6Le%M+@2oo)&jdOg=iCszzv59e zDRCeaX8l1hC=8LbBt|k5?CXgep=3r9BXx1uR8!p%Z|0+4Xro=xi0G!e{c4U~1j6!) zH6adq0}#l{%*1U(Cb%4AJ}VLWKBPi0MoKFaQH6x?^hQ!6em@993xdtS%_dmevzeNl z(o?YlOI=jl(`L9^ z0O+H9k$_@`6L13eTT8ci-V0ljDMD|0ifUw|Q-Hep$xYj0hTO@0%IS^TD4b4n6EKDG z??uM;MEx`s98KYN(K0>c!C3HZdZ{+_53DO%9k5W%pr6yJusQAv_;IA}925Y%;+!tY z%2k!YQmLLOr{rF~!s<3-WEUs)`ix_mSU|cNRBIWxOox_Yb7Z=~Q45ZNe*u|m^|)d* zog=i>`=bTe!|;8F+#H>EjIMcgWcG2ORD`w0WD;YZAy5#s{65~qfI6o$+Ty&-hyMyJ z3Ra~t>R!p=5ZpxA;QkDAoPi4sYOP6>LT+}{xp}tk+<0k^CKCFdNYG(Es>p0gqD)jP zWOeX5G;9(m@?GOG7g;e74i_|SmE?`B2i;sLYwRWKLy0RLW!Hx`=!LH3&k=FuCsM=9M4|GqzA)anEHfxkB z?2iK-u(DC_T1};KaUT@3nP~LEcENT^UgPvp!QC@Dw&PVAhaEYrPey{nkcn(ro|r7XUz z%#(=$7D8uP_uU-oPHhd>>^adbCSQetgSG`e$U|7mr!`|bU0aHl_cmL)na-5x1#OsVE#m*+k84Y^+UMeSAa zbrVZHU=mFwXEaGHtXQq`2ZtjfS!B2H{5A<3(nb-6ARVV8kEmOkx6D2x7~-6hl;*-*}2Xz;J#a8Wn;_B5=m zl3dY;%krf?i-Ok^Pal-}4F`{F@TYPTwTEhxpZK5WCpfD^UmM_iYPe}wpE!Djai6_{ z*pGO=WB47#Xjb7!n2Ma)s^yeR*1rTxp`Mt4sfA+`HwZf%!7ZqGosPkw69`Ix5Ku6G z@Pa;pjzV&dn{M=QDx89t?p?d9gna*}jBly*#1!6}5K<*xDPJ{wv4& zM$17DFd~L*Te3A%yD;Dp9UGWTjRxAvMu!j^Tbc}2v~q^59d4bz zvu#!IJCy(BcWTc`;v$9tH;J%oiSJ_i7s;2`JXZF+qd4C)vY!hyCtl)sJIC{ebI*0> z@x>;EzyBv>AI-~{D6l6{ST=em*U( z(r$nuXY-#CCi^8Z2#v#UXOt`dbYN1z5jzNF2 z411?w)whZrfA20;nl&C1Gi+gk<`JSm+{|*2o<< zqM#@z_D`Cn|0H^9$|Tah)0M_X4c37|KQ*PmoT@%xHc3L1ZY6(p(sNXHa&49Frzto& zR`c~ClHpE~4Z=uKa5S(-?M8EJ$zt0&fJk~p$M#fGN1-y$7!37hld`Uw>Urri(DxLa;=#rK0g4J)pXMC zxzraOVw1+kNWpi#P=6(qxf`zSdUC?D$i`8ZI@F>k6k zz21?d+dw7b&i*>Kv5L(LH-?J%@WnqT7j#qZ9B>|Zl+=> z^U-pV@1y_ptHo4hl^cPRWewbLQ#g6XYQ@EkiP z;(=SU!yhjHp%1&MsU`FV1Z_#K1&(|5n(7IHbx&gG28HNT)*~-BQi372@|->2Aw5It z0CBpUcMA*QvsPy)#lr!lIdCi@1k4V2m!NH)%Px(vu-r(Q)HYc!p zJ^$|)j^E#q#QOgcb^pd74^JUi7fUmMiNP_o*lvx*q%_odv49Dsv$NV;6J z9GOXKomA{2Pb{w}&+yHtH?IkJJu~}Z?{Uk++2mB8zyvh*xhHKE``99>y#TdD z&(MH^^JHf;g(Tbb^&8P*;_i*2&fS$7${3WJtV7K&&(MBV2~)2KB3%cWg#1!VE~k#C z!;A;?p$s{ihyojEZz+$I1)L}&G~ml=udD9qh>Tu(ylv)?YcJT3ihapi!zgPtWb*CP zlLLJSRCj-^w?@;RU9aL2zDZY1`I3d<&OMuW=c3$o0#STpv_p3b9Wtbql>w^bBi~u4 z3D8KyF?YE?=HcKk!xcp@Cigvzy=lnFgc^9c%(^F22BWYNAYRSho@~*~S)4%AhEttv zvq>7X!!EWKG?mOd9&n>vvH1p4VzE?HCuxT-u+F&mnsfDI^}*-d00-KAauEaXqg3k@ zy#)MGX!X;&3&0s}F3q40ZmVM$(H3CLfpdL?hB6nVqMxX)q=1b}o_PG%r~hZ4gUfSp zOH4qlEOW4OMUc)_m)fMR_rl^pCfXc{$fQbI*E&mV77}kRF z&{<06AJyJ!e863o-V>FA1a9Eemx6>^F$~9ppt()ZbPGfg_NdRXBWoZnDy2;#ODgf! zgl?iOcF7Meo|{AF>KDwTgYrJLb$L2%%BEtO>T$C?|9bAB&}s;gI?lY#^tttY&hfr# zKhC+&b-rpg_?~uVK%S@mQleU#_xCsvIPK*<`E0fHE1&!J7!xD#IB|SSPW6-PyuqGn3^M^Rz%WT{e?OI^svARX&SAdU77V(C~ zM$H{Kg59op{<|8ry9ecfP%=kFm(-!W&?U0@<%z*+!*<e0XesMxRFu9QnGqun6R_%T+B%&9Dtk?*d$Q zb~>84jEAPi@&F@3wAa^Lzc(AJz5gsfZ7J53;@D<;Klpl?sK&u@gie`~vTsbOE~Cd4 z%kr56mI|#b(Jk&;p6plVwmNB0H@0SmgdmjIn5Ne@)}7Vty(yb2t3ev@22AE^s!KaN zyQ>j+F3w=wnx7w@FVCRe+`vUH)3gW%_72fxzqX!S&!dchdkRiHbXW1FMrIIBwjsai8`CB2r4mAbwp%rrO>3B$Zw;9=%fXI9B{d(UzVap7u z6piC-FQ)>}VOEuPpuqznpY`hN4dGa_1Xz9rVg(;H$5Te^F0dDv*gz9JS<|>>U0J^# z6)(4ICh+N_Q`Ft0hF|3fSHs*?a=XC;e`sJaU9&d>X4l?1W=|fr!5ShD|nv$GK;j46@BV6+{oRbWfqOBRb!ir88XD*SbC(LF}I1h#6@dvK%Toe%@ zhDyG$93H8Eu&gCYddP58iF3oQH*zLbNI;rN@E{T9%A8!=v#JLxKyUe}e}BJpB{~uN zqgxRgo0*-@-iaHPV8bTOH(rS(huwK1Xg0u+e!`(Irzu@Bld&s5&bWgVc@m7;JgELd zimVs`>vQ}B_1(2#rv#N9O`fJpVfPc7V2nv34PC);Dzbb;p!6pqHzvy?2pD&1NE)?A zt(t-ucqy@wn9`^MN5apa7K|L=9>ISC>xoc#>{@e}m#YAAa1*8-RUMKwbm|;5p>T`Z zNf*ph@tnF{gmDa3uwwN(g=`Rh)4!&)^oOy@VJaK4lMT&5#YbXkl`q?<*XtsqD z9PRK6bqb)fJw0g-^a@nu`^?71k|m3RPRjt;pIkCo1{*pdqbVs-Yl>4E>3fZx3Sv44grW=*qdSoiZ9?X0wWyO4`yDHh2E!9I!ZFi zVL8|VtW38}BOJHW(Ax#KL_KQzarbuE{(%TA)AY)@tY4%A%P%SqIU~8~-Lp3qY;U-} z`h_Gel7;K1h}7$_5ZZT0&%$Lxxr-<89V&&TCsu}LL#!xpQ1O31jaa{U34~^le*Y%L za?7$>Jk^k^pS^_M&cDs}NgXlR>16AHkSK-4TRaJSh#h&p!-!vQY%f+bmn6x`4fwTp z$727L^y`~!exvmE^W&#@uY!NxJi`g!i#(++!)?iJ(1)2Wk;RN zFK&O4eTkP$Xn~4bB|q8y(btx$R#D`O@epi4ofcETrx!IM(kWNEe42Qh(8*KqfP(c0 zouBl6>Fc_zM+V;F3znbo{x#%!?mH3`_ANJ?y7ppxS@glg#S9^MXu|FM&ynpz3o&Qh z2ujAHLF3($pH}0jXQsa#?t--TnF1P73b?4`KeJ9^qK-USHE)4!IYgMn-7z|=ALF5SNGkrtPG@Y~niUQV2?g$vzJN3nZ{7;HZHzWAeQ;5P|@Tl3YHpyznGG4-f4=XflwSJY+58-+wf?~Fg@1p1wkzuu-RF3j2JX37SQUc? zQ4v%`V8z9ZVZVqS8h|@@RpD?n0W<=hk=3Cf8R?d^9YK&e9ZybFY%jdnA)PeHvtBe- zhMLD+SSteHBq*q)d6x{)s1UrsO!byyLS$58WK;sqip$Mk{l)Y(_6hEIBsIjCr5t>( z7CdKUrJTrW%qZ#1z^n*Lb8#VdfzPw~OIL76aC+Rhr<~;4Tl!sw?Rj6hXj4XWa#6Tp z@)kJ~qOV)^Rh*-?aG>ic2*NlC2M7&LUzc9RT6WM%Cpe78`iAowe!>(T0jo&ivn8-7 zs{Qa@cGy$rE-3AY0V(l8wjI^uB8Lchj@?L}fYal^>T9z;8juH@?rG&g-t+R2dVDBe zq!K%{e-rT5jX19`(bP23LUN4+_zh2KD~EAYzhpEO3MUG8@}uBHH@4J zd`>_(K4q&>*k82(dDuC)X6JuPrBBubOg7qZ{?x!r@{%0);*`h*^F|%o?&1wX?Wr4b z1~&cy#PUuES{C#xJ84!z<1tp9sfrR(i%Tu^jnXy;4`Xk;AQCdFC@?V%|; zySdC7qS|uQRcH}EFZH%mMB~7gi}a0utE}ZE_}8PQH8f;H%PN41Cb9R%w5Oi5el^fd z$n{3SqLCnrF##x?4sa^r!O$7NX!}&}V;0ZGQ&K&i%6$3C_dR%I7%gdQ;KT6YZiQrW zk%q<74oVBV>@}CvJ4Wj!d^?#Zwq(b$E1ze4$99DuNg?6t9H}k_|D7KWD7i0-g*EO7 z;5{hSIYE4DMOK3H%|f5Edx+S0VI0Yw!tsaRS2&Il2)ea^8R5TG72BrJue|f_{2UHa z@w;^c|K3da#$TB0P3;MPlF7RuQeXT$ zS<<|C0OF(k)>fr&wOB=gP8!Qm>F41u;3esv7_0l%QHt(~+n; zf!G6%hp;Gfa9L9=AceiZs~tK+Tf*Wof=4!u{nIO90jH@iS0l+#%8=~%ASzFv7zqSB^?!@N7)kp0t&tCGLmzXSRMRyxCmCYUD2!B`? zhs$4%KO~m=VFk3Buv9osha{v+mAEq=ik3RdK@;WWTV_g&-$U4IM{1IhGX{pAu%Z&H zFfwCpUsX%RKg);B@7OUzZ{Hn{q6Vv!3#8fAg!P$IEx<0vAx;GU%}0{VIsmFBPq_mb zpe^BChDK>sc-WLKl<6 zwbW|e&d&dv9Wu0goueyu>(JyPx1mz0v4E?cJjFuKF71Q1)AL8jHO$!fYT3(;U3Re* zPPOe%*O+@JYt1bW`!W_1!mN&=w3G9ru1XsmwfS~BJ))PhD(+_J_^N6j)sx5VwbWK| zwRyC?W<`pOCY)b#AS?rluxuuGf-AJ=D!M36l{ua?@SJ5>e!IBr3CXIxWw5xUZ@Xrw z_R@%?{>d%Ld4p}nEsiA@v*nc6Ah!MUs?GA7e5Q5lPpp0@`%5xY$C;{%rz24$;vR#* zBP=a{)K#CwIY%p} zXVdxTQ^HS@O&~eIftU+Qt^~(DGxrdi3k}DdT^I7Iy5SMOp$QuD8s;+93YQ!OY{eB24%xY7ml@|M7I(Nb@K_-?F;2?et|CKkuZK_>+>Lvg!>JE~wN`BI|_h6$qi!P)+K-1Hh(1;a`os z55)4Q{oJiA(lQM#;w#Ta%T0jDNXIPM_bgESMCDEg6rM33anEr}=|Fn6)|jBP6Y}u{ zv9@%7*#RI9;fv;Yii5CI+KrRdr0DKh=L>)eO4q$1zmcSmglsV`*N(x=&Wx`*v!!hn6X-l0 zP_m;X??O(skcj+oS$cIdKhfT%ABAzz3w^la-Ucw?yBPEC+=Pe_vU8nd-HV5YX6X8r zZih&j^eLU=%*;VzhUyoLF;#8QsEfmByk+Y~caBqSvQaaWf2a{JKB9B>V&r?l^rXaC z8)6AdR@Qy_BxQrE2Fk?ewD!SwLuMj@&d_n5RZFf7=>O>hzVE*seW3U?_p|R^CfoY`?|#x9)-*yjv#lo&zP=uI`M?J zbzC<^3x7GfXA4{FZ72{PE*-mNHyy59Q;kYG@BB~NhTd6pm2Oj=_ zizmD?MKVRkT^KmXuhsk?eRQllPo2Ubk=uCKiZ&u3Xjj~<(!M94c)Tez@9M1Gfs5JV z->@II)CDJOXTtPrQudNjE}Eltbjq>6KiwAwqvAKd^|g!exgLG3;wP+#mZYr`cy3#39e653d=jrR-ulW|h#ddHu(m9mFoW~2yE zz5?dB%6vF}+`-&-W8vy^OCxm3_{02royjvmwjlp+eQDzFVEUiyO#gLv%QdDSI#3W* z?3!lL8clTaNo-DVJw@ynq?q!%6hTQi35&^>P85G$TqNt78%9_sSJt2RThO|JzM$iL zg|wjxdMC2|Icc5rX*qPL(coL!u>-xxz-rFiC!6hD1IR%|HSRsV3>Kq~&vJ=s3M5y8SG%YBQ|{^l#LGlg!D?E>2yR*eV%9m$_J6VGQ~AIh&P$_aFbh zULr0Z$QE!QpkP=aAeR4ny<#3Fwyw@rZf4?Ewq`;mCVv}xaz+3ni+}a=k~P+yaWt^L z@w67!DqVf7D%7XtXX5xBW;Co|HvQ8WR1k?r2cZD%U;2$bsM%u8{JUJ5Z0k= zZJARv^vFkmWx15CB=rb=D4${+#DVqy5$C%bf`!T0+epLJLnh1jwCdb*zuCL}eEFvE z{rO1%gxg>1!W(I!owu*mJZ0@6FM(?C+d*CeceZRW_4id*D9p5nzMY&{mWqrJomjIZ z97ZNnZ3_%Hx8dn;H>p8m7F#^2;T%yZ3H;a&N7tm=Lvs&lgJLW{V1@h&6Vy~!+Ffbb zv(n3+v)_D$}dqd!2>Y2B)#<+o}LH#%ogGi2-?xRIH)1!SD)u-L65B&bsJTC=LiaF+YOCif2dUX6uAA|#+vNR z>U+KQekVGon)Yi<93(d!(yw1h3&X0N(PxN2{%vn}cnV?rYw z$N^}_o!XUB!mckL`yO1rnUaI4wrOeQ(+&k?2mi47hzxSD`N#-byqd1IhEoh!PGq>t z_MRy{5B0eKY>;Ao3z$RUU7U+i?iX^&r739F)itdrTpAi-NN0=?^m%?{A9Ly2pVv>Lqs6moTP?T2-AHqFD-o_ znVr|7OAS#AEH}h8SRPQ@NGG47dO}l=t07__+iK8nHw^(AHx&Wb<%jPc$$jl6_p(b$ z)!pi(0fQodCHfM)KMEMUR&UID>}m^(!{C^U7sBDOA)$VThRCI0_+2=( zV8mMq0R(#z;C|7$m>$>`tX+T|xGt(+Y48@ZYu#z;0pCgYgmMVbFb!$?%yhZqP_nhn zy4<#3P1oQ#2b51NU1mGnHP$cf0j-YOgAA}A$QoL6JVLcmExs(kU{4z;PBHJD%_=0F z>+sQV`mzijSIT7xn%PiDKHOujX;n|M&qr1T@rOxTdxtZ!&u&3HHFLYD5$RLQ=heur zb>+AFokUVQeJy-#LP*^)spt{mb@Mqe=A~-4p0b+Bt|pZ+@CY+%x}9f}izU5;4&QFE zO1bhg&A4uC1)Zb67kuowWY4xbo&J=%yoXlFB)&$d*-}kjBu|w!^zbD1YPc0-#XTJr z)pm2RDy%J3jlqSMq|o%xGS$bPwn4AqitC6&e?pqWcjWPt{3I{>CBy;hg0Umh#c;hU3RhCUX=8aR>rmd` z7Orw(5tcM{|-^J?ZAA9KP|)X6n9$-kvr#j5YDecTM6n z&07(nD^qb8hpF0B^z^pQ*%5ePYkv&FabrlI61ntiVp!!C8y^}|<2xgAd#FY=8b*y( zuQOuvy2`Ii^`VBNJB&R!0{hABYX55ooCAJSSevl4RPqEGb)iy_0H}v@vFwFzD%>#I>)3PsouQ+_Kkbqy*kKdHdfkN7NBcq%V{x^fSxgXpg7$bF& zj!6AQbDY(1u#1_A#1UO9AxiZaCVN2F0wGXdY*g@x$ByvUA?ePdide0dmr#}udE%K| z3*k}Vv2Ew2u1FXBaVA6aerI36R&rzEZeDDCl5!t0J=ug6kuNZzH>3i_VN`%BsaVB3 zQYw|Xub_SGf{)F{$ZX5`Jc!X!;eybjP+o$I{Z^Hsj@D=E{MnnL+TbC@HEU2DjG{3-LDGIbq()U87x4eS;JXnSh;lRlJ z>EL3D>wHt-+wTjQF$fGyDO$>d+(fq@bPpLBS~xA~R=3JPbS{tzN(u~m#Po!?H;IYv zE;?8%^vle|%#oux(Lj!YzBKv+Fd}*Ur-dCBoX*t{KeNM*n~ZPYJ4NNKkI^MFbz9!v z4(Bvm*Kc!-$%VFEewYJKz-CQN{`2}KX4*CeJEs+Q(!kI%hN1!1P6iOq?ovz}X0IOi z)YfWpwW@pK08^69#wSyCZkX9?uZD?C^@rw^Y?gLS_xmFKkooyx$*^5#cPqntNTtSG zlP>XLMj2!VF^0k#ole7`-c~*~+_T5ls?x4)ah(j8vo_ zwb%S8qoaZqY0-$ZI+ViIA_1~~rAH7K_+yFS{0rT@eQtTAdz#8E5VpwnW!zJ_^{Utv zlW5Iar3V5t&H4D6A=>?mq;G92;1cg9a2sf;gY9pJDVKn$DYdQlvfXq}zz8#LyPGq@ z+`YUMD;^-6w&r-82JL7mA8&M~Pj@aK!m{0+^v<|t%APYf7`}jGEhdYLqsHW-Le9TL z_hZZ1gbrz7$f9^fAzVIP30^KIz!!#+DRLL+qMszvI_BpOSmjtl$hh;&UeM{ER@INV zcI}VbiVTPoN|iSna@=7XkP&-4#06C};8ajbxJ4Gcq8(vWv4*&X8bM^T$mBk75Q92j z1v&%a;OSKc8EIrodmIiw$lOES2hzGDcjjB`kEDfJe{r}yE6`eZL zEB`9u>Cl0IsQ+t}`-cx}{6jqcANucqIB>Qmga_&<+80E2Q|VHHQ$YlAt{6`Qu`HA3 z03s0-sSlwbvgi&_R8s={6<~M^pGvBNjKOa>tWenzS8s zR>L7R5aZ=mSU{f?ib4Grx$AeFvtO5N|D>9#)ChH#Fny2maHWHOf2G=#<9Myot#+4u zWVa6d^Vseq_0=#AYS(-m$Lp;*8nC_6jXIjEM`omUmtH@QDs3|G)i4j*#_?#UYVZvJ z?YjT-?!4Q{BNun;dKBWLEw2C-VeAz`%?A>p;)PL}TAZn5j~HK>v1W&anteARlE+~+ zj>c(F;?qO3pXBb|#OZdQnm<4xWmn~;DR5SDMxt0UK_F^&eD|KZ=O;tO3vy4@4h^;2 zUL~-z`-P1aOe?|ZC1BgVsL)2^J-&vIFI%q@40w0{jjEfeVl)i9(~bt2z#2Vm)p`V_ z1;6$Ae7=YXk#=Qkd24Y23t&GvRxaOoad~NbJ+6pxqzJ>FY#Td7@`N5xp!n(c!=RE& z&<<@^a$_Ys8jqz4|5Nk#FY$~|FPC0`*a5HH!|Gssa9=~66&xG9)|=pOOJ2KE5|YrR zw!w6K2aC=J$t?L-;}5hn6mHd%hC;p8P|Dgh6D>hGnXPgi;6r+eA=?f72y9(Cf_ho{ zH6#)uD&R=73^$$NE;5piWX2bzR67fQ)`b=85o0eOLGI4c-Tb@-KNi2pz=Ke@SDcPn za$AxXib84`!Sf;Z3B@TSo`Dz7GM5Kf(@PR>Ghzi=BBxK8wRp>YQoXm+iL>H*Jo9M3 z6w&E?BC8AFTFT&Tv8zf+m9<&S&%dIaZ)Aoqkak_$r-2{$d~0g2oLETx9Y`eOAf14QXEQw3tJne;fdzl@wV#TFXSLXM2428F-Q}t+n2g%vPRMUzYPvzQ9f# zu(liiJem9P*?0%V@RwA7F53r~|I!Ty)<*AsMX3J{_4&}{6pT%Tpw>)^|DJ)>gpS~1rNEh z0$D?uO8mG?H;2BwM5a*26^7YO$XjUm40XmBsb63MoR;bJh63J;OngS5sSI+o2HA;W zdZV#8pDpC9Oez&L8loZO)MClRz!_!WD&QRtQxnazhT%Vj6Wl4G11nUk8*vSeVab@N#oJ}`KyJv+8Mo@T1-pqZ1t|?cnaVOd;1(h9 z!$DrN=jcGsVYE-0-n?oCJ^4x)F}E;UaD-LZUIzcD?W^ficqJWM%QLy6QikrM1aKZC zi{?;oKwq^Vsr|&`i{jIphA8S6G4)$KGvpULjH%9u(Dq247;R#l&I0{IhcC|oBF*Al zvLo7Xte=C{aIt*otJD}BUq)|_pdR>{zBMT< z(^1RpZv*l*m*OV^8>9&asGBo8h*_4q*)-eCv*|Pq=XNGrZE)^(SF7^{QE_~4VDB(o zVcPA_!G+2CAtLbl+`=Q~9iW`4ZRLku!uB?;tWqVjB0lEOf}2RD7dJ=BExy=<9wkb- z9&7{XFA%n#JsHYN8t5d~=T~5DcW4$B%3M+nNvC2`0!#@sckqlzo5;hhGi(D9=*A4` z5ynobawSPRtWn&CDLEs3Xf`(8^zDP=NdF~F^s&={l7(aw&EG}KWpMjtmz7j_VLO;@ zM2NVLDxZ@GIv7*gzl1 zjq78tv*8#WSY`}Su0&C;2F$Ze(q>F(@Wm^Gw!)(j;dk9Ad{STaxn)IV9FZhm*n+U} zi;4y*3v%A`_c7a__DJ8D1b@dl0Std3F||4Wtvi)fCcBRh!X9$1x!_VzUh>*S5s!oq z;qd{J_r79EL2wIeiGAqFstWtkfIJpjVh%zFo*=55B9Zq~y0=^iqHWfQl@O!Ak;(o*m!pZqe9 z%U2oDOhR)BvW8&F70L;2TpkzIutIvNQaTjjs5V#8mV4!NQ}zN=i`i@WI1z0eN-iCS z;vL-Wxc^Vc_qK<5RPh(}*8dLT{~GzE{w2o$2kMFaEl&q zP{V=>&3kW7tWaK-Exy{~`v4J0U#OZBk{a9{&)&QG18L@6=bsZ1zC_d{{pKZ-Ey>I> z;8H0t4bwyQqgu4hmO`3|4K{R*5>qnQ&gOfdy?z`XD%e5+pTDzUt3`k^u~SaL&XMe= z9*h#kT(*Q9jO#w2Hd|Mr-%DV8i_1{J1MU~XJ3!WUplhXDYBpJH><0OU`**nIvPIof z|N8@I=wA)sf45SAvx||f?Z5uB$kz1qL3Ky_{%RPdP5iN-D2!p5scq}buuC00C@jom zhfGKm3|f?Z0iQ|K$Z~!`8{nmAS1r+fp6r#YDOS8V*;K&Gs7Lc&f^$RC66O|)28oh`NHy&vq zJh+hAw8+ybTB0@VhWN^0iiTnLsCWbS_y`^gs!LX!Lw{yE``!UVzrV24tP8o;I6-65 z1MUiHw^{bB15tmrVT*7-#sj6cs~z`wk52YQJ*TG{SE;KTm#Hf#a~|<(|ImHH17nNM z`Ub{+J3dMD!)mzC8b(2tZtokKW5pAwHa?NFiso~# z1*iaNh4lQ4TS)|@G)H4dZV@l*Vd;Rw;-;odDhW2&lJ%m@jz+Panv7LQm~2Js6rOW3 z0_&2cW^b^MYW3)@o;neZ<{B4c#m48dAl$GCc=$>ErDe|?y@z`$uq3xd(%aAsX)D%l z>y*SQ%My`yDP*zof|3@_w#cjaW_YW4BdA;#Glg1RQcJGY*CJ9`H{@|D+*e~*457kd z73p<%fB^PV!Ybw@)Dr%(ZJbX}xmCStCYv#K3O32ej{$9IzM^I{6FJ8!(=azt7RWf4 z7ib0UOPqN40X!wOnFOoddd8`!_IN~9O)#HRTyjfc#&MCZ zZAMzOVB=;qwt8gV?{Y2?b=iSZG~RF~uyx18K)IDFLl})G1v@$(s{O4@RJ%OTJyF+Cpcx4jmy|F3euCnMK!P2WTDu5j z{{gD$=M*pH!GGzL%P)V2*ROm>!$Y=z|D`!_yY6e7SU$~a5q8?hZGgaYqaiLnkK%?0 zs#oI%;zOxF@g*@(V4p!$7dS1rOr6GVs6uYCTt2h)eB4?(&w8{#o)s#%gN@BBosRUe z)@P@8_Zm89pr~)b>e{tbPC~&_MR--iB{=)y;INU5#)@Gix-YpgP<-c2Ms{9zuCX|3 z!p(?VaXww&(w&uBHzoT%!A2=3HAP>SDxcljrego7rY|%hxy3XlODWffO_%g|l+7Y_ zqV(xbu)s4lV=l7M;f>vJl{`6qBm>#ZeMA}kXb97Z)?R97EkoI?x6Lp0yu1Z>PS?2{ z0QQ(8D)|lc9CO3B~e(pQM&5(1y&y=e>C^X$`)_&XuaI!IgDTVqt31wX#n+@!a_A0ZQkA zCJ2@M_4Gb5MfCrm5UPggeyh)8 zO9?`B0J#rkoCx(R0I!ko_2?iO@|oRf1;3r+i)w-2&j?=;NVIdPFsB)`|IC0zk6r9c zRrkfxWsiJ(#8QndNJj@{@WP2Ackr|r1VxV{7S&rSU(^)-M8gV>@UzOLXu9K<{6e{T zXJ6b92r$!|lwjhmgqkdswY&}c)KW4A)-ac%sU;2^fvq7gfUW4Bw$b!i@duy1CAxSn z(pyh$^Z=&O-q<{bZUP+$U}=*#M9uVc>CQVgDs4swy5&8RAHZ~$)hrTF4W zPsSa~qYv_0mJnF89RnnJTH`3}w4?~epFl=D(35$ zWa07ON$`OMBOHgCmfO(9RFc<)?$x)N}Jd2A(<*Ll7+4jrRt9w zwGxExUXd9VB#I|DwfxvJ;HZ8Q{37^wDhaZ%O!oO(HpcqfLH%#a#!~;Jl7F5>EX_=8 z{()l2NqPz>La3qJR;_v+wlK>GsHl;uRA8%j`A|yH@k5r%55S9{*Cp%uw6t`qc1!*T za2OeqtQj7sAp#Q~=5Fs&aCR9v>5V+s&RdNvo&H~6FJOjvaj--2sYYBvMq;55%z8^o z|BJDA4vzfow#DO#ZQHh;Oq_{r+qP{R9ox2TOgwQiv7Ow!zjN+A@BN;0tA2lUb#+zO z(^b89eV)D7UVE+h{mcNc6&GtpOqDn_?VAQ)Vob$hlFwW%xh>D#wml{t&Ofmm_d_+; zKDxzdr}`n2Rw`DtyIjrG)eD0vut$}dJAZ0AohZ+ZQdWXn_Z@dI_y=7t3q8x#pDI-K z2VVc&EGq445Rq-j0=U=Zx`oBaBjsefY;%)Co>J3v4l8V(T8H?49_@;K6q#r~Wwppc z4XW0(4k}cP=5ex>-Xt3oATZ~bBWKv)aw|I|Lx=9C1s~&b77idz({&q3T(Y(KbWO?+ zmcZ6?WeUsGk6>km*~234YC+2e6Zxdl~<_g2J|IE`GH%n<%PRv-50; zH{tnVts*S5*_RxFT9eM0z-pksIb^drUq4>QSww=u;UFCv2AhOuXE*V4z?MM`|ABOC4P;OfhS(M{1|c%QZ=!%rQTDFx`+}?Kdx$&FU?Y<$x;j7z=(;Lyz+?EE>ov!8vvMtSzG!nMie zsBa9t8as#2nH}n8xzN%W%U$#MHNXmDUVr@GX{?(=yI=4vks|V)!-W5jHsU|h_&+kY zS_8^kd3jlYqOoiI`ZqBVY!(UfnAGny!FowZWY_@YR0z!nG7m{{)4OS$q&YDyw6vC$ zm4!$h>*|!2LbMbxS+VM6&DIrL*X4DeMO!@#EzMVfr)e4Tagn~AQHIU8?e61TuhcKD zr!F4(kEebk(Wdk-?4oXM(rJwanS>Jc%<>R(siF+>+5*CqJLecP_we33iTFTXr6W^G z7M?LPC-qFHK;E!fxCP)`8rkxZyFk{EV;G-|kwf4b$c1k0atD?85+|4V%YATWMG|?K zLyLrws36p%Qz6{}>7b>)$pe>mR+=IWuGrX{3ZPZXF3plvuv5Huax86}KX*lbPVr}L z{C#lDjdDeHr~?l|)Vp_}T|%$qF&q#U;ClHEPVuS+Jg~NjC1RP=17=aQKGOcJ6B3mp z8?4*-fAD~}sX*=E6!}^u8)+m2j<&FSW%pYr_d|p_{28DZ#Cz0@NF=gC-o$MY?8Ca8 zr5Y8DSR^*urS~rhpX^05r30Ik#2>*dIOGxRm0#0YX@YQ%Mg5b6dXlS!4{7O_kdaW8PFSdj1=ryI-=5$fiieGK{LZ+SX(1b=MNL!q#lN zv98?fqqTUH8r8C7v(cx#BQ5P9W>- zmW93;eH6T`vuJ~rqtIBg%A6>q>gnWb3X!r0wh_q;211+Om&?nvYzL1hhtjB zK_7G3!n7PL>d!kj){HQE zE8(%J%dWLh1_k%gVXTZt zEdT09XSKAx27Ncaq|(vzL3gm83q>6CAw<$fTnMU05*xAe&rDfCiu`u^1)CD<>sx0i z*hr^N_TeN89G(nunZoLBf^81#pmM}>JgD@Nn1l*lN#a=B=9pN%tmvYFjFIoKe_(GF z-26x{(KXdfsQL7Uv6UtDuYwV`;8V3w>oT_I<`Ccz3QqK9tYT5ZQzbop{=I=!pMOCb zCU68`n?^DT%^&m>A%+-~#lvF!7`L7a{z<3JqIlk1$<||_J}vW1U9Y&eX<}l8##6i( zZcTT@2`9(Mecptm@{3A_Y(X`w9K0EwtPq~O!16bq{7c0f7#(3wn-^)h zxV&M~iiF!{-6A@>o;$RzQ5A50kxXYj!tcgme=Qjrbje~;5X2xryU;vH|6bE(8z^<7 zQ>BG7_c*JG8~K7Oe68i#0~C$v?-t@~@r3t2inUnLT(c=URpA9kA8uq9PKU(Ps(LVH zqgcqW>Gm?6oV#AldDPKVRcEyQIdTT`Qa1j~vS{<;SwyTdr&3*t?J)y=M7q*CzucZ&B0M=joT zBbj@*SY;o2^_h*>R0e({!QHF0=)0hOj^B^d*m>SnRrwq>MolNSgl^~r8GR#mDWGYEIJA8B<|{{j?-7p zVnV$zancW3&JVDtVpIlI|5djKq0(w$KxEFzEiiL=h5Jw~4Le23@s(mYyXWL9SX6Ot zmb)sZaly_P%BeX_9 zw&{yBef8tFm+%=--m*J|o~+Xg3N+$IH)t)=fqD+|fEk4AAZ&!wcN5=mi~Vvo^i`}> z#_3ahR}Ju)(Px7kev#JGcSwPXJ2id9%Qd2A#Uc@t8~egZ8;iC{e! z%=CGJOD1}j!HW_sgbi_8suYnn4#Ou}%9u)dXd3huFIb!ytlX>Denx@pCS-Nj$`VO&j@(z!kKSP0hE4;YIP#w9ta=3DO$7f*x zc9M4&NK%IrVmZAe=r@skWD`AEWH=g+r|*13Ss$+{c_R!b?>?UaGXlw*8qDmY#xlR= z<0XFbs2t?8i^G~m?b|!Hal^ZjRjt<@a? z%({Gn14b4-a|#uY^=@iiKH+k?~~wTj5K1A&hU z2^9-HTC)7zpoWK|$JXaBL6C z#qSNYtY>65T@Zs&-0cHeu|RX(Pxz6vTITdzJdYippF zC-EB+n4}#lM7`2Ry~SO>FxhKboIAF#Z{1wqxaCb{#yEFhLuX;Rx(Lz%T`Xo1+a2M}7D+@wol2)OJs$TwtRNJ={( zD@#zTUEE}#Fz#&(EoD|SV#bayvr&E0vzmb%H?o~46|FAcx?r4$N z&67W3mdip-T1RIxwSm_&(%U|+WvtGBj*}t69XVd&ebn>KOuL(7Y8cV?THd-(+9>G7*Nt%T zcH;`p={`SOjaf7hNd(=37Lz3-51;58JffzIPgGs_7xIOsB5p2t&@v1mKS$2D$*GQ6 zM(IR*j4{nri7NMK9xlDy-hJW6sW|ZiDRaFiayj%;(%51DN!ZCCCXz+0Vm#};70nOx zJ#yA0P3p^1DED;jGdPbQWo0WATN=&2(QybbVdhd=Vq*liDk`c7iZ?*AKEYC#SY&2g z&Q(Ci)MJ{mEat$ZdSwTjf6h~roanYh2?9j$CF@4hjj_f35kTKuGHvIs9}Re@iKMxS-OI*`0S z6s)fOtz}O$T?PLFVSeOjSO26$@u`e<>k(OSP!&YstH3ANh>)mzmKGNOwOawq-MPXe zy4xbeUAl6tamnx))-`Gi2uV5>9n(73yS)Ukma4*7fI8PaEwa)dWHs6QA6>$}7?(L8 ztN8M}?{Tf!Zu22J5?2@95&rQ|F7=FK-hihT-vDp!5JCcWrVogEnp;CHenAZ)+E+K5 z$Cffk5sNwD_?4+ymgcHR(5xgt20Z8M`2*;MzOM#>yhk{r3x=EyM226wb&!+j`W<%* zSc&|`8!>dn9D@!pYow~(DsY_naSx7(Z4i>cu#hA5=;IuI88}7f%)bRkuY2B;+9Uep zpXcvFWkJ!mQai63BgNXG26$5kyhZ2&*3Q_tk)Ii4M>@p~_~q_cE!|^A;_MHB;7s#9 zKzMzK{lIxotjc};k67^Xsl-gS!^*m*m6kn|sbdun`O?dUkJ{0cmI0-_2y=lTAfn*Y zKg*A-2sJq)CCJgY0LF-VQvl&6HIXZyxo2#!O&6fOhbHXC?%1cMc6y^*dOS{f$=137Ds1m01qs`>iUQ49JijsaQ( zksqV9@&?il$|4Ua%4!O15>Zy&%gBY&wgqB>XA3!EldQ%1CRSM(pp#k~-pkcCg4LAT zXE=puHbgsw)!xtc@P4r~Z}nTF=D2~j(6D%gTBw$(`Fc=OOQ0kiW$_RDd=hcO0t97h zb86S5r=>(@VGy1&#S$Kg_H@7G^;8Ue)X5Y+IWUi`o;mpvoV)`fcVk4FpcT|;EG!;? zHG^zrVVZOm>1KFaHlaogcWj(v!S)O(Aa|Vo?S|P z5|6b{qkH(USa*Z7-y_Uvty_Z1|B{rTS^qmEMLEYUSk03_Fg&!O3BMo{b^*`3SHvl0 zhnLTe^_vVIdcSHe)SQE}r~2dq)VZJ!aSKR?RS<(9lzkYo&dQ?mubnWmgMM37Nudwo z3Vz@R{=m2gENUE3V4NbIzAA$H1z0pagz94-PTJyX{b$yndsdKptmlKQKaaHj@3=ED zc7L?p@%ui|RegVYutK$64q4pe9+5sv34QUpo)u{1ci?)_7gXQd{PL>b0l(LI#rJmN zGuO+%GO`xneFOOr4EU(Wg}_%bhzUf;d@TU+V*2#}!2OLwg~%D;1FAu=Un>OgjPb3S z7l(riiCwgghC=Lm5hWGf5NdGp#01xQ59`HJcLXbUR3&n%P(+W2q$h2Qd z*6+-QXJ*&Kvk9ht0f0*rO_|FMBALen{j7T1l%=Q>gf#kma zQlg#I9+HB+z*5BMxdesMND`_W;q5|FaEURFk|~&{@qY32N$G$2B=&Po{=!)x5b!#n zxLzblkq{yj05#O7(GRuT39(06FJlalyv<#K4m}+vs>9@q-&31@1(QBv82{}Zkns~K ze{eHC_RDX0#^A*JQTwF`a=IkE6Ze@j#-8Q`tTT?k9`^ZhA~3eCZJ-Jr{~7Cx;H4A3 zcZ+Zj{mzFZbVvQ6U~n>$U2ZotGsERZ@}VKrgGh0xM;Jzt29%TX6_&CWzg+YYMozrM z`nutuS)_0dCM8UVaKRj804J4i%z2BA_8A4OJRQ$N(P9Mfn-gF;4#q788C@9XR0O3< zsoS4wIoyt046d+LnSCJOy@B@Uz*#GGd#+Ln1ek5Dv>(ZtD@tgZlPnZZJGBLr^JK+!$$?A_fA3LOrkoDRH&l7 zcMcD$Hsjko3`-{bn)jPL6E9Ds{WskMrivsUu5apD z?grQO@W7i5+%X&E&p|RBaEZ(sGLR@~(y^BI@lDMot^Ll?!`90KT!JXUhYS`ZgX3jnu@Ja^seA*M5R@f`=`ynQV4rc$uT1mvE?@tz)TN<=&H1%Z?5yjxcpO+6y_R z6EPuPKM5uxKpmZfT(WKjRRNHs@ib)F5WAP7QCADvmCSD#hPz$V10wiD&{NXyEwx5S z6NE`3z!IS^$s7m}PCwQutVQ#~w+V z=+~->DI*bR2j0^@dMr9`p>q^Ny~NrAVxrJtX2DUveic5vM%#N*XO|?YAWwNI$Q)_) zvE|L(L1jP@F%gOGtnlXtIv2&1i8q<)Xfz8O3G^Ea~e*HJsQgBxWL(yuLY+jqUK zRE~`-zklrGog(X}$9@ZVUw!8*=l`6mzYLtsg`AvBYz(cxmAhr^j0~(rzXdiOEeu_p zE$sf2(w(BPAvO5DlaN&uQ$4@p-b?fRs}d7&2UQ4Fh?1Hzu*YVjcndqJLw0#q@fR4u zJCJ}>_7-|QbvOfylj+e^_L`5Ep9gqd>XI3-O?Wp z-gt*P29f$Tx(mtS`0d05nHH=gm~Po_^OxxUwV294BDKT>PHVlC5bndncxGR!n(OOm znsNt@Q&N{TLrmsoKFw0&_M9$&+C24`sIXGWgQaz=kY;S{?w`z^Q0JXXBKFLj0w0U6P*+jPKyZHX9F#b0D1$&(- zrm8PJd?+SrVf^JlfTM^qGDK&-p2Kdfg?f>^%>1n8bu&byH(huaocL>l@f%c*QkX2i znl}VZ4R1en4S&Bcqw?$=Zi7ohqB$Jw9x`aM#>pHc0x z0$!q7iFu zZ`tryM70qBI6JWWTF9EjgG@>6SRzsd}3h+4D8d~@CR07P$LJ}MFsYi-*O%XVvD@yT|rJ+Mk zDllJ7$n0V&A!0flbOf)HE6P_afPWZmbhpliqJuw=-h+r;WGk|ntkWN(8tKlYpq5Ow z(@%s>IN8nHRaYb*^d;M(D$zGCv5C|uqmsDjwy4g=Lz>*OhO3z=)VD}C<65;`89Ye} zSCxrv#ILzIpEx1KdLPlM&%Cctf@FqTKvNPXC&`*H9=l=D3r!GLM?UV zOxa(8ZsB`&+76S-_xuj?G#wXBfDY@Z_tMpXJS7^mp z@YX&u0jYw2A+Z+bD#6sgVK5ZgdPSJV3>{K^4~%HV?rn~4D)*2H!67Y>0aOmzup`{D zzDp3c9yEbGCY$U<8biJ_gB*`jluz1ShUd!QUIQJ$*1;MXCMApJ^m*Fiv88RZ zFopLViw}{$Tyhh_{MLGIE2~sZ)t0VvoW%=8qKZ>h=adTe3QM$&$PO2lfqH@brt!9j ziePM8$!CgE9iz6B<6_wyTQj?qYa;eC^{x_0wuwV~W+^fZmFco-o%wsKSnjXFEx02V zF5C2t)T6Gw$Kf^_c;Ei3G~uC8SM-xyycmXyC2hAVi-IfXqhu$$-C=*|X?R0~hu z8`J6TdgflslhrmDZq1f?GXF7*ALeMmOEpRDg(s*H`4>_NAr`2uqF;k;JQ+8>A|_6ZNsNLECC%NNEb1Y1dP zbIEmNpK)#XagtL4R6BC{C5T(+=yA-(Z|Ap}U-AfZM#gwVpus3(gPn}Q$CExObJ5AC z)ff9Yk?wZ}dZ-^)?cbb9Fw#EjqQ8jxF4G3=L?Ra zg_)0QDMV1y^A^>HRI$x?Op@t;oj&H@1xt4SZ9(kifQ zb59B*`M99Td7@aZ3UWvj1rD0sE)d=BsBuW*KwkCds7ay(7*01_+L}b~7)VHI>F_!{ zyxg-&nCO?v#KOUec0{OOKy+sjWA;8rTE|Lv6I9H?CI?H(mUm8VXGwU$49LGpz&{nQp2}dinE1@lZ1iox6{ghN&v^GZv9J${7WaXj)<0S4g_uiJ&JCZ zr8-hsu`U%N;+9N^@&Q0^kVPB3)wY(rr}p7{p0qFHb3NUUHJb672+wRZs`gd1UjKPX z4o6zljKKA+Kkj?H>Ew63o%QjyBk&1!P22;MkD>sM0=z_s-G{mTixJCT9@_|*(p^bz zJ8?ZZ&;pzV+7#6Mn`_U-)k8Pjg?a;|Oe^us^PoPY$Va~yi8|?+&=y$f+lABT<*pZr zP}D{~Pq1Qyni+@|aP;ixO~mbEW9#c0OU#YbDZIaw=_&$K%Ep2f%hO^&P67hApZe`x zv8b`Mz@?M_7-)b!lkQKk)JXXUuT|B8kJlvqRmRpxtQDgvrHMXC1B$M@Y%Me!BSx3P z#2Eawl$HleZhhTS6Txm>lN_+I`>eV$&v9fOg)%zVn3O5mI*lAl>QcHuW6!Kixmq`X zBCZ*Ck6OYtDiK!N47>jxI&O2a9x7M|i^IagRr-fmrmikEQGgw%J7bO|)*$2FW95O4 zeBs>KR)izRG1gRVL;F*sr8A}aRHO0gc$$j&ds8CIO1=Gwq1%_~E)CWNn9pCtBE}+`Jelk4{>S)M)`Ll=!~gnn1yq^EX(+y*ik@3Ou0qU`IgYi3*doM+5&dU!cho$pZ zn%lhKeZkS72P?Cf68<#kll_6OAO26bIbueZx**j6o;I0cS^XiL`y+>{cD}gd%lux} z)3N>MaE24WBZ}s0ApfdM;5J_Ny}rfUyxfkC``Awo2#sgLnGPewK};dORuT?@I6(5~ z?kE)Qh$L&fwJXzK){iYx!l5$Tt|^D~MkGZPA}(o6f7w~O2G6Vvzdo*a;iXzk$B66$ zwF#;wM7A+(;uFG4+UAY(2`*3XXx|V$K8AYu#ECJYSl@S=uZW$ksfC$~qrrbQj4??z-)uz0QL}>k^?fPnJTPw% zGz)~?B4}u0CzOf@l^um}HZzbaIwPmb<)< zi_3@E9lc)Qe2_`*Z^HH;1CXOceL=CHpHS{HySy3T%<^NrWQ}G0i4e1xm_K3(+~oi$ zoHl9wzb?Z4j#90DtURtjtgvi7uw8DzHYmtPb;?%8vb9n@bszT=1qr)V_>R%s!92_` zfnHQPANx z<#hIjIMm#*(v*!OXtF+w8kLu`o?VZ5k7{`vw{Yc^qYclpUGIM_PBN1+c{#Vxv&E*@ zxg=W2W~JuV{IuRYw3>LSI1)a!thID@R=bU+cU@DbR^_SXY`MC7HOsCN z!dO4OKV7(E_Z8T#8MA1H`99?Z!r0)qKW_#|29X3#Jb+5+>qUidbeP1NJ@)(qi2S-X zao|f0_tl(O+$R|Qwd$H{_ig|~I1fbp_$NkI!0E;Y z6JrnU{1Ra6^on{9gUUB0mwzP3S%B#h0fjo>JvV~#+X0P~JV=IG=yHG$O+p5O3NUgG zEQ}z6BTp^Fie)Sg<){Z&I8NwPR(=mO4joTLHkJ>|Tnk23E(Bo`FSbPc05lF2-+)X? z6vV3*m~IBHTy*^E!<0nA(tCOJW2G4DsH7)BxLV8kICn5lu6@U*R`w)o9;Ro$i8=Q^V%uH8n3q=+Yf;SFRZu z!+F&PKcH#8cG?aSK_Tl@K9P#8o+jry@gdexz&d(Q=47<7nw@e@FFfIRNL9^)1i@;A z28+$Z#rjv-wj#heI|<&J_DiJ*s}xd-f!{J8jfqOHE`TiHHZVIA8CjkNQ_u;Ery^^t zl1I75&u^`1_q)crO+JT4rx|z2ToSC>)Or@-D zy3S>jW*sNIZR-EBsfyaJ+Jq4BQE4?SePtD2+jY8*%FsSLZ9MY>+wk?}}}AFAw)vr{ml)8LUG-y9>^t!{~|sgpxYc0Gnkg`&~R z-pilJZjr@y5$>B=VMdZ73svct%##v%wdX~9fz6i3Q-zOKJ9wso+h?VME7}SjL=!NUG{J?M&i!>ma`eoEa@IX`5G>B1(7;%}M*%-# zfhJ(W{y;>MRz!Ic8=S}VaBKqh;~7KdnGEHxcL$kA-6E~=!hrN*zw9N+_=odt<$_H_8dbo;0=42wcAETPCVGUr~v(`Uai zb{=D!Qc!dOEU6v)2eHSZq%5iqK?B(JlCq%T6av$Cb4Rko6onlG&?CqaX7Y_C_cOC3 zYZ;_oI(}=>_07}Oep&Ws7x7-R)cc8zfe!SYxJYP``pi$FDS)4Fvw5HH=FiU6xfVqIM!hJ;Rx8c0cB7~aPtNH(Nmm5Vh{ibAoU#J6 zImRCr?(iyu_4W_6AWo3*vxTPUw@vPwy@E0`(>1Qi=%>5eSIrp^`` zK*Y?fK_6F1W>-7UsB)RPC4>>Ps9)f+^MqM}8AUm@tZ->j%&h1M8s*s!LX5&WxQcAh z8mciQej@RPm?660%>{_D+7er>%zX_{s|$Z+;G7_sfNfBgY(zLB4Ey}J9F>zX#K0f6 z?dVNIeEh?EIShmP6>M+d|0wMM85Sa4diw1hrg|ITJ}JDg@o8y>(rF9mXk5M z2@D|NA)-7>wD&wF;S_$KS=eE84`BGw3g0?6wGxu8ys4rwI?9U=*^VF22t3%mbGeOh z`!O-OpF7#Vceu~F`${bW0nYVU9ecmk31V{tF%iv&5hWofC>I~cqAt@u6|R+|HLMMX zVxuSlMFOK_EQ86#E8&KwxIr8S9tj_goWtLv4f@!&h8;Ov41{J~496vp9vX=(LK#j! zAwi*21RAV-LD>9Cw3bV_9X(X3)Kr0-UaB*7Y>t82EQ%!)(&(XuAYtTsYy-dz+w=$ir)VJpe!_$ z6SGpX^i(af3{o=VlFPC);|J8#(=_8#vdxDe|Cok+ANhYwbE*FO`Su2m1~w+&9<_9~ z-|tTU_ACGN`~CNW5WYYBn^B#SwZ(t4%3aPp z;o)|L6Rk569KGxFLUPx@!6OOa+5OjQLK5w&nAmwxkC5rZ|m&HT8G%GVZxB_@ME z>>{rnXUqyiJrT(8GMj_ap#yN_!9-lO5e8mR3cJiK3NE{_UM&=*vIU`YkiL$1%kf+1 z4=jk@7EEj`u(jy$HnzE33ZVW_J4bj}K;vT?T91YlO(|Y0FU4r+VdbmQ97%(J5 zkK*Bed8+C}FcZ@HIgdCMioV%A<*4pw_n}l*{Cr4}a(lq|injK#O?$tyvyE`S%(1`H z_wwRvk#13ElkZvij2MFGOj`fhy?nC^8`Zyo%yVcUAfEr8x&J#A{|moUBAV_^f$hpaUuyQeY3da^ zS9iRgf87YBwfe}>BO+T&Fl%rfpZh#+AM?Dq-k$Bq`vG6G_b4z%Kbd&v>qFjow*mBl z-OylnqOpLg}or7_VNwRg2za3VBK6FUfFX{|TD z`Wt0Vm2H$vdlRWYQJqDmM?JUbVqL*ZQY|5&sY*?!&%P8qhA~5+Af<{MaGo(dl&C5t zE%t!J0 zh6jqANt4ABdPxSTrVV}fLsRQal*)l&_*rFq(Ez}ClEH6LHv{J#v?+H-BZ2)Wy{K@9 z+ovXHq~DiDvm>O~r$LJo!cOuwL+Oa--6;UFE2q@g3N8Qkw5E>ytz^(&($!O47+i~$ zKM+tkAd-RbmP{s_rh+ugTD;lriL~`Xwkad#;_aM?nQ7L_muEFI}U_4$phjvYgleK~`Fo`;GiC07&Hq1F<%p;9Q;tv5b?*QnR%8DYJH3P>Svmv47Y>*LPZJy8_{9H`g6kQpyZU{oJ`m%&p~D=K#KpfoJ@ zn-3cqmHsdtN!f?~w+(t+I`*7GQA#EQC^lUA9(i6=i1PqSAc|ha91I%X&nXzjYaM{8$s&wEx@aVkQ6M{E2 zfzId#&r(XwUNtPcq4Ngze^+XaJA1EK-%&C9j>^9(secqe{}z>hR5CFNveMsVA)m#S zk)_%SidkY-XmMWlVnQ(mNJ>)ooszQ#vaK;!rPmGKXV7am^_F!Lz>;~{VrIO$;!#30XRhE1QqO_~#+Ux;B_D{Nk=grn z8Y0oR^4RqtcYM)7a%@B(XdbZCOqnX#fD{BQTeLvRHd(irHKq=4*jq34`6@VAQR8WG z^%)@5CXnD_T#f%@-l${>y$tfb>2LPmc{~5A82|16mH)R?&r#KKLs7xpN-D`=&Cm^R zvMA6#Ahr<3X>Q7|-qfTY)}32HkAz$_mibYV!I)u>bmjK`qwBe(>za^0Kt*HnFbSdO z1>+ryKCNxmm^)*$XfiDOF2|{-v3KKB?&!(S_Y=Ht@|ir^hLd978xuI&N{k>?(*f8H z=ClxVJK_%_z1TH0eUwm2J+2To7FK4o+n_na)&#VLn1m;!+CX+~WC+qg1?PA~KdOlC zW)C@pw75_xoe=w7i|r9KGIvQ$+3K?L{7TGHwrQM{dCp=Z*D}3kX7E-@sZnup!BImw z*T#a=+WcTwL78exTgBn|iNE3#EsOorO z*kt)gDzHiPt07fmisA2LWN?AymkdqTgr?=loT7z@d`wnlr6oN}@o|&JX!yPzC*Y8d zu6kWlTzE1)ckyBn+0Y^HMN+GA$wUO_LN6W>mxCo!0?oiQvT`z$jbSEu&{UHRU0E8# z%B^wOc@S!yhMT49Y)ww(Xta^8pmPCe@eI5C*ed96)AX9<>))nKx0(sci8gwob_1}4 z0DIL&vsJ1_s%<@y%U*-eX z5rN&(zef-5G~?@r79oZGW1d!WaTqQn0F6RIOa9tJ=0(kdd{d1{<*tHT#cCvl*i>YY zH+L7jq8xZNcTUBqj(S)ztTU!TM!RQ}In*n&Gn<>(60G7}4%WQL!o>hbJqNDSGwl#H z`4k+twp0cj%PsS+NKaxslAEu9!#U3xT1|_KB6`h=PI0SW`P9GTa7caD1}vKEglV8# zjKZR`pluCW19c2fM&ZG)c3T3Um;ir3y(tSCJ7Agl6|b524dy5El{^EQBG?E61H0XY z`bqg!;zhGhyMFl&(o=JWEJ8n~z)xI}A@C0d2hQGvw7nGv)?POU@(kS1m=%`|+^ika zXl8zjS?xqW$WlO?Ewa;vF~XbybHBor$f<%I&*t$F5fynwZlTGj|IjZtVfGa7l&tK} zW>I<69w(cZLu)QIVG|M2xzW@S+70NinQzk&Y0+3WT*cC)rx~04O-^<{JohU_&HL5XdUKW!uFy|i$FB|EMu0eUyW;gsf`XfIc!Z0V zeK&*hPL}f_cX=@iv>K%S5kL;cl_$v?n(Q9f_cChk8Lq$glT|=e+T*8O4H2n<=NGmn z+2*h+v;kBvF>}&0RDS>)B{1!_*XuE8A$Y=G8w^qGMtfudDBsD5>T5SB;Qo}fSkkiV ze^K^M(UthkwrD!&*tTsu>Dacdj_q`~V%r_twr$(Ct&_dKeeXE?fA&4&yASJWJ*}~- zel=@W)tusynfC_YqH4ll>4Eg`Xjs5F7Tj>tTLz<0N3)X<1px_d2yUY>X~y>>93*$) z5PuNMQLf9Bu?AAGO~a_|J2akO1M*@VYN^VxvP0F$2>;Zb9;d5Yfd8P%oFCCoZE$ z4#N$^J8rxYjUE_6{T%Y>MmWfHgScpuGv59#4u6fpTF%~KB^Ae`t1TD_^Ud#DhL+Dm zbY^VAM#MrAmFj{3-BpVSWph2b_Y6gCnCAombVa|1S@DU)2r9W<> zT5L8BB^er3zxKt1v(y&OYk!^aoQisqU zH(g@_o)D~BufUXcPt!Ydom)e|aW{XiMnes2z&rE?og>7|G+tp7&^;q?Qz5S5^yd$i z8lWr4g5nctBHtigX%0%XzIAB8U|T6&JsC4&^hZBw^*aIcuNO47de?|pGXJ4t}BB`L^d8tD`H`i zqrP8?#J@8T#;{^B!KO6J=@OWKhAerih(phML`(Rg7N1XWf1TN>=Z3Do{l_!d~DND&)O)D>ta20}@Lt77qSnVsA7>)uZAaT9bsB>u&aUQl+7GiY2|dAEg@%Al3i316y;&IhQL^8fw_nwS>f60M_-m+!5)S_6EPM7Y)(Nq^8gL7(3 zOiot`6Wy6%vw~a_H?1hLVzIT^i1;HedHgW9-P#)}Y6vF%C=P70X0Tk^z9Te@kPILI z_(gk!k+0%CG)%!WnBjjw*kAKs_lf#=5HXC00s-}oM-Q1aXYLj)(1d!_a7 z*Gg4Fe6F$*ujVjI|79Z5+Pr`us%zW@ln++2l+0hsngv<{mJ%?OfSo_3HJXOCys{Ug z00*YR-(fv<=&%Q!j%b-_ppA$JsTm^_L4x`$k{VpfLI(FMCap%LFAyq;#ns5bR7V+x zO!o;c5y~DyBPqdVQX)8G^G&jWkBy2|oWTw>)?5u}SAsI$RjT#)lTV&Rf8;>u*qXnb z8F%Xb=7#$m)83z%`E;49)t3fHInhtc#kx4wSLLms!*~Z$V?bTyUGiS&m>1P(952(H zuHdv=;o*{;5#X-uAyon`hP}d#U{uDlV?W?_5UjJvf%11hKwe&(&9_~{W)*y1nR5f_ z!N(R74nNK`y8>B!0Bt_Vr!;nc3W>~RiKtGSBkNlsR#-t^&;$W#)f9tTlZz>n*+Fjz z3zXZ;jf(sTM(oDzJt4FJS*8c&;PLTW(IQDFs_5QPy+7yhi1syPCarvqrHFcf&yTy)^O<1EBx;Ir`5W{TIM>{8w&PB>ro4;YD<5LF^TjTb0!zAP|QijA+1Vg>{Afv^% zmrkc4o6rvBI;Q8rj4*=AZacy*n8B{&G3VJc)so4$XUoie0)vr;qzPZVbb<#Fc=j+8CGBWe$n|3K& z_@%?{l|TzKSlUEO{U{{%Fz_pVDxs7i9H#bnbCw7@4DR=}r_qV!Zo~CvD4ZI*+j3kO zW6_=|S`)(*gM0Z;;}nj`73OigF4p6_NPZQ-Od~e$c_);;4-7sR>+2u$6m$Gf%T{aq zle>e3(*Rt(TPD}03n5)!Ca8Pu!V}m6v0o1;5<1h$*|7z|^(3$Y&;KHKTT}hV056wuF0Xo@mK-52~r=6^SI1NC%c~CC?n>yX6wPTgiWYVz!Sx^atLby9YNn1Rk{g?|pJaxD4|9cUf|V1_I*w zzxK)hRh9%zOl=*$?XUjly5z8?jPMy%vEN)f%T*|WO|bp5NWv@B(K3D6LMl!-6dQg0 zXNE&O>Oyf%K@`ngCvbGPR>HRg5!1IV$_}m@3dWB7x3t&KFyOJn9pxRXCAzFr&%37wXG;z^xaO$ekR=LJG ztIHpY8F5xBP{mtQidqNRoz= z@){+N3(VO5bD+VrmS^YjG@+JO{EOIW)9=F4v_$Ed8rZtHvjpiEp{r^c4F6Ic#ChlC zJX^DtSK+v(YdCW)^EFcs=XP7S>Y!4=xgmv>{S$~@h=xW-G4FF9?I@zYN$e5oF9g$# zb!eVU#J+NjLyX;yb)%SY)xJdvGhsnE*JEkuOVo^k5PyS=o#vq!KD46UTW_%R=Y&0G zFj6bV{`Y6)YoKgqnir2&+sl+i6foAn-**Zd1{_;Zb7Ki=u394C5J{l^H@XN`_6XTKY%X1AgQM6KycJ+= zYO=&t#5oSKB^pYhNdzPgH~aEGW2=ec1O#s-KG z71}LOg@4UEFtp3GY1PBemXpNs6UK-ax*)#$J^pC_me;Z$Je(OqLoh|ZrW*mAMBFn< zHttjwC&fkVfMnQeen8`Rvy^$pNRFVaiEN4Pih*Y3@jo!T0nsClN)pdrr9AYLcZxZ| zJ5Wlj+4q~($hbtuY zVQ7hl>4-+@6g1i`1a)rvtp-;b0>^`Dloy(#{z~ytgv=j4q^Kl}wD>K_Y!l~ zp(_&7sh`vfO(1*MO!B%<6E_bx1)&s+Ae`O)a|X=J9y~XDa@UB`m)`tSG4AUhoM=5& znWoHlA-(z@3n0=l{E)R-p8sB9XkV zZ#D8wietfHL?J5X0%&fGg@MH~(rNS2`GHS4xTo7L$>TPme+Is~!|79=^}QbPF>m%J zFMkGzSndiPO|E~hrhCeo@&Ea{M(ieIgRWMf)E}qeTxT8Q#g-!Lu*x$v8W^M^>?-g= zwMJ$dThI|~M06rG$Sv@C@tWR>_YgaG&!BAbkGggVQa#KdtDB)lMLNVLN|51C@F^y8 zCRvMB^{GO@j=cHfmy}_pCGbP%xb{pNN>? z?7tBz$1^zVaP|uaatYaIN+#xEN4jBzwZ|YI_)p(4CUAz1ZEbDk>J~Y|63SZaak~#0 zoYKruYsWHoOlC1(MhTnsdUOwQfz5p6-D0}4;DO$B;7#M{3lSE^jnTT;ns`>!G%i*F?@pR1JO{QTuD0U+~SlZxcc8~>IB{)@8p`P&+nDxNj`*gh|u?yrv$phpQcW)Us)bi`kT%qLj(fi{dWRZ%Es2!=3mI~UxiW0$-v3vUl?#g{p6eF zMEUAqo5-L0Ar(s{VlR9g=j7+lt!gP!UN2ICMokAZ5(Agd>})#gkA2w|5+<%-CuEP# zqgcM}u@3(QIC^Gx<2dbLj?cFSws_f3e%f4jeR?4M^M3cx1f+Qr6ydQ>n)kz1s##2w zk}UyQc+Z5G-d-1}{WzjkLXgS-2P7auWSJ%pSnD|Uivj5u!xk0 z_^-N9r9o;(rFDt~q1PvE#iJZ_f>J3gcP$)SOqhE~pD2|$=GvpL^d!r z6u=sp-CrMoF7;)}Zd7XO4XihC4ji?>V&(t^?@3Q&t9Mx=qex6C9d%{FE6dvU6%d94 zIE;hJ1J)cCqjv?F``7I*6bc#X)JW2b4f$L^>j{*$R`%5VHFi*+Q$2;nyieduE}qdS{L8y8F08yLs?w}{>8>$3236T-VMh@B zq-nujsb_1aUv_7g#)*rf9h%sFj*^mIcImRV*k~Vmw;%;YH(&ylYpy!&UjUVqqtfG` zox3esju?`unJJA_zKXRJP)rA3nXc$m^{S&-p|v|-0x9LHJm;XIww7C#R$?00l&Yyj z=e}gKUOpsImwW?N)+E(awoF@HyP^EhL+GlNB#k?R<2>95hz!h9sF@U20DHSB3~WMa zk90+858r@-+vWwkawJ)8ougd(i#1m3GLN{iSTylYz$brAsP%=&m$mQQrH$g%3-^VR zE%B`Vi&m8f3T~&myTEK28BDWCVzfWir1I?03;pX))|kY5ClO^+bae z*7E?g=3g7EiisYOrE+lA)2?Ln6q2*HLNpZEWMB|O-JI_oaHZB%CvYB(%=tU= zE*OY%QY58fW#RG5=gm0NR#iMB=EuNF@)%oZJ}nmm=tsJ?eGjia{e{yuU0l3{d^D@)kVDt=1PE)&tf_hHC%0MB znL|CRCPC}SeuVTdf>-QV70`0(EHizc21s^sU>y%hW0t!0&y<7}Wi-wGy>m%(-jsDj zP?mF|>p_K>liZ6ZP(w5(|9Ga%>tLgb$|doDDfkdW>Z z`)>V2XC?NJT26mL^@ zf+IKr27TfM!UbZ@?zRddC7#6ss1sw%CXJ4FWC+t3lHZupzM77m^=9 z&(a?-LxIq}*nvv)y?27lZ{j zifdl9hyJudyP2LpU$-kXctshbJDKS{WfulP5Dk~xU4Le4c#h^(YjJit4#R8_khheS z|8(>2ibaHES4+J|DBM7I#QF5u-*EdN{n=Kt@4Zt?@Tv{JZA{`4 zU#kYOv{#A&gGPwT+$Ud}AXlK3K7hYzo$(fBSFjrP{QQ zeaKg--L&jh$9N}`pu{Bs>?eDFPaWY4|9|foN%}i;3%;@4{dc+iw>m}{3rELqH21G! z`8@;w-zsJ1H(N3%|1B@#ioLOjib)j`EiJqPQVSbPSPVHCj6t5J&(NcWzBrzCiDt{4 zdlPAUKldz%6x5II1H_+jv)(xVL+a;P+-1hv_pM>gMRr%04@k;DTokASSKKhU1Qms| zrWh3a!b(J3n0>-tipg{a?UaKsP7?+|@A+1WPDiQIW1Sf@qDU~M_P65_s}7(gjTn0X zucyEm)o;f8UyshMy&>^SC3I|C6jR*R_GFwGranWZe*I>K+0k}pBuET&M~ z;Odo*ZcT?ZpduHyrf8E%IBFtv;JQ!N_m>!sV6ly$_1D{(&nO~w)G~Y`7sD3#hQk%^ zp}ucDF_$!6DAz*PM8yE(&~;%|=+h(Rn-=1Wykas_-@d&z#=S}rDf`4w(rVlcF&lF! z=1)M3YVz7orwk^BXhslJ8jR);sh^knJW(Qmm(QdSgIAIdlN4Te5KJisifjr?eB{FjAX1a0AB>d?qY4Wx>BZ8&}5K0fA+d{l8 z?^s&l8#j7pR&ijD?0b%;lL9l$P_mi2^*_OL+b}4kuLR$GAf85sOo02?Y#90}CCDiS zZ%rbCw>=H~CBO=C_JVV=xgDe%b4FaEFtuS7Q1##y686r%F6I)s-~2(}PWK|Z8M+Gu zl$y~5@#0Ka%$M<&Cv%L`a8X^@tY&T7<0|(6dNT=EsRe0%kp1Qyq!^43VAKYnr*A5~ zsI%lK1ewqO;0TpLrT9v}!@vJK{QoVa_+N4FYT#h?Y8rS1S&-G+m$FNMP?(8N`MZP zels(*?kK{{^g9DOzkuZXJ2;SrOQsp9T$hwRB1(phw1c7`!Q!by?Q#YsSM#I12RhU{$Q+{xj83axHcftEc$mNJ8_T7A-BQc*k(sZ+~NsO~xAA zxnbb%dam_fZlHvW7fKXrB~F&jS<4FD2FqY?VG?ix*r~MDXCE^WQ|W|WM;gsIA4lQP zJ2hAK@CF*3*VqPr2eeg6GzWFlICi8S>nO>5HvWzyZTE)hlkdC_>pBej*>o0EOHR|) z$?};&I4+_?wvL*g#PJ9)!bc#9BJu1(*RdNEn>#Oxta(VWeM40ola<0aOe2kSS~{^P zDJBd}0L-P#O-CzX*%+$#v;(x%<*SPgAje=F{Zh-@ucd2DA(yC|N_|ocs*|-!H%wEw z@Q!>siv2W;C^^j^59OAX03&}&D*W4EjCvfi(ygcL#~t8XGa#|NPO+*M@Y-)ctFA@I z-p7npT1#5zOLo>7q?aZpCZ=iecn3QYklP;gF0bq@>oyBq94f6C=;Csw3PkZ|5q=(c zfs`aw?II0e(h=|7o&T+hq&m$; zBrE09Twxd9BJ2P+QPN}*OdZ-JZV7%av@OM7v!!NL8R;%WFq*?{9T3{ct@2EKgc8h) zMxoM$SaF#p<`65BwIDfmXG6+OiK0e)`I=!A3E`+K@61f}0e z!2a*FOaDrOe>U`q%K!QN`&=&0C~)CaL3R4VY(NDt{Xz(Xpqru5=r#uQN1L$Je1*dkdqQ*=lofQaN%lO!<5z9ZlHgxt|`THd>2 zsWfU$9=p;yLyJyM^t zS2w9w?Bpto`@H^xJpZDKR1@~^30Il6oFGfk5%g6w*C+VM)+%R@gfIwNprOV5{F^M2 zO?n3DEzpT+EoSV-%OdvZvNF+pDd-ZVZ&d8 zKeIyrrfPN=EcFRCPEDCVflX#3-)Ik_HCkL(ejmY8vzcf-MTA{oHk!R2*36`O68$7J zf}zJC+bbQk--9Xm!u#lgLvx8TXx2J258E5^*IZ(FXMpq$2LUUvhWQPs((z1+2{Op% z?J}9k5^N=z;7ja~zi8a_-exIqWUBJwohe#4QJ`|FF*$C{lM18z^#hX6!5B8KAkLUX ziP=oti-gpV(BsLD{0(3*dw}4JxK23Y7M{BeFPucw!sHpY&l%Ws4pSm`+~V7;bZ%Dx zeI)MK=4vC&5#;2MT7fS?^ch9?2;%<8Jlu-IB&N~gg8t;6S-#C@!NU{`p7M8@2iGc& zg|JPg%@gCoCQ&s6JvDU&`X2S<57f(k8nJ1wvBu{8r?;q3_kpZZ${?|( z+^)UvR33sjSd)aT!UPkA;ylO6{aE3MQa{g%Mcf$1KONcjO@&g5zPHWtzM1rYC{_K> zgQNcs<{&X{OA=cEWw5JGqpr0O>x*Tfak2PE9?FuWtz^DDNI}rwAaT0(bdo-<+SJ6A z&}S%boGMWIS0L}=S>|-#kRX;e^sUsotry(MjE|3_9duvfc|nwF#NHuM-w7ZU!5ei8 z6Mkf>2)WunY2eU@C-Uj-A zG(z0Tz2YoBk>zCz_9-)4a>T46$(~kF+Y{#sA9MWH%5z#zNoz)sdXq7ZR_+`RZ%0(q zC7&GyS_|BGHNFl8Xa%@>iWh%Gr?=J5<(!OEjauj5jyrA-QXBjn0OAhJJ9+v=!LK`` z@g(`^*84Q4jcDL`OA&ZV60djgwG`|bcD*i50O}Q{9_noRg|~?dj%VtKOnyRs$Uzqg z191aWoR^rDX#@iSq0n z?9Sg$WSRPqSeI<}&n1T3!6%Wj@5iw5`*`Btni~G=&;J+4`7g#OQTa>u`{4ZZ(c@s$ zK0y;ySOGD-UTjREKbru{QaS>HjN<2)R%Nn-TZiQ(Twe4p@-saNa3~p{?^V9Nixz@a zykPv~<@lu6-Ng9i$Lrk(xi2Tri3q=RW`BJYOPC;S0Yly%77c727Yj-d1vF!Fuk{Xh z)lMbA69y7*5ufET>P*gXQrxsW+ zz)*MbHZv*eJPEXYE<6g6_M7N%#%mR{#awV3i^PafNv(zyI)&bH?F}2s8_rR(6%!V4SOWlup`TKAb@ee>!9JKPM=&8g#BeYRH9FpFybxBXQI2|g}FGJfJ+ zY-*2hB?o{TVL;Wt_ek;AP5PBqfDR4@Z->_182W z{P@Mc27j6jE*9xG{R$>6_;i=y{qf(c`5w9fa*`rEzX6t!KJ(p1H|>J1pC-2zqWENF zmm=Z5B4u{cY2XYl(PfrInB*~WGWik3@1oRhiMOS|D;acnf-Bs(QCm#wR;@Vf!hOPJ zgjhDCfDj$HcyVLJ=AaTbQ{@vIv14LWWF$=i-BDoC11}V;2V8A`S>_x)vIq44-VB-v z*w-d}$G+Ql?En8j!~ZkCpQ$|cA0|+rrY>tiCeWxkRGPoarxlGU2?7%k#F693RHT24 z-?JsiXlT2PTqZqNb&sSc>$d;O4V@|b6VKSWQb~bUaWn1Cf0+K%`Q&Wc<>mQ>*iEGB zbZ;aYOotBZ{vH3y<0A*L0QVM|#rf*LIsGx(O*-7)r@yyBIzJnBFSKBUSl1e|8lxU* zzFL+YDVVkIuzFWeJ8AbgN&w(4-7zbiaMn{5!JQXu)SELk*CNL+Fro|2v|YO)1l15t zs(0^&EB6DPMyaqvY>=KL>)tEpsn;N5Q#yJj<9}ImL((SqErWN3Q=;tBO~ExTCs9hB z2E$7eN#5wX4<3m^5pdjm#5o>s#eS_Q^P)tm$@SawTqF*1dj_i#)3};JslbLKHXl_N z)Fxzf>FN)EK&Rz&*|6&%Hs-^f{V|+_vL1S;-1K-l$5xiC@}%uDuwHYhmsV?YcOUlk zOYkG5v2+`+UWqpn0aaaqrD3lYdh0*!L`3FAsNKu=Q!vJu?Yc8n|CoYyDo_`r0mPoo z8>XCo$W4>l(==h?2~PoRR*kEe)&IH{1sM41mO#-36`02m#nTX{r*r`Q5rZ2-sE|nA zhnn5T#s#v`52T5|?GNS`%HgS2;R(*|^egNPDzzH_z^W)-Q98~$#YAe)cEZ%vge965AS_am#DK#pjPRr-!^za8>`kksCAUj(Xr*1NW5~e zpypt_eJpD&4_bl_y?G%>^L}=>xAaV>KR6;^aBytqpiHe%!j;&MzI_>Sx7O%F%D*8s zSN}cS^<{iiK)=Ji`FpO#^zY!_|D)qeRNAtgmH)m;qC|mq^j(|hL`7uBz+ULUj37gj zksdbnU+LSVo35riSX_4z{UX=%n&}7s0{WuZYoSfwAP`8aKN9P@%e=~1`~1ASL-z%# zw>DO&ixr}c9%4InGc*_y42bdEk)ZdG7-mTu0bD@_vGAr*NcFoMW;@r?@LUhRI zCUJgHb`O?M3!w)|CPu~ej%fddw20lod?Ufp8Dmt0PbnA0J%KE^2~AIcnKP()025V> zG>noSM3$5Btmc$GZoyP^v1@Poz0FD(6YSTH@aD0}BXva?LphAiSz9f&Y(aDAzBnUh z?d2m``~{z;{}kZJ>a^wYI?ry(V9hIoh;|EFc0*-#*`$T0DRQ1;WsqInG;YPS+I4{g zJGpKk%%Sdc5xBa$Q^_I~(F97eqDO7AN3EN0u)PNBAb+n+ zWBTxQx^;O9o0`=g+Zrt_{lP!sgWZHW?8bLYS$;1a@&7w9rD9|Ge;Gb?sEjFoF9-6v z#!2)t{DMHZ2@0W*fCx;62d#;jouz`R5Y(t{BT=$N4yr^^o$ON8d{PQ=!O zX17^CrdM~7D-;ZrC!||<+FEOxI_WI3CA<35va%4v>gc zEX-@h8esj=a4szW7x{0g$hwoWRQG$yK{@3mqd-jYiVofJE!Wok1* znV7Gm&Ssq#hFuvj1sRyHg(6PFA5U*Q8Rx>-blOs=lb`qa{zFy&n4xY;sd$fE+<3EI z##W$P9M{B3c3Si9gw^jlPU-JqD~Cye;wr=XkV7BSv#6}DrsXWFJ3eUNrc%7{=^sP> zrp)BWKA9<}^R9g!0q7yWlh;gr_TEOD|#BmGq<@IV;ueg+D2}cjpp+dPf&Q(36sFU&K8}hA85U61faW&{ zlB`9HUl-WWCG|<1XANN3JVAkRYvr5U4q6;!G*MTdSUt*Mi=z_y3B1A9j-@aK{lNvx zK%p23>M&=KTCgR!Ee8c?DAO2_R?B zkaqr6^BSP!8dHXxj%N1l+V$_%vzHjqvu7p@%Nl6;>y*S}M!B=pz=aqUV#`;h%M0rU zHfcog>kv3UZAEB*g7Er@t6CF8kHDmKTjO@rejA^ULqn!`LwrEwOVmHx^;g|5PHm#B zZ+jjWgjJ!043F+&#_;D*mz%Q60=L9Ove|$gU&~As5^uz@2-BfQ!bW)Khn}G+Wyjw- z19qI#oB(RSNydn0t~;tAmK!P-d{b-@@E5|cdgOS#!>%#Rj6ynkMvaW@37E>@hJP^8 z2zk8VXx|>#R^JCcWdBCy{0nPmYFOxN55#^-rlqobe0#L6)bi?E?SPymF*a5oDDeSd zO0gx?#KMoOd&G(2O@*W)HgX6y_aa6iMCl^~`{@UR`nMQE`>n_{_aY5nA}vqU8mt8H z`oa=g0SyiLd~BxAj2~l$zRSDHxvDs;I4>+M$W`HbJ|g&P+$!U7-PHX4RAcR0szJ*( ze-417=bO2q{492SWrqDK+L3#ChUHtz*@MP)e^%@>_&#Yk^1|tv@j4%3T)diEX zATx4K*hcO`sY$jk#jN5WD<=C3nvuVsRh||qDHnc~;Kf59zr0;c7VkVSUPD%NnnJC_ zl3F^#f_rDu8l}l8qcAz0FFa)EAt32IUy_JLIhU_J^l~FRH&6-ivSpG2PRqzDdMWft>Zc(c)#tb%wgmWN%>IOPm zZi-noqS!^Ftb81pRcQi`X#UhWK70hy4tGW1mz|+vI8c*h@ zfFGJtW3r>qV>1Z0r|L>7I3un^gcep$AAWfZHRvB|E*kktY$qQP_$YG60C@X~tTQjB3%@`uz!qxtxF+LE!+=nrS^07hn` zEgAp!h|r03h7B!$#OZW#ACD+M;-5J!W+{h|6I;5cNnE(Y863%1(oH}_FTW})8zYb$7czP zg~Szk1+_NTm6SJ0MS_|oSz%e(S~P-&SFp;!k?uFayytV$8HPwuyELSXOs^27XvK-D zOx-Dl!P|28DK6iX>p#Yb%3`A&CG0X2S43FjN%IB}q(!hC$fG}yl1y9W&W&I@KTg6@ zK^kpH8=yFuP+vI^+59|3%Zqnb5lTDAykf z9S#X`3N(X^SpdMyWQGOQRjhiwlj!0W-yD<3aEj^&X%=?`6lCy~?`&WSWt z?U~EKFcCG_RJ(Qp7j=$I%H8t)Z@6VjA#>1f@EYiS8MRHZphp zMA_5`znM=pzUpBPO)pXGYpQ6gkine{6u_o!P@Q+NKJ}k!_X7u|qfpAyIJb$_#3@wJ z<1SE2Edkfk9C!0t%}8Yio09^F`YGzpaJHGk*-ffsn85@)%4@`;Fv^8q(-Wk7r=Q8p zT&hD`5(f?M{gfzGbbwh8(}G#|#fDuk7v1W)5H9wkorE0ZZjL0Q1=NRGY>zwgfm81DdoaVwNH;or{{eSyybt)m<=zXoA^RALYG-2t zouH|L*BLvmm9cdMmn+KGopyR@4*=&0&4g|FLoreZOhRmh=)R0bg~ zT2(8V_q7~42-zvb)+y959OAv!V$u(O3)%Es0M@CRFmG{5sovIq4%8Ahjk#*5w{+)+ zMWQoJI_r$HxL5km1#6(e@{lK3Udc~n0@g`g$s?VrnQJ$!oPnb?IHh-1qA`Rz$)Ai< z6w$-MJW-gKNvOhL+XMbE7&mFt`x1KY>k4(!KbbpZ`>`K@1J<(#vVbjx@Z@(6Q}MF# zMnbr-f55(cTa^q4+#)=s+ThMaV~E`B8V=|W_fZWDwiso8tNMTNse)RNBGi=gVwgg% zbOg8>mbRN%7^Um-7oj4=6`$|(K7!+t^90a{$18Z>}<#!bm%ZEFQ{X(yBZMc>lCz0f1I2w9Sq zuGh<9<=AO&g6BZte6hn>Qmvv;Rt)*cJfTr2=~EnGD8P$v3R|&1RCl&7)b+`=QGapi zPbLg_pxm`+HZurtFZ;wZ=`Vk*do~$wB zxoW&=j0OTbQ=Q%S8XJ%~qoa3Ea|au5o}_(P;=!y-AjFrERh%8la!z6Fn@lR?^E~H12D?8#ht=1F;7@o4$Q8GDj;sSC%Jfn01xgL&%F2 zwG1|5ikb^qHv&9hT8w83+yv&BQXOQyMVJSBL(Ky~p)gU3#%|blG?IR9rP^zUbs7rOA0X52Ao=GRt@C&zlyjNLv-} z9?*x{y(`509qhCV*B47f2hLrGl^<@SuRGR!KwHei?!CM10Tq*YDIoBNyRuO*>3FU? zHjipIE#B~y3FSfOsMfj~F9PNr*H?0oHyYB^G(YyNh{SxcE(Y-`x5jFMKb~HO*m+R% zrq|ic4fzJ#USpTm;X7K+E%xsT_3VHKe?*uc4-FsILUH;kL>_okY(w`VU*8+l>o>Jm ziU#?2^`>arnsl#)*R&nf_%>A+qwl%o{l(u)M?DK1^mf260_oteV3#E_>6Y4!_hhVD zM8AI6MM2V*^_M^sQ0dmHu11fy^kOqXqzpr?K$`}BKWG`=Es(9&S@K@)ZjA{lj3ea7_MBP zk(|hBFRjHVMN!sNUkrB;(cTP)T97M$0Dtc&UXSec<+q?y>5=)}S~{Z@ua;1xt@=T5 zI7{`Z=z_X*no8s>mY;>BvEXK%b`a6(DTS6t&b!vf_z#HM{Uoy_5fiB(zpkF{})ruka$iX*~pq1ZxD?q68dIo zIZSVls9kFGsTwvr4{T_LidcWtt$u{kJlW7moRaH6+A5hW&;;2O#$oKyEN8kx`LmG)Wfq4ykh+q{I3|RfVpkR&QH_x;t41Uw z`P+tft^E2B$domKT@|nNW`EHwyj>&}K;eDpe z1bNOh=fvIfk`&B61+S8ND<(KC%>y&?>opCnY*r5M+!UrWKxv0_QvTlJc>X#AaI^xo zaRXL}t5Ej_Z$y*|w*$6D+A?Lw-CO-$itm^{2Ct82-<0IW)0KMNvJHgBrdsIR0v~=H z?n6^}l{D``Me90`^o|q!olsF?UX3YSq^6Vu>Ijm>>PaZI8G@<^NGw{Cx&%|PwYrfw zR!gX_%AR=L3BFsf8LxI|K^J}deh0ZdV?$3r--FEX`#INxsOG6_=!v)DI>0q|BxT)z z-G6kzA01M?rba+G_mwNMQD1mbVbNTWmBi*{s_v_Ft9m2Avg!^78(QFu&n6mbRJ2bA zv!b;%yo{g*9l2)>tsZJOOp}U~8VUH`}$ z8p_}t*XIOehezolNa-a2x0BS})Y9}&*TPgua{Ewn-=wVrmJUeU39EKx+%w%=ixQWK zDLpwaNJs65#6o7Ln7~~X+p_o2BR1g~VCfxLzxA{HlWAI6^H;`juI=&r1jQrUv_q0Z z1Ja-tjdktrrP>GOC*#p?*xfQU5MqjMsBe!9lh(u8)w$e@Z|>aUHI5o;MGw*|Myiz3 z-f0;pHg~Q#%*Kx8MxH%AluVXjG2C$)WL-K63@Q`#y9_k_+}eR(x4~dp7oV-ek0H>I zgy8p#i4GN{>#v=pFYUQT(g&b$OeTy-X_#FDgNF8XyfGY6R!>inYn8IR2RDa&O!(6< znXs{W!bkP|s_YI*Yx%4stI`=ZO45IK6rBs`g7sP40ic}GZ58s?Mc$&i`kq_tfci>N zIHrC0H+Qpam1bNa=(`SRKjixBTtm&e`j9porEci!zdlg1RI0Jw#b(_Tb@RQK1Zxr_ z%7SUeH6=TrXt3J@js`4iDD0=IoHhK~I7^W8^Rcp~Yaf>2wVe|Hh1bUpX9ATD#moByY57-f2Ef1TP^lBi&p5_s7WGG9|0T}dlfxOx zXvScJO1Cnq`c`~{Dp;{;l<-KkCDE+pmexJkd}zCgE{eF=)K``-qC~IT6GcRog_)!X z?fK^F8UDz$(zFUrwuR$qro5>qqn>+Z%<5>;_*3pZ8QM|yv9CAtrAx;($>4l^_$_-L z*&?(77!-=zvnCVW&kUcZMb6;2!83si518Y%R*A3JZ8Is|kUCMu`!vxDgaWjs7^0j( ziTaS4HhQ)ldR=r)_7vYFUr%THE}cPF{0H45FJ5MQW^+W>P+eEX2kLp3zzFe*-pFVA zdDZRybv?H|>`9f$AKVjFWJ=wegO7hOOIYCtd?Vj{EYLT*^gl35|HQ`R=ti+ADm{jyQE7K@kdjuqJhWVSks>b^ zxha88-h3s;%3_5b1TqFCPTxVjvuB5U>v=HyZ$?JSk+&I%)M7KE*wOg<)1-Iy)8-K! z^XpIt|0ibmk9RtMmlUd7#Ap3Q!q9N4atQy)TmrhrFhfx1DAN`^vq@Q_SRl|V z#lU<~n67$mT)NvHh`%als+G-)x1`Y%4Bp*6Un5Ri9h=_Db zA-AdP!f>f0m@~>7X#uBM?diI@)Egjuz@jXKvm zJo+==juc9_<;CqeRaU9_Mz@;3e=E4=6TK+c`|uu#pIqhSyNm`G(X)&)B`8q0RBv#> z`gGlw(Q=1Xmf55VHj%C#^1lpc>LY8kfA@|rlC1EA<1#`iuyNO z(=;irt{_&K=i4)^x%;U(Xv<)+o=dczC5H3W~+e|f~{*ucxj@{Yi-cw^MqYr3fN zF5D+~!wd$#al?UfMnz(@K#wn`_5na@rRr8XqN@&M&FGEC@`+OEv}sI1hw>Up0qAWf zL#e4~&oM;TVfjRE+10B_gFlLEP9?Q-dARr3xi6nQqnw>k-S;~b z;!0s2VS4}W8b&pGuK=7im+t(`nz@FnT#VD|!)eQNp-W6)@>aA+j~K*H{$G`y2|QHY z|Hmy+CR@#jWY4~)lr1qBJB_RfHJFfP<}pK5(#ZZGSqcpyS&}01LnTWk5fzmXMGHkJ zTP6L^B+uj;lmB_W<~4=${+v0>z31M!-_O@o-O9GyW)j_mjx}!0@br_LE-7SIuPP84 z;5=O(U*g_um0tyG|61N@d9lEuOeiRd+#NY^{nd5;-CVlw&Ap7J?qwM^?E29wvS}2d zbzar4Fz&RSR(-|s!Z6+za&Z zY#D<5q_JUktIzvL0)yq_kLWG6DO{ri=?c!y!f(Dk%G{8)k`Gym%j#!OgXVDD3;$&v@qy#ISJfp=Vm>pls@9-mapVQChAHHd-x+OGx)(*Yr zC1qDUTZ6mM(b_hi!TuFF2k#8uI2;kD70AQ&di$L*4P*Y-@p`jdm%_c3f)XhYD^6M8&#Y$ZpzQMcR|6nsH>b=*R_Von!$BTRj7yGCXokoAQ z&ANvx0-Epw`QIEPgI(^cS2f(Y85yV@ygI{ewyv5Frng)e}KCZF7JbR(&W618_dcEh(#+^zZFY;o<815<5sOHQdeax9_!PyM&;{P zkBa5xymca0#)c#tke@3KNEM8a_mT&1gm;p&&JlMGH(cL(b)BckgMQ^9&vRwj!~3@l zY?L5}=Jzr080OGKb|y`ee(+`flQg|!lo6>=H)X4`$Gz~hLmu2a%kYW_Uu8x09Pa0J zKZ`E$BKJ=2GPj_3l*TEcZ*uYRr<*J^#5pILTT;k_cgto1ZL-%slyc16J~OH-(RgDA z%;EjEnoUkZ&acS{Q8`{i6T5^nywgqQI5bDIymoa7CSZG|WWVk>GM9)zy*bNih|QIm z%0+(Nnc*a_xo;$=!HQYaapLms>J1ToyjtFByY`C2H1wT#178#4+|{H0BBqtCdd$L% z_3Hc60j@{t9~MjM@LBalR&6@>B;9?r<7J~F+WXyYu*y3?px*=8MAK@EA+jRX8{CG?GI-< z54?Dc9CAh>QTAvyOEm0^+x;r2BWX|{3$Y7)L5l*qVE*y0`7J>l2wCmW zL1?|a`pJ-l{fb_N;R(Z9UMiSj6pQjOvQ^%DvhIJF!+Th7jO2~1f1N+(-TyCFYQZYw z4)>7caf^Ki_KJ^Zx2JUb z&$3zJy!*+rCV4%jqwyuNY3j1ZEiltS0xTzd+=itTb;IPYpaf?8Y+RSdVdpacB(bVQ zC(JupLfFp8y43%PMj2}T|VS@%LVp>hv4Y!RPMF?pp8U_$xCJ)S zQx!69>bphNTIb9yn*_yfj{N%bY)t{L1cs8<8|!f$;UQ*}IN=2<6lA;x^(`8t?;+ST zh)z4qeYYgZkIy{$4x28O-pugO&gauRh3;lti9)9Pvw+^)0!h~%m&8Q!AKX%urEMnl z?yEz?g#ODn$UM`+Q#$Q!6|zsq_`dLO5YK-6bJM6ya>}H+vnW^h?o$z;V&wvuM$dR& zeEq;uUUh$XR`TWeC$$c&Jjau2it3#%J-y}Qm>nW*s?En?R&6w@sDXMEr#8~$=b(gk zwDC3)NtAP;M2BW_lL^5ShpK$D%@|BnD{=!Tq)o(5@z3i7Z){} zGr}Exom_qDO{kAVkZ*MbLNHE666Kina#D{&>Jy%~w7yX$oj;cYCd^p9zy z8*+wgSEcj$4{WxKmCF(5o7U4jqwEvO&dm1H#7z}%VXAbW&W24v-tS6N3}qrm1OnE)fUkoE8yMMn9S$?IswS88tQWm4#Oid#ckgr6 zRtHm!mfNl-`d>O*1~d7%;~n+{Rph6BBy^95zqI{K((E!iFQ+h*C3EsbxNo_aRm5gj zKYug($r*Q#W9`p%Bf{bi6;IY0v`pB^^qu)gbg9QHQ7 zWBj(a1YSu)~2RK8Pi#C>{DMlrqFb9e_RehEHyI{n?e3vL_}L>kYJC z_ly$$)zFi*SFyNrnOt(B*7E$??s67EO%DgoZL2XNk8iVx~X_)o++4oaK1M|ou73vA0K^503j@uuVmLcHH4ya-kOIDfM%5%(E z+Xpt~#7y2!KB&)PoyCA+$~DXqxPxxALy!g-O?<9+9KTk4Pgq4AIdUkl`1<1#j^cJg zgU3`0hkHj_jxV>`Y~%LAZl^3o0}`Sm@iw7kwff{M%VwtN)|~!p{AsfA6vB5UolF~d zHWS%*uBDt<9y!9v2Xe|au&1j&iR1HXCdyCjxSgG*L{wmTD4(NQ=mFjpa~xooc6kju z`~+d{j7$h-;HAB04H!Zscu^hZffL#9!p$)9>sRI|Yovm)g@F>ZnosF2EgkU3ln0bR zTA}|+E(tt)!SG)-bEJi_0m{l+(cAz^pi}`9=~n?y&;2eG;d9{M6nj>BHGn(KA2n|O zt}$=FPq!j`p&kQ8>cirSzkU0c08%8{^Qyqi-w2LoO8)^E7;;I1;HQ6B$u0nNaX2CY zSmfi)F`m94zL8>#zu;8|{aBui@RzRKBlP1&mfFxEC@%cjl?NBs`cr^nm){>;$g?rhKr$AO&6qV_Wbn^}5tfFBry^e1`%du2~o zs$~dN;S_#%iwwA_QvmMjh%Qo?0?rR~6liyN5Xmej8(*V9ym*T`xAhHih-v$7U}8=dfXi2i*aAB!xM(Xekg*ix@r|ymDw*{*s0?dlVys2e)z62u1 z+k3esbJE=-P5S$&KdFp+2H7_2e=}OKDrf( z9-207?6$@f4m4B+9E*e((Y89!q?zH|mz_vM>kp*HGXldO0Hg#!EtFhRuOm$u8e~a9 z5(roy7m$Kh+zjW6@zw{&20u?1f2uP&boD}$#Zy)4o&T;vyBoqFiF2t;*g=|1=)PxB z8eM3Mp=l_obbc?I^xyLz?4Y1YDWPa+nm;O<$Cn;@ane616`J9OO2r=rZr{I_Kizyc zP#^^WCdIEp*()rRT+*YZK>V@^Zs=ht32x>Kwe zab)@ZEffz;VM4{XA6e421^h~`ji5r%)B{wZu#hD}f3$y@L0JV9f3g{-RK!A?vBUA}${YF(vO4)@`6f1 z-A|}e#LN{)(eXloDnX4Vs7eH|<@{r#LodP@Nz--$Dg_Par%DCpu2>2jUnqy~|J?eZ zBG4FVsz_A+ibdwv>mLp>P!(t}E>$JGaK$R~;fb{O3($y1ssQQo|5M;^JqC?7qe|hg zu0ZOqeFcp?qVn&Qu7FQJ4hcFi&|nR!*j)MF#b}QO^lN%5)4p*D^H+B){n8%VPUzi! zDihoGcP71a6!ab`l^hK&*dYrVYzJ0)#}xVrp!e;lI!+x+bfCN0KXwUAPU9@#l7@0& QuEJmfE|#`Dqx|px0L@K;Y5)KL From 9d5672f9b9bb50cea76f08fda3a2bdcec89c9483 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Thu, 7 May 2026 17:47:04 -0700 Subject: [PATCH 02/19] Added support for: 1. POST request to MDS with cert-chain 2. Cert-key matching 3. Included logic to consider the user's choice by looking at GOOGLE_API_USE_CLIENT_CERTIFICATE env variable 4. Bound ID tokens. # Conflicts: # google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java # google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java --- .../auth/oauth2/AgentIdentityUtils.java | 166 +++++++++++++++++- .../auth/oauth2/ComputeEngineCredentials.java | 55 ++++-- .../auth/oauth2/AgentIdentityUtilsTest.java | 84 ++++++++- .../oauth2/ComputeEngineCredentialsTest.java | 136 +++++++------- .../DefaultCredentialsProviderTest.java | 9 +- .../oauth2/MockMetadataServerTransport.java | 6 + 6 files changed, 356 insertions(+), 100 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index b66ef8a2679a..9e52d57b7423 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -16,6 +16,8 @@ import java.security.GeneralSecurityException; import java.security.MessageDigest; import java.security.cert.CertificateFactory; +import java.security.PrivateKey; +import java.security.Signature; import java.security.cert.CertificateParsingException; import java.security.cert.X509Certificate; import java.util.ArrayList; @@ -36,8 +38,8 @@ class AgentIdentityUtils { // Environment variables static final String GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG"; - static final String GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES = - "GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES"; + static final String GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES = + "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES"; private static final List AGENT_IDENTITY_SPIFFE_PATTERNS = ImmutableList.of( @@ -47,6 +49,13 @@ class AgentIdentityUtils { private static final int SAN_URI_TYPE = 6; private static final String SPIFFE_SCHEME_PREFIX = "spiffe://"; + private static String wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/"; + + @VisibleForTesting + static void setWellKnownDir(String dir) { + wellKnownDir = dir; + } + // Polling configuration private static final int FAST_POLL_CYCLES = 50; private static final long FAST_POLL_INTERVAL_MS = 100; // 0.1 seconds @@ -94,20 +103,41 @@ public void sleep(long millis) throws InterruptedException { private AgentIdentityUtils() {} - static X509Certificate getAgentIdentityCertificate() throws IOException { + static class CertInfo { + final X509Certificate certificate; + final String path; + CertInfo(X509Certificate certificate, String path) { + this.certificate = certificate; + this.path = path; + } + } + + static CertInfo getAgentIdentityCertInfo() throws IOException { if (isOptedOut()) { return null; } String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG); - if (Strings.isNullOrEmpty(certConfigPath)) { + + boolean configExists = !Strings.isNullOrEmpty(certConfigPath) && Files.exists(Paths.get(certConfigPath)); + String certPath; + if (!Strings.isNullOrEmpty(certConfigPath)) { + certPath = getCertificatePathWithRetry(certConfigPath); + } else { + certPath = getWellKnownCertificatePathWithRetry(); + } + + boolean certsPresent = !Strings.isNullOrEmpty(certPath); + + if (!shouldEnableMtls(certsPresent, configExists)) { return null; } - String certPath = getCertificatePathWithRetry(certConfigPath); - return parseCertificate(certPath); + + X509Certificate cert = parseCertificate(certPath); + return new CertInfo(cert, certPath); } private static boolean isOptedOut() { - String optOut = envReader.getEnv(GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES); + String optOut = envReader.getEnv(GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES); return "false".equalsIgnoreCase(optOut); } @@ -145,10 +175,130 @@ private static String getCertificatePathWithRetry(String certConfigPath) throws } throw new IOException( "Unable to find Agent Identity certificate config or file for bound token request after multiple retries. Token binding protection is failing. You can turn off this protection by setting " - + GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES + + GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES + " to false to fall back to unbound tokens."); } + private static String getWellKnownCertificatePathWithRetry() throws IOException { + String bundlePath = wellKnownDir + "credentialbundle.pem"; + String certOnlyPath = wellKnownDir + "certificates.pem"; + + boolean warned = false; + for (long sleepInterval : POLLING_INTERVALS) { + try { + if (Files.exists(Paths.get(bundlePath))) { + return bundlePath; + } + if (Files.exists(Paths.get(certOnlyPath))) { + return certOnlyPath; + } + } catch (Exception e) { + // Fall through to retry + } + if (!warned) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + String.format( + "Well-known certificate file not found at %s. Retrying for up to %d seconds.", + wellKnownDir, TOTAL_TIMEOUT_MS / 1000)); + warned = true; + } + try { + timeService.sleep(sleepInterval); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException( + "Interrupted while waiting for well-known certificate files.", + e); + } + } + throw new IOException( + "Unable to find well-known certificate file for bound token request after multiple retries."); + } + + static String readCertificateChain(String certPath) throws IOException { + System.out.println("AgentIdentityUtils: Reading certificate chain from: " + certPath); + return new String(Files.readAllBytes(Paths.get(certPath)), StandardCharsets.UTF_8); + } + + static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { + try { + byte[] data = "verification-data".getBytes(StandardCharsets.UTF_8); + + String keyAlgorithm = cert.getPublicKey().getAlgorithm(); + String sigAlg; + if ("RSA".equals(keyAlgorithm)) { + sigAlg = "SHA256withRSA"; + } else if ("EC".equals(keyAlgorithm)) { + sigAlg = "SHA256withECDSA"; + } else { + throw new IllegalArgumentException("Unsupported key algorithm: " + keyAlgorithm); + } + + Signature signer = Signature.getInstance(sigAlg); + signer.initSign(privateKey); + signer.update(data); + byte[] signature = signer.sign(); + + Signature verifier = Signature.getInstance(sigAlg); + verifier.initVerify(cert.getPublicKey()); + verifier.update(data); + + return verifier.verify(signature); + } catch (Exception e) { + System.out.println("AgentIdentityUtils: Key pair verification failed: " + e.getMessage()); + return false; + } + } + + static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOException { + String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); + OAuth2Utils.Pkcs8Algorithm pkcs8Alg = "EC".equals(algorithm) ? OAuth2Utils.Pkcs8Algorithm.EC : OAuth2Utils.Pkcs8Algorithm.RSA; + return OAuth2Utils.privateKeyFromPkcs8(keyPem, pkcs8Alg); + } + + static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) throws IOException { + String useClientCert = envReader.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); + + if ("true".equalsIgnoreCase(useClientCert)) { + if (certsPresent) { + return true; // Case 1 + } + if (configExists) { + throw new IOException("Certificate intent established via config, but cert files are missing."); // Case 2 + } + return false; // Case 3 + } else if ("false".equalsIgnoreCase(useClientCert)) { + if (certsPresent) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Token binding protection is disabled because mTLS was explicitly disabled via GOOGLE_API_USE_CLIENT_CERTIFICATE."); + return false; // Case 4 + } + return false; // Case 5 + } else { // Unset + if (certsPresent) { + return true; // Case 6 (Infer enabled) + } + if (configExists) { + throw new IOException("Certificate intent inferred via config, but cert files are missing."); // Case 7 + } + return false; // Case 8 + } + } + + static String getBoundTokenPayload() throws IOException { + CertInfo info = getAgentIdentityCertInfo(); + if (info != null && shouldRequestBoundToken(info.certificate)) { + return readCertificateChain(info.path); + } + return null; + } + @SuppressWarnings("unchecked") private static String extractCertPathFromConfig(String certConfigPath) throws IOException { try (InputStream stream = new FileInputStream(certConfigPath)) { diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java index 553eb7da0cd5..b8b64edde86e 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java @@ -61,6 +61,7 @@ import java.io.ObjectInputStream; import java.net.SocketTimeoutException; import java.net.UnknownHostException; +import java.nio.charset.StandardCharsets; import java.security.cert.X509Certificate; import java.time.Duration; import java.util.ArrayList; @@ -321,7 +322,7 @@ public String getUniverseDomain() throws IOException { private String getUniverseDomainFromMetadata() throws IOException { HttpResponse response = - getMetadataResponse(getUniverseDomainUrl(), RequestType.UNTRACKED, false); + getMetadataResponse(getUniverseDomainUrl(), "GET", null, RequestType.UNTRACKED, false); int statusCode = response.getStatusCode(); if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { return Credentials.GOOGLE_DEFAULT_UNIVERSE; @@ -380,7 +381,7 @@ public String getProjectId() { private String getProjectIdFromMetadata() { try { - HttpResponse response = getMetadataResponse(getProjectIdUrl(), RequestType.UNTRACKED, false); + HttpResponse response = getMetadataResponse(getProjectIdUrl(), "GET", null, RequestType.UNTRACKED, false); int statusCode = response.getStatusCode(); if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { LoggingUtils.log( @@ -424,16 +425,17 @@ private String getProjectIdFromMetadata() { public AccessToken refreshAccessToken() throws IOException { String tokenUrl = createTokenUrlWithScopes(); - // Checks whether access token has to be bound to certificate for agent identity. - X509Certificate cert = AgentIdentityUtils.getAgentIdentityCertificate(); - if (cert != null && AgentIdentityUtils.shouldRequestBoundToken(cert)) { - String fingerprint = AgentIdentityUtils.calculateCertificateFingerprint(cert); - GenericUrl url = new GenericUrl(tokenUrl); - url.set("bindCertificateFingerprint", fingerprint); - tokenUrl = url.build(); + String boundTokenPayload = AgentIdentityUtils.getBoundTokenPayload(); + HttpResponse response; + + if (boundTokenPayload != null) { + String escapedChain = boundTokenPayload.replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); + String jsonString = "{\"certificate_chain\":\"" + escapedChain + "\"}"; + + response = getMetadataResponse(tokenUrl, "POST", jsonString, RequestType.ACCESS_TOKEN_REQUEST, true); + } else { + response = getMetadataResponse(tokenUrl, "GET", null, RequestType.ACCESS_TOKEN_REQUEST, true); } - - HttpResponse response = getMetadataResponse(tokenUrl, RequestType.ACCESS_TOKEN_REQUEST, true); int statusCode = response.getStatusCode(); if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { throw new IOException( @@ -501,8 +503,19 @@ public IdToken idTokenWithAudience(String targetAudience, List AgentIdentityUtils.shouldEnableMtls(false, true)); + } + + @Test + public void shouldEnableMtls_false_certsPresent_returnsFalse() throws IOException { + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "false"); + assertFalse(AgentIdentityUtils.shouldEnableMtls(true, true)); + } + + @Test + public void shouldEnableMtls_unset_certsPresent_returnsTrue() throws IOException { + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", null); + assertTrue(AgentIdentityUtils.shouldEnableMtls(true, true)); + } + + @Test + public void getAgentIdentityCertInfo_fallbackPath_loadsCertificate() throws IOException { + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + + URL certUrl = getClass().getClassLoader().getResource("x509_leaf_certificate.pem"); + assertNotNull(certUrl, "Test resource x509_leaf_certificate.pem not found"); + String certPath = new File(certUrl.getFile()).getAbsolutePath(); + + Files.copy(Paths.get(certPath), tempDir.resolve("certificates.pem")); + + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); + + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(info); + assertEquals(tempDir.resolve("certificates.pem").toAbsolutePath().toString(), info.path); + } + + @Test + public void verifyKeyPair_match_returnsTrue() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp = kpg.generateKeyPair(); + + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getPublicKey()).thenReturn(kp.getPublic()); + + assertTrue(AgentIdentityUtils.verifyKeyPair(mockCert, kp.getPrivate())); + } + + @Test + public void verifyKeyPair_mismatch_returnsFalse() throws Exception { + KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA"); + kpg.initialize(2048); + KeyPair kp1 = kpg.generateKeyPair(); + KeyPair kp2 = kpg.generateKeyPair(); + + X509Certificate mockCert = mock(X509Certificate.class); + when(mockCert.getPublicKey()).thenReturn(kp1.getPublic()); + + assertFalse(AgentIdentityUtils.verifyKeyPair(mockCert, kp2.getPrivate())); + } + private static class FakeTimeService implements AgentIdentityUtils.TimeService { private final AtomicLong currentTime = new AtomicLong(0); @Override diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 640434dc1e2f..3e6a8b5cf2f3 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -97,6 +97,22 @@ void setUp() throws IOException { // Inject our test environment reader into AgentIdentityUtils AgentIdentityUtils.setEnvReader(envProvider::getEnv); tempDir = Files.createTempDirectory("compute_engine_creds_test"); + + // Speed up polling in tests by using a fake time service that advances time immediately + final AtomicLong currentTime = new AtomicLong(0); + AgentIdentityUtils.setTimeService( + new AgentIdentityUtils.TimeService() { + @Override + public long currentTimeMillis() { + return currentTime.get(); + } + @Override + public void sleep(long millis) { + currentTime.addAndGet(millis); + } + }); + // Opt out of bound tokens by default in tests to avoid polling delays + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); } @AfterEach @@ -1258,69 +1274,12 @@ InputStream readStream(File file) throws FileNotFoundException { boolean isOnGce = ComputeEngineCredentials.isOnGce(transportFactory, provider); assertTrue(isOnGce); assertEquals(1, transportFactory.transport.getRequestCount()); - - void refreshAccessToken_noAgentConfig_requestsNormalToken() throws IOException { - envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); - MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); - transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); - transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); - ComputeEngineCredentials credentials = - ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); - AccessToken token = credentials.refreshAccessToken(); - assertNotNull(token); - assertEquals(ACCESS_TOKEN, token.getTokenValue()); - String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); - assertFalse(requestUrl.contains("bindCertificateFingerprint")); } - @Test - void refreshAccessToken_withStandardCert_requestsNormalToken() throws IOException { - setupCertConfig("x509_leaf_certificate.pem"); - MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); - transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); - transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); - ComputeEngineCredentials credentials = - ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); - AccessToken token = credentials.refreshAccessToken(); - assertNotNull(token); - assertEquals(ACCESS_TOKEN, token.getTokenValue()); - String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); - assertFalse(requestUrl.contains("bindCertificateFingerprint")); - } - - @Test - void refreshAccessToken_withAgentCert_requestsBoundToken() throws IOException { - setupCertConfig("agent_spiffe_cert.pem"); - MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); - transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); - transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); - ComputeEngineCredentials credentials = - ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); - AccessToken token = credentials.refreshAccessToken(); - assertNotNull(token); - assertEquals(ACCESS_TOKEN, token.getTokenValue()); - String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); - assertTrue(requestUrl.contains("bindCertificateFingerprint")); - } - - @Test - void refreshAccessToken_withAgentCert_optedOut_requestsNormalToken() throws IOException { - setupCertConfig("agent_spiffe_cert.pem"); - envProvider.setEnv("GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); - MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); - transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); - transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); - ComputeEngineCredentials credentials = - ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); - AccessToken token = credentials.refreshAccessToken(); - assertNotNull(token); - assertEquals(ACCESS_TOKEN, token.getTokenValue()); - String requestUrl = transportFactory.transport.getRequest().getUrl().toString(); - assertFalse(requestUrl.contains("bindCertificateFingerprint")); - } @Test void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); envProvider.setEnv( AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, tempDir.resolve("missing_config.json").toAbsolutePath().toString()); @@ -1347,22 +1306,67 @@ public void sleep(long millis) { "Unable to find Agent Identity certificate config or file for bound token request after multiple retries.")); } - private void setupCertConfig(String certResourceName) throws IOException { - Path certPath = tempDir.resolve("cert.pem"); - try (InputStream certStream = - getClass().getClassLoader().getResourceAsStream(certResourceName)) { - assertNotNull(certStream, "Test resource " + certResourceName + " not found"); - Files.copy(certStream, certPath); - } + + + private void setupCertAndKeyConfig() throws IOException { + java.nio.file.Path certSource = java.nio.file.Paths.get("testresources/agent_spiffe_cert.pem"); + java.nio.file.Path keySource = java.nio.file.Paths.get("testresources/mtls/test_key.pem"); + + java.nio.file.Path certTarget = tempDir.resolve("certificates.pem"); + java.nio.file.Path keyTarget = tempDir.resolve("private_key.pem"); + + Files.copy(certSource, certTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + Files.copy(keySource, keyTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + Path configPath = tempDir.resolve("config.json"); String configContent = "{\"cert_configs\": {\"workload\": {\"cert_path\": \"" - + certPath.toAbsolutePath().toString().replace("\\", "\\\\") + + certTarget.toAbsolutePath().toString().replace("\\", "\\\\") + + "\", \"key_path\": \"" + + keyTarget.toAbsolutePath().toString().replace("\\", "\\\\") + "\"}}}"; Files.write(configPath, configContent.getBytes(StandardCharsets.UTF_8)); envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configPath.toAbsolutePath().toString()); } + @Test + void refreshAccessToken_withValidCertAndKey_requestsBoundToken() throws IOException { + setupCertAndKeyConfig(); + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); + + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + AccessToken token = credentials.refreshAccessToken(); + + assertNotNull(token); + com.google.api.client.testing.http.MockLowLevelHttpRequest request = transportFactory.transport.getRequest(); + assertEquals("POST", transportFactory.transport.getRequestMethod()); + String body = request.getContentAsString(); + assertTrue(body.contains("certificate_chain")); + } + + @Test + void idTokenWithAudience_withValidCertAndKey_requestsBoundToken() throws IOException { + setupCertAndKeyConfig(); + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setIdToken(STANDARD_ID_TOKEN); + + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + IdToken token = credentials.idTokenWithAudience("https://foo.bar", null); + + assertNotNull(token); + com.google.api.client.testing.http.MockLowLevelHttpRequest request = transportFactory.transport.getRequest(); + assertEquals("POST", transportFactory.transport.getRequestMethod()); + String body = request.getContentAsString(); + assertTrue(body.contains("certificate_chain")); + } + private static class TestEnvironmentProvider { private final Map env = new HashMap<>(); void setEnv(String key, String value) { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java index d641f9b18a3d..a45c11cd8717 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java @@ -76,8 +76,13 @@ class DefaultCredentialsProviderTest { @BeforeEach void setUp() { - // Isolate tests from user's GOOGLE_API_CERTIFICATE_CONFIG environment variable. - AgentIdentityUtils.setEnvReader(name -> null); + // Isolate tests and opt out of bound tokens by default to avoid polling delays + AgentIdentityUtils.setEnvReader(name -> { + if ("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES".equals(name)) { + return "false"; // Triggers isOptedOut() = true + } + return null; + }); } @AfterEach diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java index 369ea35b857c..1f8878587c88 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java @@ -72,6 +72,7 @@ public class MockMetadataServerTransport extends MockHttpTransport { private boolean emptyContent; private MockLowLevelHttpRequest request; private int requestCount = 0; + private String requestMethod; public int getRequestCount() { return requestCount; @@ -128,9 +129,14 @@ public MockLowLevelHttpRequest getRequest() { return request; } + public String getRequestMethod() { + return requestMethod; + } + @Override public LowLevelHttpRequest buildRequest(String method, String url) throws IOException { requestCount++; + this.requestMethod = method; if (url.startsWith(ComputeEngineCredentials.getTokenServerEncodedUrl())) { this.request = getMockRequestForTokenEndpoint(url); return this.request; From a591db4bb9366888246e6fe1287d7b5861a67aa6 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Mon, 11 May 2026 16:39:43 -0700 Subject: [PATCH 03/19] Added logic to verify key and cert to ensure no mismatch along with retry logic. Nit fixes. # Conflicts: # google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java --- .../auth/oauth2/AgentIdentityUtils.java | 162 ++++++++++++++---- .../auth/oauth2/ComputeEngineCredentials.java | 52 ++++-- .../auth/oauth2/AgentIdentityUtilsTest.java | 106 ++++++++++-- .../oauth2/ComputeEngineCredentialsTest.java | 65 +++---- .../DefaultCredentialsProviderTest.java | 13 +- .../auth/oauth2/IdTokenCredentialsTest.java | 31 ++++ .../testresources/agent_spiffe_cert.pem | 37 ++-- .../testresources/agent_spiffe_key.pem | 28 +++ 8 files changed, 381 insertions(+), 113 deletions(-) create mode 100644 google-auth-library-java/oauth2_http/testresources/agent_spiffe_key.pem diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 9e52d57b7423..6d17514d3e60 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -1,3 +1,33 @@ +/* + * Copyright 2026, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ package com.google.auth.oauth2; import com.google.api.client.json.GenericJson; @@ -15,9 +45,9 @@ import java.nio.file.Paths; import java.security.GeneralSecurityException; import java.security.MessageDigest; -import java.security.cert.CertificateFactory; import java.security.PrivateKey; import java.security.Signature; +import java.security.cert.CertificateFactory; import java.security.cert.CertificateParsingException; import java.security.cert.X509Certificate; import java.util.ArrayList; @@ -29,9 +59,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** - * Utility class for Agent Identity token binding in Cloud Run. - */ +/** Utility class for Agent Identity token binding in Cloud Run. */ class AgentIdentityUtils { private static final Logger LOGGER = LoggerFactory.getLogger(AgentIdentityUtils.class); @@ -85,6 +113,7 @@ interface EnvReader { @VisibleForTesting interface TimeService { long currentTimeMillis(); + void sleep(long millis) throws InterruptedException; } @@ -106,6 +135,7 @@ private AgentIdentityUtils() {} static class CertInfo { final X509Certificate certificate; final String path; + CertInfo(X509Certificate certificate, String path) { this.certificate = certificate; this.path = path; @@ -117,22 +147,76 @@ static CertInfo getAgentIdentityCertInfo() throws IOException { return null; } String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG); - - boolean configExists = !Strings.isNullOrEmpty(certConfigPath) && Files.exists(Paths.get(certConfigPath)); - String certPath; + + boolean configExists = + !Strings.isNullOrEmpty(certConfigPath) && Files.exists(Paths.get(certConfigPath)); + String certPath = null; + String keyPath = null; + if (!Strings.isNullOrEmpty(certConfigPath)) { certPath = getCertificatePathWithRetry(certConfigPath); + keyPath = extractKeyPathFromConfig(certConfigPath); } else { certPath = getWellKnownCertificatePathWithRetry(); + if (certPath != null) { + if (certPath.endsWith("credentialbundle.pem")) { + keyPath = certPath; // Bundle contains both + } else if (certPath.endsWith("certificates.pem")) { + keyPath = Paths.get(wellKnownDir, "private_key.pem").toString(); + } + } } boolean certsPresent = !Strings.isNullOrEmpty(certPath); - + if (!shouldEnableMtls(certsPresent, configExists)) { return null; } - X509Certificate cert = parseCertificate(certPath); + X509Certificate cert = null; + PrivateKey privateKey = null; + + if (!Strings.isNullOrEmpty(certPath) + && !Strings.isNullOrEmpty(keyPath) + && !certPath.equals(keyPath) + && Files.exists(Paths.get(keyPath))) { + // Separate files, verify match with retry + int retries = 0; + boolean matched = false; + while (retries < 3) { + try { + cert = parseCertificate(certPath); + privateKey = readPrivateKey(keyPath, cert.getPublicKey().getAlgorithm()); + + if (verifyKeyPair(cert, privateKey)) { + matched = true; + break; + } + LOGGER.warn("Cert and key mismatch, retrying..."); + } catch (Exception e) { + LOGGER.warn("Failed to read or verify cert/key, retrying...", e); + } + + retries++; + if (retries < 3) { + try { + timeService.sleep(100); // 0.1 seconds backoff + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for cert/key match.", e); + } + } + } + + if (!matched) { + throw new IOException( + "Agent Identity certificate and private key mismatch or read failure after 3 retries."); + } + } else if (!Strings.isNullOrEmpty(certPath)) { + // Bundle or only cert available + cert = parseCertificate(certPath); + } + return new CertInfo(cert, certPath); } @@ -180,9 +264,9 @@ private static String getCertificatePathWithRetry(String certConfigPath) throws } private static String getWellKnownCertificatePathWithRetry() throws IOException { - String bundlePath = wellKnownDir + "credentialbundle.pem"; - String certOnlyPath = wellKnownDir + "certificates.pem"; - + String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString(); + String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString(); + boolean warned = false; for (long sleepInterval : POLLING_INTERVALS) { try { @@ -209,9 +293,7 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException timeService.sleep(sleepInterval); } catch (InterruptedException e) { Thread.currentThread().interrupt(); - throw new IOException( - "Interrupted while waiting for well-known certificate files.", - e); + throw new IOException("Interrupted while waiting for well-known certificate files.", e); } } throw new IOException( @@ -219,14 +301,13 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException } static String readCertificateChain(String certPath) throws IOException { - System.out.println("AgentIdentityUtils: Reading certificate chain from: " + certPath); return new String(Files.readAllBytes(Paths.get(certPath)), StandardCharsets.UTF_8); } static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { try { byte[] data = "verification-data".getBytes(StandardCharsets.UTF_8); - + String keyAlgorithm = cert.getPublicKey().getAlgorithm(); String sigAlg; if ("RSA".equals(keyAlgorithm)) { @@ -236,40 +317,42 @@ static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { } else { throw new IllegalArgumentException("Unsupported key algorithm: " + keyAlgorithm); } - + Signature signer = Signature.getInstance(sigAlg); signer.initSign(privateKey); signer.update(data); byte[] signature = signer.sign(); - + Signature verifier = Signature.getInstance(sigAlg); verifier.initVerify(cert.getPublicKey()); verifier.update(data); - + return verifier.verify(signature); } catch (Exception e) { - System.out.println("AgentIdentityUtils: Key pair verification failed: " + e.getMessage()); + LOGGER.warn("Key pair verification failed", e); return false; } } static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOException { String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); - OAuth2Utils.Pkcs8Algorithm pkcs8Alg = "EC".equals(algorithm) ? OAuth2Utils.Pkcs8Algorithm.EC : OAuth2Utils.Pkcs8Algorithm.RSA; + OAuth2Utils.Pkcs8Algorithm pkcs8Alg = + "EC".equals(algorithm) ? OAuth2Utils.Pkcs8Algorithm.EC : OAuth2Utils.Pkcs8Algorithm.RSA; return OAuth2Utils.privateKeyFromPkcs8(keyPem, pkcs8Alg); } static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) throws IOException { String useClientCert = envReader.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); - + if ("true".equalsIgnoreCase(useClientCert)) { if (certsPresent) { - return true; // Case 1 + return true; } if (configExists) { - throw new IOException("Certificate intent established via config, but cert files are missing."); // Case 2 + throw new IOException( + "Certificate intent established via config, but cert files are missing."); } - return false; // Case 3 + return false; } else if ("false".equalsIgnoreCase(useClientCert)) { if (certsPresent) { Slf4jUtils.log( @@ -277,15 +360,16 @@ static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) thro org.slf4j.event.Level.WARN, Collections.emptyMap(), "Token binding protection is disabled because mTLS was explicitly disabled via GOOGLE_API_USE_CLIENT_CERTIFICATE."); - return false; // Case 4 + return false; } - return false; // Case 5 - } else { // Unset + return false; + } else { if (certsPresent) { - return true; // Case 6 (Infer enabled) + return true; } if (configExists) { - throw new IOException("Certificate intent inferred via config, but cert files are missing."); // Case 7 + throw new IOException( + "Certificate intent inferred via config, but cert files are missing."); // Case 7 } return false; // Case 8 } @@ -315,6 +399,22 @@ private static String extractCertPathFromConfig(String certConfigPath) throws IO return null; } + @SuppressWarnings("unchecked") + private static String extractKeyPathFromConfig(String certConfigPath) throws IOException { + try (InputStream stream = new FileInputStream(certConfigPath)) { + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson config = parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class); + Map certConfigs = (Map) config.get("cert_configs"); + if (certConfigs != null) { + Map workload = (Map) certConfigs.get("workload"); + if (workload != null) { + return (String) workload.get("key_path"); + } + } + } + return null; + } + private static X509Certificate parseCertificate(String certPath) throws IOException { try (InputStream stream = new FileInputStream(certPath)) { CertificateFactory cf = CertificateFactory.getInstance("X.509"); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java index b8b64edde86e..0e5a296bcbf2 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java @@ -62,7 +62,6 @@ import java.net.SocketTimeoutException; import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; -import java.security.cert.X509Certificate; import java.time.Duration; import java.util.ArrayList; import java.util.Arrays; @@ -381,7 +380,8 @@ public String getProjectId() { private String getProjectIdFromMetadata() { try { - HttpResponse response = getMetadataResponse(getProjectIdUrl(), "GET", null, RequestType.UNTRACKED, false); + HttpResponse response = + getMetadataResponse(getProjectIdUrl(), "GET", null, RequestType.UNTRACKED, false); int statusCode = response.getStatusCode(); if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { LoggingUtils.log( @@ -427,12 +427,14 @@ public AccessToken refreshAccessToken() throws IOException { String boundTokenPayload = AgentIdentityUtils.getBoundTokenPayload(); HttpResponse response; - + if (boundTokenPayload != null) { - String escapedChain = boundTokenPayload.replace("\"", "\\\"").replace("\n", "\\n").replace("\r", "\\r"); - String jsonString = "{\"certificate_chain\":\"" + escapedChain + "\"}"; - - response = getMetadataResponse(tokenUrl, "POST", jsonString, RequestType.ACCESS_TOKEN_REQUEST, true); + java.util.Map payload = + Collections.singletonMap("certificate_chain", boundTokenPayload); + String jsonString = OAuth2Utils.JSON_FACTORY.toString(payload); + + response = + getMetadataResponse(tokenUrl, "POST", jsonString, RequestType.ACCESS_TOKEN_REQUEST, true); } else { response = getMetadataResponse(tokenUrl, "GET", null, RequestType.ACCESS_TOKEN_REQUEST, true); } @@ -505,16 +507,19 @@ public IdToken idTokenWithAudience(String targetAudience, List payload = + Collections.singletonMap("certificate_chain", boundTokenPayload); + String jsonString = OAuth2Utils.JSON_FACTORY.toString(payload); + + response = + getMetadataResponse( + documentUrl.toString(), "POST", jsonString, RequestType.ID_TOKEN_REQUEST, true); } else { - System.out.println("ComputeEngineCredentials: Requesting standard unbound ID token via GET."); - response = getMetadataResponse(documentUrl.toString(), "GET", null, RequestType.ID_TOKEN_REQUEST, true); + response = + getMetadataResponse( + documentUrl.toString(), "GET", null, RequestType.ID_TOKEN_REQUEST, true); } int statusCode = response.getStatusCode(); if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) { @@ -544,15 +549,23 @@ public IdToken idTokenWithAudience(String targetAudience, List env = new java.util.HashMap<>(); + void setEnv(String key, String value) { env.put(key, value); } + String getEnv(String key) { return env.get(key); } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 3e6a8b5cf2f3..9931aec8459a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -62,7 +62,6 @@ import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; -import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -70,17 +69,17 @@ import java.util.ArrayDeque; import java.util.Arrays; import java.util.Collection; -import java.util.HashMap; -import java.util.concurrent.atomic.AtomicLong; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.BeforeEach; import java.util.Collections; +import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Queue; +import java.util.concurrent.atomic.AtomicLong; import java.util.stream.Collectors; import java.util.stream.IntStream; import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; /** Test case for {@link ComputeEngineCredentials}. */ @@ -97,7 +96,7 @@ void setUp() throws IOException { // Inject our test environment reader into AgentIdentityUtils AgentIdentityUtils.setEnvReader(envProvider::getEnv); tempDir = Files.createTempDirectory("compute_engine_creds_test"); - + // Speed up polling in tests by using a fake time service that advances time immediately final AtomicLong currentTime = new AtomicLong(0); AgentIdentityUtils.setTimeService( @@ -106,6 +105,7 @@ void setUp() throws IOException { public long currentTimeMillis() { return currentTime.get(); } + @Override public void sleep(long millis) { currentTime.addAndGet(millis); @@ -1275,8 +1275,6 @@ InputStream readStream(File file) throws FileNotFoundException { assertTrue(isOnGce); assertEquals(1, transportFactory.transport.getRequestCount()); } - - @Test void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); @@ -1290,6 +1288,7 @@ void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { public long currentTimeMillis() { return currentTime.get(); } + @Override public void sleep(long millis) { currentTime.addAndGet(millis); @@ -1306,25 +1305,27 @@ public void sleep(long millis) { "Unable to find Agent Identity certificate config or file for bound token request after multiple retries.")); } - - private void setupCertAndKeyConfig() throws IOException { java.nio.file.Path certSource = java.nio.file.Paths.get("testresources/agent_spiffe_cert.pem"); - java.nio.file.Path keySource = java.nio.file.Paths.get("testresources/mtls/test_key.pem"); - java.nio.file.Path certTarget = tempDir.resolve("certificates.pem"); - java.nio.file.Path keyTarget = tempDir.resolve("private_key.pem"); - Files.copy(certSource, certTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); + + java.nio.file.Path keySource = java.nio.file.Paths.get("testresources/agent_spiffe_key.pem"); + java.nio.file.Path keyTarget = tempDir.resolve("private_key.pem"); Files.copy(keySource, keyTarget, java.nio.file.StandardCopyOption.REPLACE_EXISTING); - + Path configPath = tempDir.resolve("config.json"); - String configContent = - "{\"cert_configs\": {\"workload\": {\"cert_path\": \"" - + certTarget.toAbsolutePath().toString().replace("\\", "\\\\") - + "\", \"key_path\": \"" - + keyTarget.toAbsolutePath().toString().replace("\\", "\\\\") - + "\"}}}"; + Map workload = new HashMap<>(); + workload.put("cert_path", certTarget.toAbsolutePath().toString()); + workload.put("key_path", keyTarget.toAbsolutePath().toString()); + + Map certConfigs = new HashMap<>(); + certConfigs.put("workload", workload); + + Map config = new HashMap<>(); + config.put("cert_configs", certConfigs); + + String configContent = OAuth2Utils.JSON_FACTORY.toString(config); Files.write(configPath, configContent.getBytes(StandardCharsets.UTF_8)); envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configPath.toAbsolutePath().toString()); } @@ -1332,17 +1333,19 @@ private void setupCertAndKeyConfig() throws IOException { @Test void refreshAccessToken_withValidCertAndKey_requestsBoundToken() throws IOException { setupCertAndKeyConfig(); - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token + envProvider.setEnv( + "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); transportFactory.transport.setAccessToken("default", ACCESS_TOKEN); - + ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); AccessToken token = credentials.refreshAccessToken(); - + assertNotNull(token); - com.google.api.client.testing.http.MockLowLevelHttpRequest request = transportFactory.transport.getRequest(); + com.google.api.client.testing.http.MockLowLevelHttpRequest request = + transportFactory.transport.getRequest(); assertEquals("POST", transportFactory.transport.getRequestMethod()); String body = request.getContentAsString(); assertTrue(body.contains("certificate_chain")); @@ -1351,17 +1354,19 @@ void refreshAccessToken_withValidCertAndKey_requestsBoundToken() throws IOExcept @Test void idTokenWithAudience_withValidCertAndKey_requestsBoundToken() throws IOException { setupCertAndKeyConfig(); - envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token + envProvider.setEnv( + "GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); // Enable bound token MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); transportFactory.transport.setIdToken(STANDARD_ID_TOKEN); - + ComputeEngineCredentials credentials = ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); IdToken token = credentials.idTokenWithAudience("https://foo.bar", null); - + assertNotNull(token); - com.google.api.client.testing.http.MockLowLevelHttpRequest request = transportFactory.transport.getRequest(); + com.google.api.client.testing.http.MockLowLevelHttpRequest request = + transportFactory.transport.getRequest(); assertEquals("POST", transportFactory.transport.getRequestMethod()); String body = request.getContentAsString(); assertTrue(body.contains("certificate_chain")); @@ -1369,9 +1374,11 @@ void idTokenWithAudience_withValidCertAndKey_requestsBoundToken() throws IOExcep private static class TestEnvironmentProvider { private final Map env = new HashMap<>(); + void setEnv(String key, String value) { env.put(key, value); } + String getEnv(String key) { return env.get(key); } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java index a45c11cd8717..0961db67ccb5 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DefaultCredentialsProviderTest.java @@ -77,12 +77,13 @@ class DefaultCredentialsProviderTest { @BeforeEach void setUp() { // Isolate tests and opt out of bound tokens by default to avoid polling delays - AgentIdentityUtils.setEnvReader(name -> { - if ("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES".equals(name)) { - return "false"; // Triggers isOptedOut() = true - } - return null; - }); + AgentIdentityUtils.setEnvReader( + name -> { + if ("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES".equals(name)) { + return "false"; // Triggers isOptedOut() = true + } + return null; + }); } @AfterEach diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java index e3dcec4b520c..9e80bc63477f 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdTokenCredentialsTest.java @@ -34,11 +34,42 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import java.io.IOException; +import java.util.HashMap; +import java.util.Map; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; /** Test case for {@link IdTokenCredentials}. */ class IdTokenCredentialsTest extends BaseSerializationTest { + private static class TestEnvironmentProvider { + private final Map env = new HashMap<>(); + + void setEnv(String key, String value) { + env.put(key, value); + } + + String getEnv(String key) { + return env.get(key); + } + } + + private TestEnvironmentProvider envProvider; + + @BeforeEach + void setUp() { + envProvider = new TestEnvironmentProvider(); + AgentIdentityUtils.setEnvReader(envProvider::getEnv); + // Opt out by default to avoid polling delays or file reads + envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "false"); + } + + @AfterEach + void tearDown() { + AgentIdentityUtils.setEnvReader(System::getenv); + } + @Test void hashCode_equals() throws IOException { ComputeEngineCredentialsTest.MockMetadataServerTransportFactory transportFactory = diff --git a/google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem b/google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem index d0a0ed1a883d..885ecec9b6cf 100644 --- a/google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem +++ b/google-auth-library-java/oauth2_http/testresources/agent_spiffe_cert.pem @@ -1,20 +1,21 @@ -----BEGIN CERTIFICATE----- -MIIDPjCCAiagAwIBAgIUCYeV4dwM29T5yucwWrSWlOC9wwYwDQYJKoZIhvcNAQEL -BQAwIjEgMB4GA1UEAwwXVGVzdCBTUElGRkUgQ2VydGlmaWNhdGUwHhcNMjUxMTA3 -MDEyMjQ4WhcNMzUxMTA1MDEyMjQ4WjAiMSAwHgYDVQQDDBdUZXN0IFNQSUZGRSBD -ZXJ0aWZpY2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBANDr1Bzo -KtzIZB35acQ+mpk6yScf59AnwHjjgNCMbC7kq2DSUfQzTlu9Kd0uUB6O7DmJ73D8 -Pge4XLE/Q1B6dI6DzJx7lhPoC1BiQFUGJ4Cu+TbbdlK3RiXNAZYjIj9UKP7DejCY -WRgFB+PYyLczEkByvU9cy7Z9Uuufsn6LnYu7qOG+DcRSE41ThurZxQ14OWvLfjZm -lhZXam4VBBli8Qku8qFIALe78kpy+hp2YCRnK84amATwPpGprRACp9WVka2JDYKD -LY0OoYlyAQel6960aS11N3/2v0cvx03/LM5+Yj+DTvdyb2Mk/NVeRIKo8cM5YwPn -sTLCf1cdxJvseRMCAwEAAaNsMGowSQYDVR0RBEIwQIY+c3BpZmZlOi8vYWdlbnRz -Lmdsb2JhbC5wcm9qLTEyMzQ1LnN5c3RlbS5pZC5nb29nL3Rlc3Qtd29ya2xvYWQw -HQYDVR0OBBYEFPvn+KXBcrYCmAMopkghUczUx/IkMA0GCSqGSIb3DQEBCwUAA4IB -AQCbwd9RMFkr1C9AEgnLMWd1l9ciBbK0t1Sydu3eA0SNm2w6E58ih8O+huo6eGsM -7z0E4i7YuaHnTdah/lPMqd75YRO57GSRbvi2g+yPyw6XdFl9HCHwF4WARdTF4Nkf -1c1WstvBXb24PSSQQdy9un72ZG6f9fSVQrko6hchd8Rg6yyBTFE8APPkeMR/EJtV -cnXg4CgsQIPHxJGQrhNvQhF7VLZePlTass4bqTqTYXwAte2jX/KW3qlW/t/v4AJe -/q+pcXmNIvwRpT8zYA5tJHIDVJ+v9pWZA+nhoD9Qtr7FVHfB4mdNuFv7bMPoXN0+ -mCPzP08MnjgbX7zRETVlblrx +MIIDcjCCAlqgAwIBAgIUb5t9VPgT3uTrj+z47BfU8xF6sFowDQYJKoZIhvcNAQEL +BQAwIjEgMB4GA1UEAwwXVGVzdCBTUElGRkUgQ2VydGlmaWNhdGUwHhcNMjYwNTEx +MjMyODU3WhcNMzYwNTA4MjMyODU3WjAiMSAwHgYDVQQDDBdUZXN0IFNQSUZGRSBD +ZXJ0aWZpY2F0ZTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAJlPtSAh +hYqci8YJaPlgZWUbup4q/TGuaLdR/zWpFCevAQW7/hq29LT8WsoyuirSlbJg67io +PPuNp98e614L/88jP3wMeSuBSXGMyeExrMQ35ZbrnmD268Fl/jEZXPujmeFWmRoy +miKSkNvYLvkN0fiooyFYZdNG0/f6zT+dwJqoOYgeBl9XXDf5QjnQbhQXFQJIbxfQ +TTaGFtEXXwKCs9cd5EvwbiDuaM+F2O7txkaD4r+77AjsXWUFkBBbSUzs2b3ujZot +ZqwNCeeGML/G+5TQj3UExTtDzYpjvncEuqdomFraqXy6DmyELhyChugsz5rGx5Vt +aP3RvDkpLwUxjWcCAwEAAaOBnzCBnDAdBgNVHQ4EFgQUoS1OUx9a1hBHt0tHAefV +7QQ3JYswHwYDVR0jBBgwFoAUoS1OUx9a1hBHt0tHAefV7QQ3JYswDwYDVR0TAQH/ +BAUwAwEB/zBJBgNVHREEQjBAhj5zcGlmZmU6Ly9hZ2VudHMuZ2xvYmFsLnByb2ot +MTIzNDUuc3lzdGVtLmlkLmdvb2cvdGVzdC13b3JrbG9hZDANBgkqhkiG9w0BAQsF +AAOCAQEAfCrNuLFIlpvtDpBKD8lxj2vF4/6fbLhl/5YGGFf2uydaWLr2hTmLFrer +fzGf04hPD5UJTms0ZlvHTvapm1IikxKhkmp8GOlMvKWl/EwIDaJyJ8EaYCpkrTNs +pLR/ujgeSKnIHgm2Oql7HZxB1T25teFcLIIMd0zs69h/Sxejw+OTKnwb7san1qSy +uNvXVDPNFrGyjvBXAVyjyvs3Adz/A+GYhyaP31s+3qGUUR4/axv0M8pUVmK6D8lU +5TkO7smVELt51xaq77Nvv1r5FasJkF5CrnqwAjq1QfVJpDsuTCEriUXguxaXFbzM +Qfw7TEXp3xPVinhkDjlRfiGg6hIvNw== -----END CERTIFICATE----- diff --git a/google-auth-library-java/oauth2_http/testresources/agent_spiffe_key.pem b/google-auth-library-java/oauth2_http/testresources/agent_spiffe_key.pem new file mode 100644 index 000000000000..b136c31e3db7 --- /dev/null +++ b/google-auth-library-java/oauth2_http/testresources/agent_spiffe_key.pem @@ -0,0 +1,28 @@ +-----BEGIN PRIVATE KEY----- +MIIEvQIBADANBgkqhkiG9w0BAQEFAASCBKcwggSjAgEAAoIBAQCZT7UgIYWKnIvG +CWj5YGVlG7qeKv0xrmi3Uf81qRQnrwEFu/4atvS0/FrKMroq0pWyYOu4qDz7jaff +HuteC//PIz98DHkrgUlxjMnhMazEN+WW655g9uvBZf4xGVz7o5nhVpkaMpoikpDb +2C75DdH4qKMhWGXTRtP3+s0/ncCaqDmIHgZfV1w3+UI50G4UFxUCSG8X0E02hhbR +F18CgrPXHeRL8G4g7mjPhdju7cZGg+K/u+wI7F1lBZAQW0lM7Nm97o2aLWasDQnn +hjC/xvuU0I91BMU7Q82KY753BLqnaJha2ql8ug5shC4cgoboLM+axseVbWj90bw5 +KS8FMY1nAgMBAAECggEAA/g2Eq6bhEYr/lHL2u0kIvT2JSNGBAb/WIPLs/cWE2le +GujOGjrGskSSEaGa0JuiqAg6m9p+hCFPIyFShQOSUsNkYOqUkDJPe8+vG62xsTDx +uMsoAijsKMlI+bqtsPXm+MuWHw4Hww5nDsvI4Sz6iB6bS4E8JB2cEaDEtx/do7aq +5XHJYoy0kO/l87n1UlIq8Pi6yW36pQKKEK+Dm5q+QmgJIeNLFp0hb5r7/fLFENTD +CZO0GvMdmtTkTwyPB1mvFjP6mNNAZcO6j9eaWNCxFhPH+MNWtF+694wgPOQIFKL3 +n4FPmWUS7vGJjNI55Ff2tTHrbWcMuf4RwXbVIG+v1QKBgQDPnNEs2TRrMglFtT6J +wDbICK8AQbBAPFjtZUGxNnsCOdiprZXshZJ7Yq8lokDlsU+5l0YTzRj2v1IcEL49 +oy8WeQfbcOZTGgf+Qo50ZiLhziCqNTlxiXMp3Kg5h4cjxUz2tsF2Nmzn75411uPa +Q/SKHCzfz4uUtHJla8tHENuzdQKBgQC9CwOleHwdS5D1jzBpb/+rvc0vcyYeihZe +v2ao2ItEZZpDyZoou3HiuTe8b9jpg4iS1PtBe6ck/99dBA3CSCF6VCS+++fc+HOz +IyPZjfg8qQ/88XMIEJy8eAa2OFT/EwK3AeZvIuXVumUHI2R8AfkVB0OfjSVZ7hnI +p1ndQ09t6wKBgHDiQVHzX+8RK719SN25Z4/oOM8Y6G5k4a1iqw9iIgwZy9amjagn +EHiKNdVunX7GpCSzPeUyVWqEqG6eI/J7sfS0JjOI9ZMlykbThYWAq2K/oz8o5Wz4 +YWfXlJiDOlWWx7w1rodKHHkX7pwzlXxuCp61pyiiPrDCVJkUvViMsAipAoGAQHda +FfqhcKgNVgAvhTVBXgLKzwyYij+S41qoGppF29w+IDHG1W8epi99d1A5C2DkmRXy +XOFbHX34YNL6Ei/g4sOBCHQFHNDJO+SW3CDS73TD1AFOtghcOtU/jLJnIdkMyvXl +7C5dbGY0/5stMDDIDUi94dITU7ijqE6RkafblWMCgYEAxwFH2JJn8HyU30PVrIFh +AsviZ3BYrCbYFlkZpelCHzHc5imyR9euLwXHIPS0vTE5V8GzkPEgW3DIOFMmBwe2 +8M/jGi/RQCnD7Hh0C+Y0vtUyF8z88lvN5ac6lU9UpGVBBkd8HEsq5ukL3PlnW4B0 +uRGJOa6FU01LIEMoCL5LP28= +-----END PRIVATE KEY----- From 18afacb0a5bad42618d4ad22bee025d6b1d1cd13 Mon Sep 17 00:00:00 2001 From: Pranav Iyer Date: Mon, 11 May 2026 18:17:11 -0700 Subject: [PATCH 04/19] Added documentation and addressed comments. --- .../auth/oauth2/AgentIdentityUtils.java | 135 ++++++++++++++---- .../auth/oauth2/AgentIdentityUtilsTest.java | 12 +- .../oauth2/ComputeEngineCredentialsTest.java | 14 +- .../DefaultCredentialsProviderTest.java | 2 +- .../auth/oauth2/IdTokenCredentialsTest.java | 2 +- .../com/google/auth/oauth2/LoggingTest.java | 19 +++ .../FTComputeEngineCredentialsTest.java | 32 +++++ .../{ => agent}/agent_spiffe_cert.pem | 0 .../{ => agent}/agent_spiffe_key.pem | 0 .../oauth2_http/testresources/agent_cert.pem | 19 --- 10 files changed, 166 insertions(+), 69 deletions(-) rename google-auth-library-java/oauth2_http/testresources/{ => agent}/agent_spiffe_cert.pem (100%) rename google-auth-library-java/oauth2_http/testresources/{ => agent}/agent_spiffe_key.pem (100%) delete mode 100644 google-auth-library-java/oauth2_http/testresources/agent_cert.pem diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 6d17514d3e60..6c2fa2503dff 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -30,21 +30,19 @@ */ package com.google.auth.oauth2; +import com.google.api.core.InternalApi; import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonObjectParser; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; import com.google.common.collect.ImmutableList; -import com.google.common.io.BaseEncoding; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; -import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.nio.file.Files; import java.nio.file.Paths; import java.security.GeneralSecurityException; -import java.security.MessageDigest; import java.security.PrivateKey; import java.security.Signature; import java.security.cert.CertificateFactory; @@ -60,7 +58,8 @@ import org.slf4j.LoggerFactory; /** Utility class for Agent Identity token binding in Cloud Run. */ -class AgentIdentityUtils { +@InternalApi +public final class AgentIdentityUtils { private static final Logger LOGGER = LoggerFactory.getLogger(AgentIdentityUtils.class); @@ -104,7 +103,7 @@ static void setWellKnownDir(String dir) { POLLING_INTERVALS = Collections.unmodifiableList(intervals); } - interface EnvReader { + public interface EnvReader { String getEnv(String name); } @@ -142,21 +141,66 @@ static class CertInfo { } } + static class ResolvedCertAndKeyPaths { + final String certPath; + final String keyPath; + + ResolvedCertAndKeyPaths(String certPath, String keyPath) { + this.certPath = certPath; + this.keyPath = keyPath; + } + } + + /** + * Retrieves the certificate and path for the Agent Identity. + * + *

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

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

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

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

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

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

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

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

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

To handle transient race conditions during certificate rotation on disk, this method employs + * a retry mechanism with backoff when reading the configuration and certificate files. + * + * @return A {@link CertInfo} object containing the loaded certificate and its path, or {@code + * null} if the agent identity features are disabled, opted out, or if no valid credentials + * could be loaded. + * @throws IOException If an I/O error occurs while reading the files, or if the key-pair + * verification fails after retries. + */ + static CertInfo getAgentIdentityCertInfo() throws IOException { + if (!isTokenBindingEnabled()) { + return null; } + String certConfigPath = envReader.getEnv(GOOGLE_API_CERTIFICATE_CONFIG); + boolean configExists = + !Strings.isNullOrEmpty(certConfigPath) && Files.exists(Paths.get(certConfigPath)); - /** Searches for certificates at well-known locations with retry logic. */ - private static String getWellKnownCertificatePathWithRetry() throws IOException { - String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString(); - String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString(); + ResolvedCertAndKeyPaths paths = resolveCertAndKeyPaths(certConfigPath); + boolean certsPresent = !Strings.isNullOrEmpty(paths.getCertPath()); - boolean warned = false; - for (long sleepInterval : POLLING_INTERVALS) { - try { - if (checkExistsOrAccessDenied(Paths.get(bundlePath))) { - return bundlePath; - } - if (checkExistsOrAccessDenied(Paths.get(certOnlyPath))) { - return certOnlyPath; - } - } catch (java.nio.file.AccessDeniedException e) { - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - "Permission denied reading well-known certificates. Falling back to unbound" - + " token."); - return null; - } catch (Exception e) { - // Fall through to retry - } - if (!warned) { - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - String.format( - "Well-known certificate file not found at %s. Retrying for up to %d" - + " seconds.", - wellKnownDir, TOTAL_TIMEOUT_MS / 1000)); - warned = true; - } - try { - timeService.sleep(sleepInterval); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException( - "Interrupted while waiting for well-known certificate files.", e); - } - } - throw new IOException( - "Unable to find well-known certificate file for bound token request after multiple" - + " retries."); + if (!shouldEnableMtls(certsPresent, configExists)) { + return null; } - /** Reads the full certificate chain from the specified path as a string. */ - static String readCertificateChain(String certPath) throws IOException { - return new String(Files.readAllBytes(Paths.get(certPath)), StandardCharsets.UTF_8); + return loadAndVerifyCredentials(paths.getCertPath(), paths.getKeyPath()); + } + + /** + * Resolves the paths for the certificate and private key based on the config path or well-known + * locations. + */ + static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(String certConfigPath) throws IOException { + String certPath = null; + String keyPath = null; + + if (!Strings.isNullOrEmpty(certConfigPath)) { + java.nio.file.Path configPath = Paths.get(certConfigPath); + if (!Files.exists(configPath) && !Files.exists(Paths.get(wellKnownDir))) { + // Fail-fast if config doesn't exist and we are not in a workload environment + return new ResolvedCertAndKeyPaths(null, null); + } + // Read cert and key paths from config file. We use retry with backoff to handle + // transient + // race conditions where the config file might be being updated by a rotation process. + ResolvedCertAndKeyPaths paths = getPathsFromConfigWithRetry(certConfigPath); + if (paths != null) { + certPath = paths.getCertPath(); + keyPath = paths.getKeyPath(); + } + } else { + if (!Files.exists(Paths.get(wellKnownDir))) { + // Fail-fast if well-known dir doesn't exist (e.g. workstation) + return new ResolvedCertAndKeyPaths(null, null); + } + // Fallback to well-known locations. We use retry with backoff here as well to handle + // race conditions during file replacement by a rotation process. + certPath = getWellKnownCertificatePathWithRetry(); + if (certPath != null) { + if (certPath.endsWith("credentialbundle.pem")) { + keyPath = certPath; // Bundle contains both + } else if (certPath.endsWith("certificates.pem")) { + keyPath = Paths.get(wellKnownDir, "private_key.pem").toString(); + } + } } - - /** - * Verifies that the private key corresponds to the public key in the certificate by performing - * a test signature and verification. - */ - static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { + return new ResolvedCertAndKeyPaths(certPath, keyPath); + } + + /** + * Loads the certificate and private key, and verifies that they match if they are separate files. + */ + static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws IOException { + X509Certificate cert = null; + PrivateKey privateKey = null; + String certContent = null; + + if (!Strings.isNullOrEmpty(certPath) + && !Strings.isNullOrEmpty(keyPath) + && !certPath.equals(keyPath) + && Files.exists(Paths.get(keyPath))) { + // Separate files, verify match with retry + int retries = 0; + boolean matched = false; + while (retries < CERT_KEY_MATCH_RETRIES) { try { - byte[] data = "verification-data".getBytes(StandardCharsets.UTF_8); - - String keyAlgorithm = cert.getPublicKey().getAlgorithm(); - String sigAlg; - if ("RSA".equals(keyAlgorithm)) { - sigAlg = "SHA256withRSA"; - } else if ("EC".equals(keyAlgorithm)) { - sigAlg = "SHA256withECDSA"; - } else { - throw new IllegalArgumentException("Unsupported key algorithm: " + keyAlgorithm); - } - - Signature signer = Signature.getInstance(sigAlg); - signer.initSign(privateKey); - signer.update(data); - byte[] signature = signer.sign(); - - Signature verifier = Signature.getInstance(sigAlg); - verifier.initVerify(cert.getPublicKey()); - verifier.update(data); - - return verifier.verify(signature); + certContent = readCertificateChain(certPath); + cert = parseCertificateContent(certContent); + privateKey = readPrivateKey(keyPath, cert.getPublicKey().getAlgorithm()); + + if (verifyKeyPair(cert, privateKey)) { + matched = true; + break; + } + LOGGER.warn("Cert and key mismatch, retrying..."); + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading certificate or key files. Falling back to" + + " unbound token."); + return null; } catch (Exception e) { - LOGGER.warn("Key pair verification failed", e); - return false; + LOGGER.warn("Failed to read or verify cert/key, retrying...", e); } - } - /** Reads the private key from the specified path using PKCS8 format. */ - static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOException { - String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); - OAuth2Utils.Pkcs8Algorithm pkcs8Alg = - "EC".equals(algorithm) - ? OAuth2Utils.Pkcs8Algorithm.EC - : OAuth2Utils.Pkcs8Algorithm.RSA; - return OAuth2Utils.privateKeyFromPkcs8(keyPem, pkcs8Alg); - } - - /** - * Determines if mTLS should be enabled based on environment variables and certificate presence. - */ - static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) throws IOException { - String useClientCert = envReader.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); - - // Case 1: Explicitly enabled via environment variable - if ("true".equalsIgnoreCase(useClientCert)) { - if (certsPresent) { - // Certs are available, enable mTLS - return true; - } - if (configExists) { - // Config exists but files are missing - fail fast - throw new IOException( - "Certificate intent established via config, but cert files are missing."); - } - // Neither exist, do not enable - return false; - } - // Case 2: Explicitly disabled via environment variable - else if ("false".equalsIgnoreCase(useClientCert)) { - if (certsPresent) { - // Warn that we are ignoring present certs because it was explicitly disabled - Slf4jUtils.log( - LOGGER, - org.slf4j.event.Level.WARN, - Collections.emptyMap(), - "Token binding protection is disabled because mTLS was explicitly disabled" - + " via GOOGLE_API_USE_CLIENT_CERTIFICATE."); - return false; - } - return false; - } - // Case 3: Environment variable is unset - else { - if (certsPresent) { - // Infer mTLS is enabled because certs are present - return true; - } - if (configExists) { - // Config exists but files are missing - fail fast - throw new IOException( - "Certificate intent inferred via config, but cert files are missing."); - } - // Neither cert-config nor certs exist, do not enable - return false; + retries++; + if (retries < CERT_KEY_MATCH_RETRIES) { + try { + timeService.sleep(CERT_KEY_MATCH_RETRY_INTERVAL_MS); // 0.1 seconds backoff + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for cert/key match.", e); + } } - } + } - /** Retrieves the bound token payload (certificate chain) if applicable. */ - static String getBoundTokenPayload() throws IOException { - CertInfo info = getAgentIdentityCertInfo(); - if (info != null && shouldRequestBoundToken(info.getCertificate())) { - return info.getCertContent(); - } + if (!matched) { + throw new IOException( + String.format( + "Agent Identity certificate and private key mismatch or read" + + " failure after %d retries.", + CERT_KEY_MATCH_RETRIES)); + } + } else if (!Strings.isNullOrEmpty(certPath)) { + // Bundle or only cert available + try { + certContent = readCertificateChain(certPath); + cert = parseCertificateContent(certContent); + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading certificate files. Falling back to unbound" + " token."); return null; + } } - @SuppressWarnings("unchecked") - /** Extracts the certificate and private key paths from the JSON configuration file. */ - private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigPath) - throws IOException { - try (InputStream stream = Files.newInputStream(Paths.get(certConfigPath))) { - JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); - GenericJson config = - parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class); - Object certConfigsObj = config.get("cert_configs"); - if (certConfigsObj instanceof Map) { - Map certConfigs = (Map) certConfigsObj; - Object workloadObj = certConfigs.get("workload"); - if (workloadObj instanceof Map) { - Map workload = (Map) workloadObj; - String certPath = null; - String keyPath = null; - if (workload.get("cert_path") instanceof String) { - certPath = (String) workload.get("cert_path"); - } - if (workload.get("key_path") instanceof String) { - keyPath = (String) workload.get("key_path"); - } - return new ResolvedCertAndKeyPaths(certPath, keyPath); - } - } - } catch (java.nio.file.AccessDeniedException e) { - throw e; - } catch (Exception e) { - throw new IOException("Failed to parse Agent Identity config JSON", e); + return new CertInfo(cert, certContent); + } + + /** Checks if a file exists, throwing AccessDeniedException if permission is denied. */ + private static boolean checkExistsOrAccessDenied(java.nio.file.Path path) + throws java.nio.file.AccessDeniedException { + try { + Files.readAttributes(path, java.nio.file.attribute.BasicFileAttributes.class); + return true; + } catch (java.nio.file.AccessDeniedException e) { + throw e; + } catch (IOException e) { + return false; + } + } + + /** Checks if the user has disabled token binding by setting the environment variable to false. */ + private static boolean isTokenBindingEnabled() { + String preventSharing = envReader.getEnv(GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES); + return !("false".equalsIgnoreCase(preventSharing)); + } + + /** + * Reads the certificate path from the config file with retry logic to handle rotation race + * conditions. + */ + private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(String certConfigPath) + throws IOException { + boolean warned = false; + for (long sleepInterval : POLLING_INTERVALS) { + try { + if (checkExistsOrAccessDenied(Paths.get(certConfigPath))) { + ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath); + if (paths != null + && !Strings.isNullOrEmpty(paths.getCertPath()) + && checkExistsOrAccessDenied(Paths.get(paths.getCertPath()))) { + return paths; + } } + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading certificate config file. Falling back to unbound" + + " token."); return null; + } catch (IOException e) { + // Fall through to retry + } + if (!warned) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + String.format( + "Certificate config file not found or invalid at %s (from %s" + + " environment variable). Retrying for up to %d seconds.", + certConfigPath, GOOGLE_API_CERTIFICATE_CONFIG, TOTAL_TIMEOUT_MS / 1000)); + warned = true; + } + try { + timeService.sleep(sleepInterval); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException( + "Interrupted while waiting for Agent Identity certificate files for bound" + + " token request.", + e); + } } - - /** Parses the X509 certificate from the specified content string. */ - private static X509Certificate parseCertificateContent(String certContent) throws IOException { - try (InputStream stream = - new java.io.ByteArrayInputStream(certContent.getBytes(StandardCharsets.UTF_8))) { - CertificateFactory cf = CertificateFactory.getInstance("X.509"); - return (X509Certificate) cf.generateCertificate(stream); - } catch (GeneralSecurityException e) { - throw new IOException( - "Failed to parse Agent Identity certificate for bound token request.", e); + throw new IOException( + "Unable to find Agent Identity certificate config or file for bound token request" + + " after multiple retries. Token binding protection is failing. You can turn" + + " off this protection by setting " + + GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES + + " to false to fall back to unbound tokens."); + } + + /** Searches for certificates at well-known locations with retry logic. */ + private static String getWellKnownCertificatePathWithRetry() throws IOException { + String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString(); + String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString(); + + boolean warned = false; + for (long sleepInterval : POLLING_INTERVALS) { + try { + if (checkExistsOrAccessDenied(Paths.get(bundlePath))) { + return bundlePath; } - } - - /** - * Determines if a bound token should be requested by checking if any of the certificate's - * Subject Alternative Names (SANs) match allowed SPIFFE patterns. - */ - static boolean shouldRequestBoundToken(X509Certificate cert) { - try { - Collection> sans = cert.getSubjectAlternativeNames(); - if (sans == null) { - return false; - } - // Iterate through all Subject Alternative Names - for (List san : sans) { - // Check if the SAN entry is a URI (type 6) - if (san.size() >= 2 - && san.get(0) instanceof Integer - && (Integer) san.get(0) == SAN_URI_TYPE) { - Object value = san.get(1); - if (value instanceof String) { - String uri = (String) value; - // Check if the URI starts with "spiffe://" - if (uri.startsWith(SPIFFE_SCHEME_PREFIX)) { - String withoutScheme = uri.substring(SPIFFE_SCHEME_PREFIX.length()); - int slashIndex = withoutScheme.indexOf('/'); - // Extract the trust domain (part before the first slash) - String trustDomain = - (slashIndex == -1) - ? withoutScheme - : withoutScheme.substring(0, slashIndex); - // Match the trust domain against allowed agent patterns - for (Pattern pattern : AGENT_IDENTITY_SPIFFE_PATTERNS) { - if (pattern.matcher(trustDomain).matches()) { - return true; - } - } - } - } - } - } - } catch (CertificateParsingException e) { - LOGGER.warn("Failed to parse Subject Alternative Names from certificate", e); + if (checkExistsOrAccessDenied(Paths.get(certOnlyPath))) { + return certOnlyPath; } + } catch (java.nio.file.AccessDeniedException e) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Permission denied reading well-known certificates. Falling back to unbound" + + " token."); + return null; + } catch (Exception e) { + // Fall through to retry + } + if (!warned) { + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + String.format( + "Well-known certificate file not found at %s. Retrying for up to %d" + " seconds.", + wellKnownDir, TOTAL_TIMEOUT_MS / 1000)); + warned = true; + } + try { + timeService.sleep(sleepInterval); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new IOException("Interrupted while waiting for well-known certificate files.", e); + } + } + throw new IOException( + "Unable to find well-known certificate file for bound token request after multiple" + + " retries."); + } + + /** Reads the full certificate chain from the specified path as a string. */ + static String readCertificateChain(String certPath) throws IOException { + return new String(Files.readAllBytes(Paths.get(certPath)), StandardCharsets.UTF_8); + } + + /** + * Verifies that the private key corresponds to the public key in the certificate by performing a + * test signature and verification. + */ + static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { + try { + byte[] data = "verification-data".getBytes(StandardCharsets.UTF_8); + + String keyAlgorithm = cert.getPublicKey().getAlgorithm(); + String sigAlg; + if ("RSA".equals(keyAlgorithm)) { + sigAlg = "SHA256withRSA"; + } else if ("EC".equals(keyAlgorithm)) { + sigAlg = "SHA256withECDSA"; + } else { + throw new IllegalArgumentException("Unsupported key algorithm: " + keyAlgorithm); + } + + Signature signer = Signature.getInstance(sigAlg); + signer.initSign(privateKey); + signer.update(data); + byte[] signature = signer.sign(); + + Signature verifier = Signature.getInstance(sigAlg); + verifier.initVerify(cert.getPublicKey()); + verifier.update(data); + + return verifier.verify(signature); + } catch (Exception e) { + LOGGER.warn("Key pair verification failed", e); + return false; + } + } + + /** Reads the private key from the specified path using PKCS8 format. */ + static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOException { + String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); + OAuth2Utils.Pkcs8Algorithm pkcs8Alg = + "EC".equals(algorithm) ? OAuth2Utils.Pkcs8Algorithm.EC : OAuth2Utils.Pkcs8Algorithm.RSA; + return OAuth2Utils.privateKeyFromPkcs8(keyPem, pkcs8Alg); + } + + /** + * Determines if mTLS should be enabled based on environment variables and certificate presence. + */ + static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) throws IOException { + String useClientCert = envReader.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); + + // Case 1: Explicitly enabled via environment variable + if ("true".equalsIgnoreCase(useClientCert)) { + if (certsPresent) { + // Certs are available, enable mTLS + return true; + } + if (configExists) { + // Config exists but files are missing - fail fast + throw new IOException( + "Certificate intent established via config, but cert files are missing."); + } + // Neither exist, do not enable + return false; + } + // Case 2: Explicitly disabled via environment variable + else if ("false".equalsIgnoreCase(useClientCert)) { + if (certsPresent) { + // Warn that we are ignoring present certs because it was explicitly disabled + Slf4jUtils.log( + LOGGER, + org.slf4j.event.Level.WARN, + Collections.emptyMap(), + "Token binding protection is disabled because mTLS was explicitly disabled" + + " via GOOGLE_API_USE_CLIENT_CERTIFICATE."); return false; + } + return false; } - - @VisibleForTesting - public static void setEnvReader(EnvReader reader) { - envReader = reader; + // Case 3: Environment variable is unset + else { + if (certsPresent) { + // Infer mTLS is enabled because certs are present + return true; + } + if (configExists) { + // Config exists but files are missing - fail fast + throw new IOException( + "Certificate intent inferred via config, but cert files are missing."); + } + // Neither cert-config nor certs exist, do not enable + return false; } + } - @VisibleForTesting - static void setTimeService(TimeService service) { - timeService = service; + /** Retrieves the bound token payload (certificate chain) if applicable. */ + static String getBoundTokenPayload() throws IOException { + CertInfo info = getAgentIdentityCertInfo(); + if (info != null && shouldRequestBoundToken(info.getCertificate())) { + return info.getCertContent(); } - - @VisibleForTesting - static void resetTimeService() { - timeService = - new TimeService() { - @Override - public long currentTimeMillis() { - return System.currentTimeMillis(); - } - - @Override - public void sleep(final long millis) throws InterruptedException { - Thread.sleep(millis); - } - }; + return null; + } + + @SuppressWarnings("unchecked") + /** Extracts the certificate and private key paths from the JSON configuration file. */ + private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigPath) + throws IOException { + try (InputStream stream = Files.newInputStream(Paths.get(certConfigPath))) { + JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); + GenericJson config = parser.parseAndClose(stream, StandardCharsets.UTF_8, GenericJson.class); + Object certConfigsObj = config.get("cert_configs"); + if (certConfigsObj instanceof Map) { + Map certConfigs = (Map) certConfigsObj; + Object workloadObj = certConfigs.get("workload"); + if (workloadObj instanceof Map) { + Map workload = (Map) workloadObj; + String certPath = null; + String keyPath = null; + if (workload.get("cert_path") instanceof String) { + certPath = (String) workload.get("cert_path"); + } + if (workload.get("key_path") instanceof String) { + keyPath = (String) workload.get("key_path"); + } + return new ResolvedCertAndKeyPaths(certPath, keyPath); + } + } + } catch (java.nio.file.AccessDeniedException e) { + throw e; + } catch (Exception e) { + throw new IOException("Failed to parse Agent Identity config JSON", e); + } + return null; + } + + /** Parses the X509 certificate from the specified content string. */ + private static X509Certificate parseCertificateContent(String certContent) throws IOException { + try (InputStream stream = + new java.io.ByteArrayInputStream(certContent.getBytes(StandardCharsets.UTF_8))) { + CertificateFactory cf = CertificateFactory.getInstance("X.509"); + return (X509Certificate) cf.generateCertificate(stream); + } catch (GeneralSecurityException e) { + throw new IOException( + "Failed to parse Agent Identity certificate for bound token request.", e); + } + } + + /** + * Determines if a bound token should be requested by checking if any of the certificate's Subject + * Alternative Names (SANs) match allowed SPIFFE patterns. + */ + static boolean shouldRequestBoundToken(X509Certificate cert) { + try { + Collection> sans = cert.getSubjectAlternativeNames(); + if (sans == null) { + return false; + } + // Iterate through all Subject Alternative Names + for (List san : sans) { + // Check if the SAN entry is a URI (type 6) + if (san.size() >= 2 + && san.get(0) instanceof Integer + && (Integer) san.get(0) == SAN_URI_TYPE) { + Object value = san.get(1); + if (value instanceof String) { + String uri = (String) value; + // Check if the URI starts with "spiffe://" + if (uri.startsWith(SPIFFE_SCHEME_PREFIX)) { + String withoutScheme = uri.substring(SPIFFE_SCHEME_PREFIX.length()); + int slashIndex = withoutScheme.indexOf('/'); + // Extract the trust domain (part before the first slash) + String trustDomain = + (slashIndex == -1) ? withoutScheme : withoutScheme.substring(0, slashIndex); + // Match the trust domain against allowed agent patterns + for (Pattern pattern : AGENT_IDENTITY_SPIFFE_PATTERNS) { + if (pattern.matcher(trustDomain).matches()) { + return true; + } + } + } + } + } + } + } catch (CertificateParsingException e) { + LOGGER.warn("Failed to parse Subject Alternative Names from certificate", e); } + return false; + } + + @VisibleForTesting + public static void setEnvReader(EnvReader reader) { + envReader = reader; + } + + @VisibleForTesting + static void setTimeService(TimeService service) { + timeService = service; + } + + @VisibleForTesting + static void resetTimeService() { + timeService = + new TimeService() { + @Override + public long currentTimeMillis() { + return System.currentTimeMillis(); + } + + @Override + public void sleep(final long millis) throws InterruptedException { + Thread.sleep(millis); + } + }; + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java index de93b8b52d7b..e1e6dea70fc7 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java @@ -116,14 +116,17 @@ public void shouldRequestBoundToken_invalidFormat_returnsFalse() throws Certific } @Test - public void shouldRequestBoundToken_certificateParsingException_returnsFalse() throws java.security.cert.CertificateParsingException { + public void shouldRequestBoundToken_certificateParsingException_returnsFalse() + throws java.security.cert.CertificateParsingException { X509Certificate mockCert = mock(X509Certificate.class); - when(mockCert.getSubjectAlternativeNames()).thenThrow(new java.security.cert.CertificateParsingException()); + when(mockCert.getSubjectAlternativeNames()) + .thenThrow(new java.security.cert.CertificateParsingException()); assertFalse(AgentIdentityUtils.shouldRequestBoundToken(mockCert)); } @Test - public void shouldRequestBoundToken_nonUriSan_returnsFalse() throws java.security.cert.CertificateParsingException { + public void shouldRequestBoundToken_nonUriSan_returnsFalse() + throws java.security.cert.CertificateParsingException { X509Certificate mockCert = mock(X509Certificate.class); List dnsSan = Arrays.asList(2, "www.example.com"); when(mockCert.getSubjectAlternativeNames()).thenReturn(Collections.>singleton(dnsSan)); diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 68a546abbed9..d17efca074e2 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -59,10 +59,10 @@ import com.google.auth.http.HttpTransportFactory; import com.google.auth.oauth2.DefaultCredentialsProviderTest.MockRequestCountingTransportFactory; import java.io.ByteArrayInputStream; -import java.io.InputStream; import java.io.File; import java.io.FileNotFoundException; import java.io.IOException; +import java.io.InputStream; import java.net.URI; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -1277,6 +1277,7 @@ InputStream readStream(File file) throws FileNotFoundException { assertTrue(isOnGce); assertEquals(1, transportFactory.transport.getRequestCount()); } + @Test void refreshAccessToken_agentConfigMissingFile_throws() throws IOException { envProvider.setEnv("GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES", "true"); @@ -1312,10 +1313,11 @@ public void sleep(long millis) { private void setupCertAndKeyConfig() throws IOException { java.nio.file.Path certSource = null; try { - certSource = java.nio.file.Paths.get( - ComputeEngineCredentialsTest.class - .getResource("/agent/agent_spiffe_cert.pem") - .toURI()); + certSource = + java.nio.file.Paths.get( + ComputeEngineCredentialsTest.class + .getResource("/agent/agent_spiffe_cert.pem") + .toURI()); } catch (java.net.URISyntaxException e) { throw new IOException("Failed to load test resource", e); } @@ -1324,10 +1326,11 @@ private void setupCertAndKeyConfig() throws IOException { java.nio.file.Path keySource = null; try { - keySource = java.nio.file.Paths.get( - ComputeEngineCredentialsTest.class - .getResource("/agent/agent_spiffe_key.pem") - .toURI()); + keySource = + java.nio.file.Paths.get( + ComputeEngineCredentialsTest.class + .getResource("/agent/agent_spiffe_key.pem") + .toURI()); } catch (java.net.URISyntaxException e) { throw new IOException("Failed to load test resource", e); } From 0649e4920f629e4f9143a19a5948107a8073e952 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Wed, 29 Jul 2026 19:59:35 +0000 Subject: [PATCH 19/19] docs(oauth2): Make AgentIdentityUtils Javadoc comments proper and fulfill CI formatting rules --- .../auth/oauth2/AgentIdentityUtils.java | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index f36c93a35557..08f8ad62f119 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -127,7 +127,7 @@ static void setWellKnownDir(final String dir) { public interface EnvReader { /** Javadoc. */ - String getEnv(String name); + String getEnv(final String name); } /** Javadoc. */ @@ -241,7 +241,8 @@ static CertInfo getAgentIdentityCertInfo() throws IOException { * Resolves the paths for the certificate and private key based on the config path or well-known * locations. */ - static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(String certConfigPath) throws IOException { + static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(final String certConfigPath) + throws IOException { String certPath = null; String keyPath = null; @@ -281,7 +282,8 @@ static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(String certConfigPath) thr /** * Loads the certificate and private key, and verifies that they match if they are separate files. */ - static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws IOException { + static CertInfo loadAndVerifyCredentials(final String certPath, final String keyPath) + throws IOException { X509Certificate cert = null; PrivateKey privateKey = null; String certContent = null; @@ -353,7 +355,7 @@ static CertInfo loadAndVerifyCredentials(String certPath, String keyPath) throws } /** Checks if a file exists, throwing AccessDeniedException if permission is denied. */ - private static boolean checkExistsOrAccessDenied(java.nio.file.Path path) + private static boolean checkExistsOrAccessDenied(final java.nio.file.Path path) throws java.nio.file.AccessDeniedException { try { Files.readAttributes(path, java.nio.file.attribute.BasicFileAttributes.class); @@ -375,7 +377,7 @@ private static boolean isTokenBindingEnabled() { * Reads the certificate path from the config file with retry logic to handle rotation race * conditions. */ - private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(String certConfigPath) + private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(final String certConfigPath) throws IOException { boolean warned = false; for (long sleepInterval : POLLING_INTERVALS) { @@ -476,7 +478,7 @@ private static String getWellKnownCertificatePathWithRetry() throws IOException } /** Reads the full certificate chain from the specified path as a string. */ - static String readCertificateChain(String certPath) throws IOException { + static String readCertificateChain(final String certPath) throws IOException { return new String(Files.readAllBytes(Paths.get(certPath)), StandardCharsets.UTF_8); } @@ -484,7 +486,7 @@ static String readCertificateChain(String certPath) throws IOException { * Verifies that the private key corresponds to the public key in the certificate by performing a * test signature and verification. */ - static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { + static boolean verifyKeyPair(final X509Certificate cert, final PrivateKey privateKey) { try { byte[] data = "verification-data".getBytes(StandardCharsets.UTF_8); @@ -515,7 +517,8 @@ static boolean verifyKeyPair(X509Certificate cert, PrivateKey privateKey) { } /** Reads the private key from the specified path using PKCS8 format. */ - static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOException { + static PrivateKey readPrivateKey(final String keyPath, final String algorithm) + throws IOException { String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8); OAuth2Utils.Pkcs8Algorithm pkcs8Alg = "EC".equals(algorithm) ? OAuth2Utils.Pkcs8Algorithm.EC : OAuth2Utils.Pkcs8Algorithm.RSA; @@ -525,7 +528,8 @@ static PrivateKey readPrivateKey(String keyPath, String algorithm) throws IOExce /** * Determines if mTLS should be enabled based on environment variables and certificate presence. */ - static boolean shouldEnableMtls(boolean certsPresent, boolean configExists) throws IOException { + static boolean shouldEnableMtls(final boolean certsPresent, final boolean configExists) + throws IOException { String useClientCert = envReader.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); // Case 1: Explicitly enabled via environment variable @@ -583,7 +587,7 @@ static String getBoundTokenPayload() throws IOException { @SuppressWarnings("unchecked") /** Extracts the certificate and private key paths from the JSON configuration file. */ - private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigPath) + private static ResolvedCertAndKeyPaths extractPathsFromConfig(final String certConfigPath) throws IOException { try (InputStream stream = Files.newInputStream(Paths.get(certConfigPath))) { JsonObjectParser parser = new JsonObjectParser(OAuth2Utils.JSON_FACTORY); @@ -614,7 +618,8 @@ private static ResolvedCertAndKeyPaths extractPathsFromConfig(String certConfigP } /** Parses the X509 certificate from the specified content string. */ - private static X509Certificate parseCertificateContent(String certContent) throws IOException { + private static X509Certificate parseCertificateContent(final String certContent) + throws IOException { try (InputStream stream = new java.io.ByteArrayInputStream(certContent.getBytes(StandardCharsets.UTF_8))) { CertificateFactory cf = CertificateFactory.getInstance("X.509"); @@ -629,7 +634,7 @@ private static X509Certificate parseCertificateContent(String certContent) throw * Determines if a bound token should be requested by checking if any of the certificate's Subject * Alternative Names (SANs) match allowed SPIFFE patterns. */ - static boolean shouldRequestBoundToken(X509Certificate cert) { + static boolean shouldRequestBoundToken(final X509Certificate cert) { try { Collection> sans = cert.getSubjectAlternativeNames(); if (sans == null) {