diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/DefaultMtlsProviderFactory.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/DefaultMtlsProviderFactory.java
index 8538f10c0d48..7b9b824d5e87 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/DefaultMtlsProviderFactory.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/DefaultMtlsProviderFactory.java
@@ -40,9 +40,10 @@ public class DefaultMtlsProviderFactory {
/**
* Creates an instance of {@link MtlsProvider}. It first attempts to create an {@link
- * com.google.auth.mtls.X509Provider}. If the certificate source is unavailable, it falls back to
- * creating a {@link SecureConnectProvider}. If the secure connect provider also fails, it throws
- * a {@link com.google.auth.mtls.CertificateSourceUnavailableException}.
+ * com.google.auth.mtls.X509Provider}, which also falls back to the GKE credential bundle when no
+ * certificate configuration is present. If the certificate source is unavailable, it falls back
+ * to creating a {@link SecureConnectProvider}. If the secure connect provider also fails, it
+ * throws a {@link com.google.auth.mtls.CertificateSourceUnavailableException}.
*
* @return an instance of {@link MtlsProvider}.
* @throws com.google.auth.mtls.CertificateSourceUnavailableException if neither provider can be
@@ -53,7 +54,7 @@ public static MtlsProvider create() throws IOException {
// Note: The caller should handle CertificateSourceUnavailableException gracefully, since
// it is an expected error case. All other IOExceptions are unexpected and should be surfaced
// up the call stack.
- MtlsProvider mtlsProvider = new X509Provider();
+ MtlsProvider mtlsProvider = X509Provider.createWithGkeCredentialBundleFallback();
if (mtlsProvider.isAvailable()) {
return mtlsProvider;
}
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsKeyStoreUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsKeyStoreUtils.java
new file mode 100644
index 000000000000..d8f51c61935f
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsKeyStoreUtils.java
@@ -0,0 +1,173 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ * * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+
+package com.google.auth.mtls;
+
+import com.google.api.client.util.PemReader;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.nio.charset.StandardCharsets;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateException;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.security.spec.PKCS8EncodedKeySpec;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+import org.jspecify.annotations.NullMarked;
+
+/**
+ * Builds the mTLS {@link KeyStore} from PEM-encoded certificates and a private key.
+ *
+ *
This replaces {@code com.google.api.client.util.SecurityUtils#createMtlsKeyStore}, which keeps
+ * only the first certificate. Here the key entry holds every distinct certificate in the input, so
+ * the intermediate certificates are presented too. Some peers (for example, another workload that
+ * trusts only a root CA) need them to verify the client certificate. Because every certificate in
+ * the input is sent in the TLS handshake, the input should hold only the leaf certificate and its
+ * intermediates.
+ *
+ *
Everything else matches the upstream method: key store type (JKS), alias, empty password,
+ * accepted key format (PKCS#8 {@code PRIVATE KEY}), which private key is chosen, and error
+ * messages. Any input that the upstream method accepted is still accepted, even though more of it
+ * is now used: once a certificate and a private key have been found (the point where the upstream
+ * method stopped reading), a malformed later PEM section ends the input instead of failing, and an
+ * extra certificate that cannot be parsed is left out of the chain. Unlike the upstream method, the
+ * input is decoded as UTF-8 rather than the platform default charset, and it is read to the end.
+ */
+@NullMarked
+final class MtlsKeyStoreUtils {
+
+ private static final Logger LOGGER = Logger.getLogger(MtlsKeyStoreUtils.class.getName());
+
+ private MtlsKeyStoreUtils() {}
+
+ /**
+ * Creates a key store holding one private key entry.
+ *
+ * @param certAndKey PEM input containing one or more {@code CERTIFICATE} sections and a PKCS#8
+ * {@code PRIVATE KEY} section, in any order. The first certificate must be the one that
+ * matches the private key (the leaf); the remaining distinct certificates are kept in input
+ * order as the rest of the chain. If there are several private keys, the one used is the same
+ * as upstream: the last one before the first certificate, or else the first one after it. The
+ * stream is read to the end and is not closed.
+ * @throws IllegalArgumentException if the input has no certificate or no private key, or if a PEM
+ * section before both a certificate and a private key have been read is malformed
+ * @throws GeneralSecurityException if the first certificate or the private key cannot be parsed,
+ * or the key store cannot be created
+ * @throws IOException if the input cannot be read
+ */
+ static KeyStore createMtlsKeyStore(InputStream certAndKey)
+ throws GeneralSecurityException, IOException {
+ List certificates = new ArrayList<>();
+ byte[] privateKey = null;
+ PemReader reader = new PemReader(new InputStreamReader(certAndKey, StandardCharsets.UTF_8));
+ while (true) {
+ PemReader.Section section;
+ try {
+ section = reader.readNextSection();
+ } catch (IllegalArgumentException e) {
+ if (!certificates.isEmpty() && privateKey != null) {
+ // Malformed trailing content after a usable certificate and key: stop here, as the
+ // upstream method (which stopped reading at this point) would have.
+ break;
+ }
+ throw e;
+ }
+ if (section == null) {
+ break;
+ }
+ if ("CERTIFICATE".equals(section.getTitle())) {
+ byte[] certBytes = section.getBase64DecodedBytes();
+ if (!containsCertificate(certificates, certBytes)) {
+ certificates.add(certBytes);
+ }
+ } else if ("PRIVATE KEY".equals(section.getTitle())
+ && (privateKey == null || certificates.isEmpty())) {
+ // Same choice as upstream: a later key replaces an earlier one until the first certificate
+ // has been seen.
+ privateKey = section.getBase64DecodedBytes();
+ }
+ }
+ if (certificates.isEmpty()) {
+ throw new IllegalArgumentException("certificate is missing from certAndKey string");
+ }
+ if (privateKey == null) {
+ throw new IllegalArgumentException("private key is missing from certAndKey string");
+ }
+
+ CertificateFactory certFactory = CertificateFactory.getInstance("X.509");
+ List chain = new ArrayList<>(certificates.size());
+ chain.add(parseCertificate(certFactory, certificates.get(0)));
+ for (int i = 1; i < certificates.size(); i++) {
+ try {
+ chain.add(parseCertificate(certFactory, certificates.get(i)));
+ } catch (CertificateException | RuntimeException e) {
+ // The upstream method never parsed extra certificates; leave this one out rather than
+ // fail.
+ LOGGER.log(Level.FINE, "Skipping unparsable extra certificate in mTLS chain", e);
+ }
+ }
+ PrivateKey key =
+ KeyFactory.getInstance(chain.get(0).getPublicKey().getAlgorithm())
+ .generatePrivate(new PKCS8EncodedKeySpec(privateKey));
+
+ KeyStore keyStore = KeyStore.getInstance("JKS");
+ keyStore.load(null);
+ keyStore.setKeyEntry("alias", key, new char[] {}, chain.toArray(new X509Certificate[0]));
+ return keyStore;
+ }
+
+ private static boolean containsCertificate(List certificates, byte[] candidate) {
+ for (byte[] existing : certificates) {
+ if (Arrays.equals(existing, candidate)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
+ private static X509Certificate parseCertificate(CertificateFactory certFactory, byte[] der)
+ throws CertificateException {
+ Certificate cert = certFactory.generateCertificate(new ByteArrayInputStream(der));
+ if (cert == null) {
+ throw new CertificateException("Empty certificate");
+ }
+ return (X509Certificate) cert;
+ }
+}
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsUtils.java
index a5c4c0f86e77..4f0d4c034b6d 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsUtils.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsUtils.java
@@ -33,13 +33,19 @@
import com.google.api.core.InternalApi;
import com.google.auth.oauth2.EnvironmentProvider;
import com.google.auth.oauth2.PropertyProvider;
+import com.google.common.annotations.VisibleForTesting;
import com.google.common.base.Strings;
+import com.google.common.io.BaseEncoding;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.security.MessageDigest;
import java.util.Locale;
import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
/**
* Utility class for mTLS related operations.
@@ -53,10 +59,257 @@ public class MtlsUtils {
static final String WELL_KNOWN_CERTIFICATE_CONFIG_FILE = "certificate_config.json";
static final String CLOUDSDK_CONFIG_DIRECTORY = "gcloud";
+ /**
+ * Well-known path of the combined certificate chain and private key bundle that GKE delivers to
+ * pods with Agent Identity (Pod Certificates). Used as a fallback client certificate source when
+ * no certificate configuration file is configured or present.
+ */
+ static final String GKE_CREDENTIAL_BUNDLE_PATH =
+ "/var/run/secrets/workload-spiffe-credentials/x509.credential-bundle.private-key.pem";
+
+ // Overridable via setGkeCredentialBundlePathForTesting so unit tests can point to a temp file.
+ private static volatile String gkeCredentialBundlePath = GKE_CREDENTIAL_BUNDLE_PATH;
+
private MtlsUtils() {
// Prevent instantiation for Utility class
}
+ /**
+ * Returns {@code true} if a certificate configuration is configured or present: either {@code
+ * GOOGLE_API_CERTIFICATE_CONFIG} is set, or the well-known gcloud {@code certificate_config.json}
+ * file exists. When this returns {@code true}, the GKE credential bundle fallback is not used, so
+ * the certificate used for mTLS and the certificate bound to tokens always come from the same
+ * source.
+ */
+ public static boolean hasCertificateConfiguration(
+ EnvironmentProvider envProvider, PropertyProvider propProvider) {
+ if (!Strings.isNullOrEmpty(envProvider.getEnv(CERTIFICATE_CONFIGURATION_ENV_VARIABLE))) {
+ return true;
+ }
+ try {
+ return getWellKnownCertificateConfigFile(envProvider, propProvider).exists();
+ } catch (IOException e) {
+ // APPDATA missing on Windows, etc. No well-known configuration file.
+ return false;
+ }
+ }
+
+ /**
+ * Returns the GKE credential bundle path if no certificate configuration is configured or present
+ * (see {@link #hasCertificateConfiguration}) and the bundle exists and is readable; otherwise
+ * returns {@code null}.
+ */
+ static @Nullable String getGkeCredentialBundlePath(
+ EnvironmentProvider envProvider, PropertyProvider propProvider) {
+ if (hasCertificateConfiguration(envProvider, propProvider)) {
+ return null;
+ }
+ return findGkeCredentialBundle();
+ }
+
+ /**
+ * Returns {@code true} if the GKE credential bundle at {@code path} is a readable, non-empty
+ * regular file.
+ */
+ public static boolean isGkeCredentialBundleReady(String path) {
+ File bundle = new File(path);
+ return bundle.isFile() && bundle.canRead() && bundle.length() > 0;
+ }
+
+ /**
+ * Returns the GKE credential bundle path if it is a readable, non-empty regular file, otherwise
+ * null.
+ */
+ private static @Nullable String findGkeCredentialBundle() {
+ String path = gkeCredentialBundlePath;
+ return isGkeCredentialBundleReady(path) ? path : null;
+ }
+
+ /** Overrides the GKE credential bundle path for testing; {@code null} restores the default. */
+ @VisibleForTesting
+ static void setGkeCredentialBundlePathForTesting(@Nullable String path) {
+ gkeCredentialBundlePath = path != null ? path : GKE_CREDENTIAL_BUNDLE_PATH;
+ }
+
+ private static boolean isUseClientCertificateExplicitlyDisabled(
+ @Nullable String useClientCertificate) {
+ return !Strings.isNullOrEmpty(useClientCertificate)
+ && !"true".equalsIgnoreCase(useClientCertificate);
+ }
+
+ /**
+ * Returns if mutual TLS client certificate should be used. Returns true if valid workload
+ * certificates are configured or if GOOGLE_API_USE_CLIENT_CERTIFICATE is explicitly set to true
+ * (e.g. for Enterprise Certificate Proxy or custom MtlsProviders), unless explicitly disabled via
+ * GOOGLE_API_USE_CLIENT_CERTIFICATE (set to a non-empty value other than "true").
+ */
+ public static boolean useMtlsClientCertificate(
+ EnvironmentProvider envProvider, PropertyProvider propProvider) {
+ String rawVal = envProvider.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE");
+ String useClientCertificate = rawVal != null ? rawVal.trim() : null;
+ if (isUseClientCertificateExplicitlyDisabled(useClientCertificate)) {
+ return false;
+ }
+ if (getWorkloadCertPath(envProvider, propProvider) != null) {
+ return true;
+ }
+ return "true".equalsIgnoreCase(useClientCertificate);
+ }
+
+ /**
+ * Resolves and returns the path to the mutual TLS client certificate, or null if none should be
+ * used.
+ *
+ *
Possible outcomes:
+ *
+ *
+ *
Non-null {@link String} (Valid happy path): A valid workload certificate
+ * configuration was found and both the certificate and private key files exist and are
+ * readable; or no certificate configuration is set or present and the GKE credential bundle
+ * ({@code /var/run/secrets/workload-spiffe-credentials/
+ * x509.credential-bundle.private-key.pem}) exists and is readable, in which case the bundle
+ * path is returned.
+ *
{@link IllegalStateException} (Invalid state - fail closed): An explicit {@code
+ * GOOGLE_API_CERTIFICATE_CONFIG} path or an existing default well-known certificate
+ * configuration file is missing, unreadable, malformed, or references missing/unreadable
+ * certificate or private key files. This is treated as an unrecoverable misconfiguration.
+ *
{@code null} (Safe fallback / fail open): Client certificates are explicitly
+ * disabled via {@code GOOGLE_API_USE_CLIENT_CERTIFICATE} (any non-empty value other than
+ * {@code "true"}), no explicit configuration is set and neither the default well-known
+ * configuration file nor a readable GKE credential bundle exists on disk, or the
+ * configuration specifies an non-workload source (e.g., ECP/PKCS11 without a {@code
+ * workload} section). Callers can proceed without workload certificate file polling.
+ *
+ */
+ public static @Nullable String getWorkloadCertPath(
+ EnvironmentProvider envProvider, PropertyProvider propProvider) {
+ String rawVal = envProvider.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE");
+ String useClientCertificate = rawVal != null ? rawVal.trim() : null;
+ if (isUseClientCertificateExplicitlyDisabled(useClientCertificate)) {
+ return null;
+ }
+
+ String explicitConfigPath = envProvider.getEnv(CERTIFICATE_CONFIGURATION_ENV_VARIABLE);
+
+ // 1. Explicit Configuration Path (Fail Closed)
+ if (!Strings.isNullOrEmpty(explicitConfigPath)) {
+ File configFile = new File(explicitConfigPath);
+ if (!configFile.exists()) {
+ throw new IllegalStateException(
+ "Certificate configuration file specified via GOOGLE_API_CERTIFICATE_CONFIG at '"
+ + explicitConfigPath
+ + "' does not exist.");
+ }
+ if (!configFile.isFile() || !configFile.canRead()) {
+ throw new IllegalStateException(
+ "Failed to read certificate configuration file specified via"
+ + " GOOGLE_API_CERTIFICATE_CONFIG at '"
+ + explicitConfigPath
+ + "'.");
+ }
+ WorkloadCertificateConfiguration config;
+ try {
+ config = getWorkloadCertificateConfiguration(envProvider, propProvider, explicitConfigPath);
+ } catch (CertificateSourceUnavailableException e) {
+ // ECP / PKCS11 configuration without workload section; safe fallback
+ return null;
+ } catch (Exception e) {
+ throw new IllegalStateException(
+ "Certificate configuration file specified via GOOGLE_API_CERTIFICATE_CONFIG at '"
+ + explicitConfigPath
+ + "' is malformed: "
+ + e.getMessage(),
+ e);
+ }
+ checkCertAndKeyFilesReadable(config, explicitConfigPath, false);
+ return config.getCertPath();
+ }
+
+ // 2. Implicit / Default gcloud Configuration Path
+ File defaultConfigFile = null;
+ try {
+ defaultConfigFile = getWellKnownCertificateConfigFile(envProvider, propProvider);
+ } catch (IOException e) {
+ // APPDATA missing on Windows, etc. Safe fallback.
+ }
+ if (defaultConfigFile != null && defaultConfigFile.exists()) {
+ if (!defaultConfigFile.isFile() || !defaultConfigFile.canRead()) {
+ throw new IllegalStateException(
+ "Default certificate configuration file at '"
+ + defaultConfigFile.getAbsolutePath()
+ + "' exists but could not be read.");
+ }
+ WorkloadCertificateConfiguration config = null;
+ try {
+ config = getWorkloadCertificateConfiguration(envProvider, propProvider, null);
+ } catch (CertificateSourceUnavailableException e) {
+ // ECP-only configuration without workload section; safe fallback
+ } catch (Exception e) {
+ throw new IllegalStateException(
+ "Default certificate configuration file at '"
+ + defaultConfigFile.getAbsolutePath()
+ + "' is malformed: "
+ + e.getMessage(),
+ e);
+ }
+ if (config != null) {
+ checkCertAndKeyFilesReadable(config, defaultConfigFile.getAbsolutePath(), true);
+ return config.getCertPath();
+ }
+ // A default configuration file exists without a workload section (e.g. ECP); it takes
+ // precedence over the GKE credential bundle.
+ return null;
+ }
+
+ // 3. GKE credential bundle (Pod Certificates), used only when no certificate configuration
+ // file is configured or present. The combined bundle serves as both certificate and key.
+ return findGkeCredentialBundle();
+ }
+
+ private static void checkCertAndKeyFilesReadable(
+ WorkloadCertificateConfiguration config, String configPath, boolean isDefaultConfig) {
+ File certFile = new File(config.getCertPath());
+ File keyFile = new File(config.getPrivateKeyPath());
+ if (!certFile.isFile() || !certFile.canRead() || !keyFile.isFile() || !keyFile.canRead()) {
+ String sourcePrefix =
+ isDefaultConfig
+ ? "referenced by default configuration '"
+ : "referenced by configuration '";
+ throw new IllegalStateException(
+ "Failed to read certificate/key file at '"
+ + config.getCertPath()
+ + "' or '"
+ + config.getPrivateKeyPath()
+ + "' "
+ + sourcePrefix
+ + configPath
+ + "'.");
+ }
+ }
+
+ /**
+ * Computes the lower-case SHA-256 hex fingerprint of the certificate file at {@code certPath}.
+ *
+ *
Unlike {@link #getWorkloadCertPath}, which validates configuration at channel initialization
+ * and fails closed on errors, this method is called dynamically at runtime during active RPCs to
+ * detect certificate rotations on disk. External certificate rotators may temporarily delete,
+ * truncate, or rewrite the certificate file mid-RPC. Returning {@code null} on read/digest
+ * exceptions (which callers normalize to {@code ""}) allows runtime refresh checks to ignore
+ * transient mid-write states and keep the active healthy channel without failing in-flight RPCs.
+ */
+ public static @Nullable String getCertificateFingerprint(@Nullable String certPath) {
+ if (certPath == null) {
+ return null;
+ }
+ try {
+ byte[] certBytes = Files.readAllBytes(Paths.get(certPath));
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(certBytes);
+ return BaseEncoding.base16().lowerCase().encode(digest);
+ } catch (Exception e) {
+ return null;
+ }
+ }
+
/**
* Returns the path to the client certificate file specified by the loaded workload certificate
* configuration.
@@ -65,14 +318,17 @@ private MtlsUtils() {
* @throws IOException if the certificate configuration cannot be found or loaded.
*/
public static String getCertificatePath(
- EnvironmentProvider envProvider, PropertyProvider propProvider, String certConfigPathOverride)
+ EnvironmentProvider envProvider,
+ PropertyProvider propProvider,
+ @Nullable String certConfigPathOverride)
throws IOException {
String certPath =
getWorkloadCertificateConfiguration(envProvider, propProvider, certConfigPathOverride)
.getCertPath();
if (Strings.isNullOrEmpty(certPath)) {
throw new CertificateSourceUnavailableException(
- "Certificate configuration loaded successfully, but does not contain a 'certificate_file' path.");
+ "Certificate configuration loaded successfully, but does not contain a"
+ + " 'cert_configs.workload.cert_path' path.");
}
return certPath;
}
@@ -92,7 +348,9 @@ public static String getCertificatePath(
* @throws IOException if the configuration file cannot be found, read, or parsed
*/
static WorkloadCertificateConfiguration getWorkloadCertificateConfiguration(
- EnvironmentProvider envProvider, PropertyProvider propProvider, String certConfigPathOverride)
+ EnvironmentProvider envProvider,
+ PropertyProvider propProvider,
+ @Nullable String certConfigPathOverride)
throws IOException {
File certConfig;
if (certConfigPathOverride != null) {
@@ -106,7 +364,7 @@ static WorkloadCertificateConfiguration getWorkloadCertificateConfiguration(
}
}
- if (!certConfig.isFile()) {
+ if (!certConfig.isFile() || !certConfig.canRead()) {
throw new CertificateSourceUnavailableException(
"Certificate configuration file does not exist or is not a file: "
+ certConfig.getAbsolutePath());
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/SecureConnectProvider.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/SecureConnectProvider.java
index 06294c611c7b..b030a992a9dd 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/SecureConnectProvider.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/SecureConnectProvider.java
@@ -32,10 +32,11 @@
import com.google.api.client.json.JsonParser;
import com.google.api.client.json.gson.GsonFactory;
-import com.google.api.client.util.SecurityUtils;
import com.google.api.core.InternalApi;
import com.google.common.annotations.VisibleForTesting;
import com.google.common.collect.ImmutableList;
+import java.io.ByteArrayInputStream;
+import java.io.ByteArrayOutputStream;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.IOException;
@@ -147,8 +148,25 @@ static KeyStore getKeyStore(InputStream metadata, ProcessProvider processProvide
"SecureConnect: Cert provider command failed with exit code: " + exitCode);
}
- // Create mTLS key store with the input certificates from shell command.
- return SecurityUtils.createMtlsKeyStore(process.getInputStream());
+ // Create mTLS key store with the input certificates from shell command. Read only the bytes
+ // already buffered now that the process has exited, so a background child process holding
+ // stdout open cannot block waiting for EOF.
+ ByteArrayOutputStream stdout = new ByteArrayOutputStream();
+ try (InputStream processOut = process.getInputStream()) {
+ if (processOut != null) {
+ for (int available = processOut.available();
+ available > 0;
+ available = processOut.available()) {
+ byte[] buffer = new byte[available];
+ int read = processOut.read(buffer);
+ if (read <= 0) {
+ break;
+ }
+ stdout.write(buffer, 0, read);
+ }
+ }
+ }
+ return MtlsKeyStoreUtils.createMtlsKeyStore(new ByteArrayInputStream(stdout.toByteArray()));
}
@VisibleForTesting
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/X509Provider.java b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/X509Provider.java
index 911c0fdcf922..fa7deeda21ca 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/X509Provider.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/X509Provider.java
@@ -30,7 +30,6 @@
package com.google.auth.mtls;
-import com.google.api.client.util.SecurityUtils;
import com.google.api.core.InternalApi;
import com.google.auth.oauth2.EnvironmentProvider;
import com.google.auth.oauth2.PropertyProvider;
@@ -38,6 +37,7 @@
import com.google.auth.oauth2.SystemPropertyProvider;
import java.io.File;
import java.io.FileInputStream;
+import java.io.FileNotFoundException;
import java.io.IOException;
import java.io.InputStream;
import java.io.SequenceInputStream;
@@ -54,7 +54,8 @@
public class X509Provider implements MtlsProvider {
private final EnvironmentProvider envProvider;
private final PropertyProvider propProvider;
- private final String certConfigPathOverride;
+ private final @Nullable String certConfigPathOverride;
+ private final boolean allowGkeCredentialBundle;
/**
* Creates an X509 provider with an override path for the certificate configuration, bypassing the
@@ -70,9 +71,39 @@ public X509Provider(
EnvironmentProvider envProvider,
PropertyProvider propProvider,
String certConfigPathOverride) {
+ this(envProvider, propProvider, certConfigPathOverride, false);
+ }
+
+ /**
+ * Creates an X509 provider that optionally falls back to the GKE credential bundle when no
+ * certificate configuration is present.
+ *
+ * @param envProvider environment provider used for environment variables
+ * @param propProvider property provider used for system properties
+ * @param certConfigPathOverride the path to read the certificate configuration from.
+ * @param allowGkeCredentialBundle whether to fall back to the GKE credential bundle when no
+ * certificate configuration is present and {@code certConfigPathOverride} is null.
+ */
+ X509Provider(
+ EnvironmentProvider envProvider,
+ PropertyProvider propProvider,
+ @Nullable String certConfigPathOverride,
+ boolean allowGkeCredentialBundle) {
this.envProvider = envProvider;
this.propProvider = propProvider;
this.certConfigPathOverride = certConfigPathOverride;
+ this.allowGkeCredentialBundle = allowGkeCredentialBundle;
+ }
+
+ /**
+ * Creates an X.509 provider that, in addition to the locations checked by {@link
+ * #X509Provider()}, falls back to the GKE credential bundle when no certificate configuration is
+ * present, so that the transport uses the same certificate that {@link
+ * MtlsUtils#getWorkloadCertPath} reports for certificate-bound tokens.
+ */
+ static X509Provider createWithGkeCredentialBundleFallback() {
+ return new X509Provider(
+ SystemEnvironmentProvider.getInstance(), SystemPropertyProvider.getInstance(), null, true);
}
/**
@@ -105,18 +136,62 @@ public X509Provider() {
*
The certificate config override path, if set.
*
The path pointed to by the "GOOGLE_API_CERTIFICATE_CONFIG" environment variable
*
The well known gcloud location for the certificate configuration file.
+ *
If none of the above is configured or present, and this provider was created with {@link
+ * #createWithGkeCredentialBundleFallback()}, the GKE credential bundle at {@code
+ * /var/run/secrets/workload-spiffe-credentials/x509.credential-bundle.private-key.pem},
+ * which contains both the certificate chain and the private key.
*
*
- * @return a KeyStore containing the X.509 certificate specified by the certificate configuration.
+ * @return a KeyStore containing the X.509 certificate chain and private key from the certificate
+ * source above.
* @throws CertificateSourceUnavailableException if the certificate source is unavailable (ex.
* missing configuration file)
* @throws IOException if a general I/O error occurs while creating the KeyStore
*/
@Override
public KeyStore getKeyStore() throws CertificateSourceUnavailableException, IOException {
- WorkloadCertificateConfiguration workloadCertConfig =
- MtlsUtils.getWorkloadCertificateConfiguration(
- envProvider, propProvider, certConfigPathOverride);
+ WorkloadCertificateConfiguration workloadCertConfig;
+ try {
+ workloadCertConfig =
+ MtlsUtils.getWorkloadCertificateConfiguration(
+ envProvider, propProvider, certConfigPathOverride);
+ } catch (CertificateSourceUnavailableException configUnavailable) {
+ // Only fall back to the GKE credential bundle when created for the mTLS transport
+ // (allowGkeCredentialBundle == true, rather than public X.509 WIF constructors) and when no
+ // explicit certificate configuration path override was passed to the constructor.
+ if (allowGkeCredentialBundle && certConfigPathOverride == null) {
+ // getWorkloadCertificateConfiguration also throws CertificateSourceUnavailableException
+ // when GOOGLE_API_CERTIFICATE_CONFIG is explicitly set or when a well-known
+ // certificate_config.json exists with only a non-workload (e.g. ECP) configuration;
+ // getGkeCredentialBundlePath checks hasCertificateConfiguration so we do not fall back to
+ // the GKE bundle in those cases.
+ String gkeCredentialBundlePath =
+ MtlsUtils.getGkeCredentialBundlePath(envProvider, propProvider);
+ if (gkeCredentialBundlePath != null) {
+ try (InputStream bundleStream = new FileInputStream(new File(gkeCredentialBundlePath))) {
+ return MtlsKeyStoreUtils.createMtlsKeyStore(bundleStream);
+ } catch (FileNotFoundException e) {
+ // The bundle was removed (e.g. mid-rotation) after the availability check.
+ throw new CertificateSourceUnavailableException(
+ "GKE credential bundle at "
+ + gkeCredentialBundlePath
+ + " was removed before it could be read (for example, during certificate"
+ + " rotation). Retry the request or verify that the GKE Pod Certificate volume"
+ + " is mounted.",
+ e);
+ } catch (Exception e) {
+ throw new IOException(
+ "X509Provider: Unable to load GKE credential bundle at "
+ + gkeCredentialBundlePath
+ + ". Verify that the file contains a valid PEM-encoded X.509 certificate chain"
+ + " and PKCS#8 private key, or set GOOGLE_API_USE_CLIENT_CERTIFICATE=false to"
+ + " disable mTLS.",
+ e);
+ }
+ }
+ }
+ throw configUnavailable;
+ }
// Read the certificate and private key file paths into streams.
try (InputStream certStream = new FileInputStream(new File(workloadCertConfig.getCertPath()));
@@ -126,7 +201,7 @@ public KeyStore getKeyStore() throws CertificateSourceUnavailableException, IOEx
new SequenceInputStream(certStream, privateKeyStream)) {
// Build a key store using the combined stream.
- return SecurityUtils.createMtlsKeyStore(certAndPrivateKeyStream);
+ return MtlsKeyStoreUtils.createMtlsKeyStore(certAndPrivateKeyStream);
} catch (CertificateSourceUnavailableException e) {
// Throw the CertificateSourceUnavailableException without wrapping.
throw e;
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityCacheUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityCacheUtils.java
new file mode 100644
index 000000000000..0dfa9d63c8a4
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityCacheUtils.java
@@ -0,0 +1,377 @@
+/*
+ * 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.auth.oauth2.AgentIdentityUtils.CertInfo;
+import com.google.common.base.Strings;
+import java.io.IOException;
+import java.nio.file.AccessDeniedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.BasicFileAttributes;
+import java.nio.file.attribute.FileTime;
+import java.util.Objects;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Utility class for in-memory caching and filesystem metadata validation of Agent Identity
+ * certificates and configuration files.
+ */
+@NullMarked
+final class AgentIdentityCacheUtils {
+
+ // In-memory cache of verified Agent Identity info to avoid redundant disk reads, X.509/PKCS#8
+ // parsing, and cryptographic signature verification on every token refresh when files are
+ // unchanged.
+ private static volatile @Nullable CachedAgentIdentityInfo cachedAgentIdentityInfo;
+
+ private AgentIdentityCacheUtils() {}
+
+ /**
+ * Captures filesystem metadata (path, modification time, size, and OS file key/inode) for a
+ * certificate, key, or configuration file to detect rotation on disk without re-parsing unchanged
+ * files.
+ */
+ static final class FileMetadata {
+ private final String path;
+ private final FileTime lastModifiedTime;
+ private final long size;
+ private final @Nullable Object fileKey;
+
+ private FileMetadata(
+ final String path,
+ final FileTime lastModifiedTime,
+ final long size,
+ final @Nullable Object fileKey) {
+ this.path = path;
+ this.lastModifiedTime = lastModifiedTime;
+ this.size = size;
+ this.fileKey = fileKey;
+ }
+
+ /** Returns the filesystem path captured by this metadata snapshot. */
+ String getPath() {
+ return path;
+ }
+
+ /** Reads the current filesystem attributes for the given file path. */
+ static @Nullable FileMetadata of(final @Nullable String pathStr) throws IOException {
+ if (Strings.isNullOrEmpty(pathStr)) {
+ return null;
+ }
+ Path path = Paths.get(pathStr);
+ BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
+ return new FileMetadata(pathStr, attrs.lastModifiedTime(), attrs.size(), attrs.fileKey());
+ }
+
+ /** Returns true if the given metadata matches this file's path, mtime, size, and file key. */
+ boolean matches(final @Nullable FileMetadata other) {
+ if (other == null) {
+ return false;
+ }
+ return Objects.equals(this.path, other.path)
+ && Objects.equals(this.lastModifiedTime, other.lastModifiedTime)
+ && this.size == other.size
+ && Objects.equals(this.fileKey, other.fileKey);
+ }
+
+ /**
+ * Re-reads the file's current attributes from disk and returns {@code true} if the file still
+ * exists on disk with the exact same modification time, size, and OS file key/inode as when
+ * this snapshot was captured.
+ */
+ boolean isUnchangedOnDisk() {
+ try {
+ return matches(FileMetadata.of(this.path));
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Returns {@code true} if {@code expectedPath} equals this snapshot's path and the file on disk
+ * is unchanged since this snapshot was captured.
+ */
+ boolean isUnchangedAtPath(final @Nullable String expectedPath) {
+ return Objects.equals(this.path, expectedPath) && isUnchangedOnDisk();
+ }
+ }
+
+ /**
+ * Stores an in-memory snapshot of verified Agent Identity certificate information and its
+ * associated filesystem metadata to avoid redundant disk I/O and cryptographic checks on
+ * subsequent token refreshes.
+ */
+ static final class CachedAgentIdentityInfo {
+ /**
+ * Filesystem metadata of the JSON certificate configuration file ({@code
+ * GOOGLE_API_CERTIFICATE_CONFIG}) when it was parsed, or {@code null} if the certificate was
+ * discovered from the well-known directory.
+ */
+ final @Nullable FileMetadata configMetadata;
+
+ /**
+ * Filesystem metadata of the X.509 certificate or combined credential bundle file when it was
+ * read.
+ */
+ final @Nullable FileMetadata certMetadata;
+
+ /**
+ * Filesystem metadata of the private key or combined credential bundle file when it was read,
+ * or {@code null} when {@link #shouldRequestBoundToken} is {@code false} (for non-agent
+ * certificates where the private key is not read).
+ */
+ final @Nullable FileMetadata keyMetadata;
+
+ /**
+ * Whether the parsed certificate's Subject Alternative Names matched an allowed Agent Identity
+ * SPIFFE trust domain pattern and therefore requires requesting a bound token.
+ */
+ final boolean shouldRequestBoundToken;
+
+ /**
+ * The verified {@link AgentIdentityUtils.CertInfo} (parsed {@code X509Certificate} and PEM
+ * certificate chain) when {@link #shouldRequestBoundToken} is {@code true}, or {@code null}
+ * when {@link #shouldRequestBoundToken} is {@code false}.
+ */
+ final @Nullable CertInfo certInfo;
+
+ /**
+ * Whether {@link #certMetadata} points to the well-known GKE credential bundle path ({@link
+ * AgentIdentityUtils#isGkeCredentialBundle}).
+ */
+ final boolean isGkeCredentialBundle;
+
+ /**
+ * Constructs a cached snapshot of Agent Identity evaluation state and file metadata.
+ *
+ * @param configMetadata filesystem metadata of the certificate config file, or {@code null}
+ * when discovered from the well-known directory
+ * @param certMetadata filesystem metadata of the certificate or bundle file
+ * @param keyMetadata filesystem metadata of the private key or bundle file, or {@code null}
+ * when {@code shouldRequestBoundToken} is {@code false}
+ * @param shouldRequestBoundToken {@code true} if the certificate matched an Agent Identity
+ * SPIFFE pattern and requires a bound token; {@code false} otherwise
+ * @param certInfo the verified {@link AgentIdentityUtils.CertInfo} when {@code
+ * shouldRequestBoundToken} is {@code true}, or {@code null} otherwise
+ */
+ CachedAgentIdentityInfo(
+ final @Nullable FileMetadata configMetadata,
+ final @Nullable FileMetadata certMetadata,
+ final @Nullable FileMetadata keyMetadata,
+ final boolean shouldRequestBoundToken,
+ final @Nullable CertInfo certInfo) {
+ this.configMetadata = configMetadata;
+ this.certMetadata = certMetadata;
+ this.keyMetadata = keyMetadata;
+ this.shouldRequestBoundToken = shouldRequestBoundToken;
+ this.certInfo = certInfo;
+ this.isGkeCredentialBundle =
+ certMetadata != null && AgentIdentityUtils.isGkeCredentialBundle(certMetadata.getPath());
+ }
+
+ /**
+ * Returns {@code true} if this cached entry was discovered from the well-known directory (not a
+ * certificate config file) and has both certificate and key metadata available for fallback.
+ */
+ boolean canUseWellKnownFallback() {
+ return configMetadata == null && certMetadata != null && keyMetadata != null;
+ }
+ }
+
+ /** Returns the current in-memory cached Agent Identity info, or {@code null} if none. */
+ static @Nullable CachedAgentIdentityInfo getCachedAgentIdentityInfo() {
+ return cachedAgentIdentityInfo;
+ }
+
+ /** Clears the in-memory cached Agent Identity info. */
+ static void clearCachedAgentIdentityInfo() {
+ cachedAgentIdentityInfo = null;
+ }
+
+ /**
+ * Returns {@code true} if the cached snapshot is non-null and its config, certificate, and key
+ * file metadata still match the current files on disk.
+ */
+ static boolean isCachedInfoValid(
+ final @Nullable CachedAgentIdentityInfo cached, final @Nullable String certConfigPath) {
+ if (cached == null || cached.certMetadata == null) {
+ return false;
+ }
+ boolean hasConfigEnv = !Strings.isNullOrEmpty(certConfigPath);
+ if (hasConfigEnv) {
+ // When GOOGLE_API_CERTIFICATE_CONFIG is set, the cache must have been populated from that
+ // config file (configMetadata != null) and the config file on disk must be unchanged.
+ if (cached.configMetadata == null
+ || !cached.configMetadata.isUnchangedAtPath(certConfigPath)) {
+ return false;
+ }
+ } else {
+ // When GOOGLE_API_CERTIFICATE_CONFIG is unset, the cache must have been populated from the
+ // well-known directory (where configMetadata is null).
+ if (cached.configMetadata != null) {
+ return false;
+ }
+ if (cached.certMetadata.path.endsWith("certificates.pem")
+ && Files.exists(
+ Paths.get(AgentIdentityUtils.getWellKnownDir(), "credentialbundle.pem"))) {
+ // credentialbundle.pem takes precedence if added after certificates.pem was cached
+ return false;
+ }
+ }
+ if (!cached.certMetadata.isUnchangedOnDisk()) {
+ return false;
+ }
+ if (!cached.shouldRequestBoundToken) {
+ return true;
+ }
+ return cached.keyMetadata != null && cached.keyMetadata.isUnchangedOnDisk();
+ }
+
+ /**
+ * Checks whether the resolved {@code certPath}, {@code keyPath}, and {@code configMetaBefore}
+ * match the given cached snapshot.
+ *
+ *
Even after {@link #isCachedInfoValid} returns {@code false} at the start of {@code
+ * AgentIdentityUtils.getAgentIdentityCertInfo()}, this check can still hit in three cases:
+ *
+ *
+ *
The config file was deleted after caching while the cert and key files on disk remain
+ * unchanged ({@code getPathsFromConfigWithRetry} fell back to the cached paths and {@code
+ * configMetadata}, where {@code tryUpdateCache} will not overwrite the cache while the
+ * config file is missing).
+ *
Another thread completed rotation and updated {@code cachedAgentIdentityInfo} while the
+ * current thread was resolving paths.
+ *
Package-private {@code AgentIdentityUtils.loadAndVerifyCredentials(certPath, keyPath)}
+ * was invoked directly without going through {@code getAgentIdentityCertInfo()}.
+ *
+ */
+ static boolean isPostResolutionCacheHit(
+ final @Nullable CachedAgentIdentityInfo cached,
+ final @Nullable String certPath,
+ final @Nullable String keyPath,
+ final @Nullable FileMetadata configMetaBefore) {
+ if (cached == null || cached.certMetadata == null) {
+ return false;
+ }
+ boolean configMatches =
+ configMetaBefore == null
+ ? cached.configMetadata == null
+ : configMetaBefore.matches(cached.configMetadata);
+ if (!configMatches || !cached.certMetadata.isUnchangedAtPath(certPath)) {
+ return false;
+ }
+ if (!cached.shouldRequestBoundToken) {
+ return true;
+ }
+ return !Strings.isNullOrEmpty(keyPath)
+ && cached.keyMetadata != null
+ && cached.keyMetadata.isUnchangedAtPath(keyPath);
+ }
+
+ /**
+ * Returns the latest {@link CachedAgentIdentityInfo} if it contains a verified bound-token {@link
+ * AgentIdentityUtils.CertInfo}, or falls back to {@code initialCached}.
+ *
+ *
{@code initialCached} is captured at the start of {@code getAgentIdentityCertInfo()} so that
+ * if the cache is cleared or overwritten with a non-agent certificate during retry backoff, the
+ * caller still retains the last-known-good bound certificate snapshot, while preferring a fresher
+ * bound certificate if another thread completed rotation in the meantime.
+ */
+ static @Nullable CachedAgentIdentityInfo getLatestOrInitialCache(
+ final @Nullable CachedAgentIdentityInfo initialCached) {
+ CachedAgentIdentityInfo latest = cachedAgentIdentityInfo;
+ return (latest != null && latest.shouldRequestBoundToken && latest.certInfo != null)
+ ? latest
+ : initialCached;
+ }
+
+ /**
+ * Safely updates the in-memory cache only if file metadata remained stable across the read/verify
+ * operation. Returns {@code true} if the post-read cert/key metadata matched the pre-read
+ * metadata, or {@code false} if the cert or key changed or disappeared mid-read. If only the
+ * config file changed, returns {@code true} without updating the cache, so the next refresh
+ * re-resolves paths from the new config.
+ */
+ static boolean tryUpdateCache(
+ final @Nullable FileMetadata configMetaBefore,
+ final @Nullable FileMetadata certMetaBefore,
+ final @Nullable FileMetadata keyMetaBefore,
+ final boolean shouldRequestBoundToken,
+ final @Nullable CertInfo certInfo) {
+ if (certMetaBefore == null || !certMetaBefore.isUnchangedOnDisk()) {
+ return false;
+ }
+ boolean configStable = configMetaBefore == null || configMetaBefore.isUnchangedOnDisk();
+ if (!shouldRequestBoundToken) {
+ if (configStable) {
+ cachedAgentIdentityInfo =
+ new CachedAgentIdentityInfo(configMetaBefore, certMetaBefore, null, false, null);
+ }
+ return true;
+ }
+ if (keyMetaBefore != null && keyMetaBefore.isUnchangedOnDisk()) {
+ if (configStable) {
+ cachedAgentIdentityInfo =
+ new CachedAgentIdentityInfo(
+ configMetaBefore, certMetaBefore, keyMetaBefore, true, certInfo);
+ }
+ return true;
+ }
+ return false;
+ }
+
+ /**
+ * Checks if a file exists, is a non-empty regular file, and is readable, throwing {@link
+ * AccessDeniedException} if permission is denied.
+ */
+ static boolean checkExistsOrAccessDenied(final Path path) throws AccessDeniedException {
+ try {
+ BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
+ if (!attrs.isRegularFile()) {
+ return false;
+ }
+ if (!Files.isReadable(path)) {
+ if (!Files.exists(path)) {
+ return false; // Deleted mid-rotation; let the caller retry.
+ }
+ throw new AccessDeniedException(path.toString());
+ }
+ return attrs.size() > 0;
+ } catch (AccessDeniedException e) {
+ throw e;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+}
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityCertificateValidationUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityCertificateValidationUtils.java
new file mode 100644
index 000000000000..e39b9bc36480
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityCertificateValidationUtils.java
@@ -0,0 +1,248 @@
+/*
+ * 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.common.collect.ImmutableList;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Paths;
+import java.security.GeneralSecurityException;
+import java.security.KeyFactory;
+import java.security.PrivateKey;
+import java.security.PublicKey;
+import java.security.Signature;
+import java.security.cert.CertificateFactory;
+import java.security.cert.CertificateParsingException;
+import java.security.cert.X509Certificate;
+import java.security.spec.X509EncodedKeySpec;
+import java.util.Collection;
+import java.util.Collections;
+import java.util.List;
+import java.util.Locale;
+import java.util.logging.Level;
+import java.util.regex.Matcher;
+import java.util.regex.Pattern;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/**
+ * Utility class for parsing, validating, and verifying X.509 certificates, SPIFFE IDs, and PKCS#8
+ * private keys for Agent Identity token binding.
+ */
+@NullMarked
+final class AgentIdentityCertificateValidationUtils {
+
+ private static final LoggerProvider LOGGER_PROVIDER =
+ LoggerProvider.forClazz(AgentIdentityCertificateValidationUtils.class);
+
+ // Allowed SPIFFE trust domain patterns for agentic identities.
+ private static final List AGENT_IDENTITY_SPIFFE_PATTERNS =
+ ImmutableList.of(
+ Pattern.compile("^agents\\.global\\.org-\\d+\\.system\\.id\\.goog$"),
+ Pattern.compile("^agents\\.global\\.proj-\\d+\\.system\\.id\\.goog$"),
+ Pattern.compile("^agents-nonprod\\.global\\.org-\\d+\\.system\\.id\\.goog$"),
+ Pattern.compile("^agents-nonprod\\.global\\.proj-\\d+\\.system\\.id\\.goog$"));
+
+ // Subject Alternative Name (SAN) type for URI as defined in RFC 5280 Section 4.2.1.6.
+ private static final int SAN_URI_TYPE = 6;
+
+ private static final String SPIFFE_SCHEME_PREFIX = "spiffe://";
+
+ private static final byte[] VERIFICATION_DATA =
+ "verification-data".getBytes(StandardCharsets.UTF_8);
+
+ private AgentIdentityCertificateValidationUtils() {}
+
+ /** Thrown for key algorithms that can never be verified, so callers fail without retrying. */
+ static final class UnsupportedKeyAlgorithmException extends IOException {
+ private static final long serialVersionUID = 1L;
+
+ UnsupportedKeyAlgorithmException(final @Nullable String algorithm) {
+ super("Unsupported key algorithm: " + algorithm);
+ }
+ }
+
+ /**
+ * Thrown when a certificate PEM block contains malformed X.509 data, so callers fail fast without
+ * retrying.
+ */
+ static final class InvalidCertificateException extends IOException {
+ private static final long serialVersionUID = 1L;
+
+ InvalidCertificateException(final String message, final Throwable cause) {
+ super(message, cause);
+ }
+ }
+
+ /**
+ * Reads the full certificate chain from the specified path as a PEM string.
+ *
+ *
Extracts only the {@code -----BEGIN CERTIFICATE-----} blocks using {@link
+ * OAuth2Utils#PEM_CERT_PATTERN}, stripping any private keys or non-certificate data that may be
+ * present in a combined bundle file.
+ */
+ static String readCertificateChain(final String certPath) throws IOException {
+ byte[] certData = Files.readAllBytes(Paths.get(certPath));
+ String content = new String(certData, StandardCharsets.UTF_8);
+ Matcher matcher = OAuth2Utils.PEM_CERT_PATTERN.matcher(content);
+ StringBuilder certChain = new StringBuilder();
+ while (matcher.find()) {
+ certChain.append(matcher.group(0)).append("\n");
+ }
+ if (certChain.length() == 0) {
+ throw new IOException("No PEM certificates found in certificate file: " + certPath);
+ }
+ return certChain.toString();
+ }
+
+ /** Parses the X509 certificate from the specified content string. */
+ static X509Certificate parseCertificateContent(final String certContent) throws IOException {
+ try (InputStream stream =
+ new ByteArrayInputStream(certContent.getBytes(StandardCharsets.UTF_8))) {
+ CertificateFactory cf = CertificateFactory.getInstance("X.509");
+ return (X509Certificate) cf.generateCertificate(stream);
+ } catch (GeneralSecurityException e) {
+ throw new InvalidCertificateException(
+ "Failed to parse Agent Identity certificate for bound token request.", e);
+ }
+ }
+
+ /** Reads the private key from the specified path using PKCS8 format. */
+ static PrivateKey readPrivateKey(final String keyPath, final @Nullable String algorithm)
+ throws IOException {
+ String keyPem = new String(Files.readAllBytes(Paths.get(keyPath)), StandardCharsets.UTF_8);
+ OAuth2Utils.Pkcs8Algorithm pkcs8Alg;
+ if ("EC".equals(algorithm) || "ECDSA".equals(algorithm)) {
+ pkcs8Alg = OAuth2Utils.Pkcs8Algorithm.EC;
+ } else if ("RSA".equals(algorithm)) {
+ pkcs8Alg = OAuth2Utils.Pkcs8Algorithm.RSA;
+ } else {
+ throw new UnsupportedKeyAlgorithmException(algorithm);
+ }
+ return OAuth2Utils.privateKeyFromPkcs8(keyPem, pkcs8Alg);
+ }
+
+ /**
+ * Verifies that the private key corresponds to the public key in the certificate by performing a
+ * test signature and verification.
+ */
+ static boolean verifyKeyPair(final X509Certificate cert, final PrivateKey privateKey)
+ throws UnsupportedKeyAlgorithmException {
+ PublicKey publicKey = cert.getPublicKey();
+ String keyAlgorithm = publicKey.getAlgorithm();
+ String sigAlg;
+ if ("RSA".equals(keyAlgorithm)) {
+ sigAlg = "SHA256withRSA";
+ } else if ("EC".equals(keyAlgorithm) || "ECDSA".equals(keyAlgorithm)) {
+ sigAlg = "SHA256withECDSA";
+ } else {
+ throw new UnsupportedKeyAlgorithmException(keyAlgorithm);
+ }
+
+ try {
+ if ("ECDSA".equals(keyAlgorithm) && publicKey.getEncoded() != null) {
+ // SunEC rejects keys whose algorithm is "ECDSA" rather than "EC"; normalize the key.
+ publicKey =
+ KeyFactory.getInstance("EC")
+ .generatePublic(new X509EncodedKeySpec(publicKey.getEncoded()));
+ }
+
+ Signature signer = Signature.getInstance(sigAlg);
+ signer.initSign(privateKey);
+ signer.update(VERIFICATION_DATA);
+ byte[] signature = signer.sign();
+
+ Signature verifier = Signature.getInstance(sigAlg);
+ verifier.initVerify(publicKey);
+ verifier.update(VERIFICATION_DATA);
+
+ return verifier.verify(signature);
+ } catch (Exception e) {
+ LoggingUtils.log(
+ LOGGER_PROVIDER,
+ Level.FINE,
+ Collections.emptyMap(),
+ "Key pair verification failed: " + e.getMessage());
+ return false;
+ }
+ }
+
+ /**
+ * 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(final X509Certificate cert) {
+ Collection> sans;
+ try {
+ sans = cert.getSubjectAlternativeNames();
+ } catch (CertificateParsingException e) {
+ LoggingUtils.log(
+ LOGGER_PROVIDER,
+ Level.FINE,
+ Collections.emptyMap(),
+ "Failed to parse Subject Alternative Names from certificate: " + e.getMessage());
+ return false;
+ }
+ 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) with a String value
+ if (san.size() < 2
+ || !(san.get(0) instanceof Integer)
+ || (Integer) san.get(0) != SAN_URI_TYPE
+ || !(san.get(1) instanceof String)) {
+ continue;
+ }
+ String uri = ((String) san.get(1)).toLowerCase(Locale.US);
+ // Check if the URI starts with "spiffe://"
+ if (!uri.startsWith(SPIFFE_SCHEME_PREFIX)) {
+ continue;
+ }
+ 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;
+ }
+ }
+ }
+ return false;
+ }
+}
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..2bbe599df301
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java
@@ -0,0 +1,964 @@
+/*
+ * 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;
+import com.google.api.client.json.JsonObjectParser;
+import com.google.auth.mtls.MtlsUtils;
+import com.google.auth.oauth2.AgentIdentityCacheUtils.CachedAgentIdentityInfo;
+import com.google.auth.oauth2.AgentIdentityCacheUtils.FileMetadata;
+import com.google.auth.oauth2.AgentIdentityCertificateValidationUtils.InvalidCertificateException;
+import com.google.auth.oauth2.AgentIdentityCertificateValidationUtils.UnsupportedKeyAlgorithmException;
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.base.Strings;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.AccessDeniedException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.security.PrivateKey;
+import java.security.cert.X509Certificate;
+import java.util.Collections;
+import java.util.Map;
+import java.util.logging.Level;
+import org.jspecify.annotations.NullMarked;
+import org.jspecify.annotations.Nullable;
+
+/** Utility class for Agent Identity runtime certificate discovery and token binding. */
+@NullMarked
+final class AgentIdentityUtils {
+
+ private static final LoggerProvider LOGGER_PROVIDER =
+ LoggerProvider.forClazz(AgentIdentityUtils.class);
+
+ /**
+ * Environment variable pointing to the client certificate configuration file.
+ *
+ *
If set, certificate and key paths are resolved from the configuration file specified by this
+ * variable.
+ */
+ static final String GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG";
+
+ /**
+ * Environment variable to explicitly enable or disable runtime token binding. Defaults to true if
+ * unset.
+ */
+ static final String GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN =
+ "GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN";
+
+ /**
+ * Legacy Cloud Run environment variable to prevent agent token sharing for GCP services. Used as
+ * a fallback if {@link #GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN} is unset.
+ */
+ static final String GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES =
+ "GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES";
+
+ /**
+ * Environment variable to explicitly enable or disable client certificate authentication (mTLS).
+ *
+ *
When set to {@code "true"}, mTLS is enforced. When set to any other non-empty value (such as
+ * {@code "false"}), mTLS and token binding are disabled.
+ */
+ static final String GOOGLE_API_USE_CLIENT_CERTIFICATE = "GOOGLE_API_USE_CLIENT_CERTIFICATE";
+
+ private static volatile String wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/";
+
+ /**
+ * File name of the combined certificate chain and private key bundle that GKE delivers to the
+ * well-known directory for pods with Agent Identity (Pod Certificates).
+ *
+ *
When {@link #GOOGLE_API_CERTIFICATE_CONFIG} is unset and no well-known gcloud certificate
+ * configuration file exists, the presence of this bundle auto-enables mTLS on its own (no {@link
+ * #GOOGLE_API_USE_CLIENT_CERTIFICATE} required). It is never polled for, because GKE delivers it
+ * before the container starts and polling on its absence would delay every workload without Agent
+ * Identity. It must be a readable, non-empty file. When present and allowed, it takes precedence
+ * over the other well-known files, matching the certificate the transport presents.
+ */
+ static final String GKE_CREDENTIAL_BUNDLE_FILE = "x509.credential-bundle.private-key.pem";
+
+ private static volatile String gkeCredentialBundlePath =
+ Paths.get(wellKnownDir, GKE_CREDENTIAL_BUNDLE_FILE).toString();
+
+ // Retries for verifying certificate and private key matching during atomic key rotation.
+ private static final int CERT_KEY_MATCH_RETRIES = 3;
+
+ private static final long CERT_KEY_MATCH_RETRY_INTERVAL_MS = 100;
+
+ // Polling configuration for initial container startup credential file readiness.
+ // Matches Python google-auth implementation (_agent_identity_utils.py) for initial
+ // asynchronous credential delivery (50 * 100ms + 50 * 500ms = 30 seconds total).
+ 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 int TOTAL_POLL_CYCLES =
+ FAST_POLL_CYCLES
+ + (int)
+ ((TOTAL_TIMEOUT_MS - (FAST_POLL_CYCLES * FAST_POLL_INTERVAL_MS))
+ / SLOW_POLL_INTERVAL_MS);
+
+ private static volatile EnvironmentProvider environmentProvider =
+ SystemEnvironmentProvider.getInstance();
+
+ private static volatile PropertyProvider propertyProvider = SystemPropertyProvider.getInstance();
+
+ private static volatile TimeService timeService = Thread::sleep;
+
+ // Tracks whether initial container startup discovery has completed. The 30-second
+ // polling loop (TOTAL_POLL_CYCLES) is strictly limited to initial startup so subsequent
+ // token refreshes never block for 30 seconds.
+ private static volatile boolean initialStartupCompleted = false;
+
+ // Tracks whether the FINE-level message for explicitly disabled mTLS when certificates are
+ // present has already been logged in this process to prevent log spam on repeated token
+ // refreshes.
+ private static volatile boolean mtlsDisabledLogged = false;
+
+ // Tracks whether mTLS has already been evaluated as explicitly disabled via
+ // GOOGLE_API_USE_CLIENT_CERTIFICATE, so later refreshes skip re-reading the config from disk.
+ private static volatile boolean mtlsDisabledEvaluated = false;
+
+ private AgentIdentityUtils() {}
+
+ @VisibleForTesting
+ @FunctionalInterface
+ interface TimeService {
+ /**
+ * Causes the currently executing thread to sleep for the specified number of milliseconds.
+ *
+ * @param millis the length of time to sleep in milliseconds
+ * @throws InterruptedException if interrupted while sleeping
+ */
+ void sleep(final long millis) throws InterruptedException;
+ }
+
+ /**
+ * Holds the parsed {@link X509Certificate} and cached PEM certificate chain content for token
+ * binding.
+ *
+ *
This class intentionally stores in-memory certificate content rather than file paths to
+ * prevent redundant filesystem reads and race conditions when binding tokens to requests.
+ */
+ static class CertInfo {
+ private final X509Certificate certificate;
+ private final String certContent;
+
+ CertInfo(final X509Certificate certificate, final String certContent) {
+ this.certificate = certificate;
+ this.certContent = certContent;
+ }
+
+ /** Returns the parsed {@link X509Certificate}. */
+ X509Certificate getCertificate() {
+ return certificate;
+ }
+
+ /** Returns the raw PEM certificate chain content. */
+ String getCertContent() {
+ return certContent;
+ }
+ }
+
+ /** Holds the resolved filesystem paths for the certificate and private key. */
+ private static final class ResolvedCertAndKeyPaths {
+ private final @Nullable String certPath;
+ private final @Nullable String keyPath;
+ private final boolean hasWorkloadConfig;
+ private final @Nullable FileMetadata configMetadata;
+
+ ResolvedCertAndKeyPaths(
+ final @Nullable String certPath,
+ final @Nullable String keyPath,
+ final boolean hasWorkloadConfig) {
+ this(certPath, keyPath, hasWorkloadConfig, null);
+ }
+
+ ResolvedCertAndKeyPaths(
+ final @Nullable String certPath,
+ final @Nullable String keyPath,
+ final boolean hasWorkloadConfig,
+ final @Nullable FileMetadata configMetadata) {
+ this.certPath = certPath;
+ this.keyPath = keyPath;
+ this.hasWorkloadConfig = hasWorkloadConfig;
+ this.configMetadata = configMetadata;
+ }
+
+ /** Returns the path to the certificate or bundle file. */
+ @Nullable String getCertPath() {
+ return certPath;
+ }
+
+ /** Returns the path to the private key file, or bundle path if combined. */
+ @Nullable String getKeyPath() {
+ return keyPath;
+ }
+
+ /** Returns whether a workload configuration was parsed from the certificate config file. */
+ boolean hasWorkloadConfig() {
+ return hasWorkloadConfig;
+ }
+
+ /** Returns the filesystem metadata of the config file when it was parsed, or null. */
+ @Nullable FileMetadata getConfigMetadata() {
+ return configMetadata;
+ }
+ }
+
+ /**
+ * Thrown when a workload configuration block is present in the certificate config file but is
+ * missing {@code cert_path} or {@code key_path}, so callers fail fast without polling.
+ */
+ private static final class IncompleteWorkloadConfigException extends IOException {
+ private static final long serialVersionUID = 1L;
+
+ IncompleteWorkloadConfigException(final String message) {
+ super(message);
+ }
+ }
+
+ private static long getSleepIntervalMs(final int cycle) {
+ return (cycle < FAST_POLL_CYCLES) ? FAST_POLL_INTERVAL_MS : SLOW_POLL_INTERVAL_MS;
+ }
+
+ private static @Nullable String getTrimmedEnv(final String name) {
+ String val = environmentProvider.getEnv(name);
+ return val != null ? val.trim() : null;
+ }
+
+ /**
+ * Reads the trimmed {@link #GOOGLE_API_USE_CLIENT_CERTIFICATE} value from {@link
+ * #environmentProvider}. Queried via {@code environmentProvider} rather than cached in a {@code
+ * static final} field so test overrides via {@link #setEnvironmentProvider} take effect.
+ */
+ private static @Nullable String getUseClientCertificateEnv() {
+ return getTrimmedEnv(GOOGLE_API_USE_CLIENT_CERTIFICATE);
+ }
+
+ /**
+ * Returns {@code true} if {@link #GOOGLE_API_USE_CLIENT_CERTIFICATE} is set to {@code "true"}
+ * (case-insensitive).
+ */
+ private static boolean isMtlsExplicitlyEnabled() {
+ return "true".equalsIgnoreCase(getUseClientCertificateEnv());
+ }
+
+ /**
+ * Returns {@code true} if {@link #GOOGLE_API_USE_CLIENT_CERTIFICATE} is set to a non-empty value
+ * other than {@code "true"} (case-insensitive).
+ */
+ private static boolean isMtlsExplicitlyDisabled() {
+ String useClientCert = getUseClientCertificateEnv();
+ return !Strings.isNullOrEmpty(useClientCert) && !"true".equalsIgnoreCase(useClientCert);
+ }
+
+ /**
+ * Resolves symlinks on existing ancestor directories of {@code path} (for example, {@code
+ * /var/run} to {@code /run} before startup files are created), preserving any trailing
+ * non-existent path elements.
+ */
+ private static Path resolveRealPathAllowingMissing(final Path path) {
+ Path normalized = path.toAbsolutePath().normalize();
+ Path current = normalized;
+ while (current != null) {
+ try {
+ Path realCurrent = current.toRealPath();
+ Path relative = current.relativize(normalized);
+ return relative.toString().isEmpty()
+ ? realCurrent
+ : realCurrent.resolve(relative).normalize();
+ } catch (IOException e) {
+ current = current.getParent();
+ }
+ }
+ return normalized;
+ }
+
+ /** Checks whether the given path resides within the well-known certificate directory. */
+ private static boolean isPathInWellKnownDir(final @Nullable String pathStr) {
+ if (Strings.isNullOrEmpty(pathStr)) {
+ return false;
+ }
+ try {
+ Path path = resolveRealPathAllowingMissing(Paths.get(pathStr));
+ Path wellKnown = resolveRealPathAllowingMissing(Paths.get(wellKnownDir));
+ return path.startsWith(wellKnown);
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ /** Returns {@code true} if {@code path} is the GKE credential bundle in the well-known dir. */
+ static boolean isGkeCredentialBundle(final @Nullable String path) {
+ return path != null && gkeCredentialBundlePath.equals(path);
+ }
+
+ /**
+ * Returns whether the resolved certificate paths came from a workload certificate configuration
+ * or point at the GKE credential bundle (either of which can enable mTLS without {@link
+ * #GOOGLE_API_USE_CLIENT_CERTIFICATE} being set to {@code "true"}).
+ */
+ private static boolean hasWorkloadConfigOrGkeBundle(
+ final boolean hasWorkloadConfig, final @Nullable String certPath) {
+ return hasWorkloadConfig || isGkeCredentialBundle(certPath);
+ }
+
+ /**
+ * Retrieves the certificate and raw PEM content for the Agent Identity.
+ *
+ *
This method attempts to load the certificate and private key for the agent identity. It
+ * first checks the location specified by the {@code GOOGLE_API_CERTIFICATE_CONFIG} environment
+ * 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. Note that
+ * callers such as {@link ComputeEngineCredentials#refreshAccessToken()} invoke this method via
+ * {@link #getBoundTokenPayload()} under {@link OAuth2Credentials}'s token refresh coalescing,
+ * which already serializes concurrent token refreshes to mitigate thundering herd contention.
+ *
+ * @return A {@link CertInfo} object containing the parsed {@link X509Certificate} and its raw PEM
+ * chain content, or {@code null} if the agent identity features are disabled, opted out, or
+ * if no valid credentials could be loaded.
+ * @throws IOException If an I/O error occurs while reading the files, or if the key-pair
+ * verification fails after retries.
+ */
+ static @Nullable CertInfo getAgentIdentityCertInfo() throws IOException {
+ if (!isTokenBindingEnabled()) {
+ return null;
+ }
+ if (mtlsDisabledEvaluated && isMtlsExplicitlyDisabled()) {
+ return null;
+ }
+ CachedAgentIdentityInfo initialCached = AgentIdentityCacheUtils.getCachedAgentIdentityInfo();
+ String certConfigPath = getTrimmedEnv(GOOGLE_API_CERTIFICATE_CONFIG);
+
+ // Fast-path: check cached info and file metadata before parsing JSON config or paths
+ if (initialCached != null
+ && AgentIdentityCacheUtils.isCachedInfoValid(initialCached, certConfigPath)) {
+ if (!initialCached.shouldRequestBoundToken) {
+ return null;
+ }
+ // isCachedInfoValid() verified that the cached certificate file still exists on disk with
+ // matching metadata, so certsPresent is guaranteed to be true here.
+ boolean hasConfigOrGkeBundle =
+ hasWorkloadConfigOrGkeBundle(
+ initialCached.configMetadata != null, initialCached.certMetadata.getPath());
+ if (!shouldEnableMtls(/* certsPresent= */ true, hasConfigOrGkeBundle)) {
+ return null;
+ }
+ return initialCached.certInfo;
+ }
+
+ ResolvedCertAndKeyPaths paths = resolveCertAndKeyPaths(certConfigPath, initialCached);
+ boolean hasConfigOrGkeBundle =
+ hasWorkloadConfigOrGkeBundle(paths.hasWorkloadConfig(), paths.getCertPath());
+ boolean certsPresent = !Strings.isNullOrEmpty(paths.getCertPath());
+
+ if (!shouldEnableMtls(certsPresent, hasConfigOrGkeBundle)) {
+ if (isMtlsExplicitlyDisabled()) {
+ mtlsDisabledEvaluated = true;
+ }
+ return null;
+ }
+
+ return loadAndVerifyCredentials(
+ paths.getCertPath(), paths.getKeyPath(), paths.getConfigMetadata(), initialCached);
+ }
+
+ /**
+ * Resolves the paths for the certificate and private key based on the config path or well-known
+ * locations.
+ */
+ private static ResolvedCertAndKeyPaths resolveCertAndKeyPaths(
+ final @Nullable String certConfigPath, final @Nullable CachedAgentIdentityInfo cached)
+ throws IOException {
+ if (!Strings.isNullOrEmpty(certConfigPath)) {
+ // Read cert and key paths from config file. We use retry with backoff to handle
+ // startup delivery (when in well-known directory) and transient rotation race conditions.
+ return getPathsFromConfigWithRetry(certConfigPath, cached);
+ }
+ // Fallback to well-known locations. We use retry with backoff here as well to handle
+ // startup delivery (when explicit mTLS is enabled) and race conditions during file
+ // replacement by a rotation process.
+ return getWellKnownCertificatePathWithRetry(cached);
+ }
+
+ /**
+ * Loads the certificate and private key, and verifies that they form a valid cryptographic
+ * key-pair, supporting both separate files and combined bundle files.
+ *
+ *
Checks {@link AgentIdentityCertificateValidationUtils#shouldRequestBoundToken} right after
+ * parsing the certificate content before reading the private key or running signature
+ * verification. Caches verified results in memory so unchanged files are not re-read and
+ * re-verified on every token refresh.
+ *
+ * @param certPath The path to the certificate or bundle file.
+ * @param keyPath The path to the private key or bundle file.
+ * @return A {@link CertInfo} object containing the parsed {@link X509Certificate} and the raw PEM
+ * certificate chain (with private keys stripped), or {@code null} if the certificate does not
+ * match agent SPIFFE patterns.
+ * @throws IOException If the files cannot be read or parsed, or if key-pair verification fails
+ * after retries.
+ */
+ static @Nullable CertInfo loadAndVerifyCredentials(
+ final @Nullable String certPath, final @Nullable String keyPath) throws IOException {
+ return loadAndVerifyCredentials(
+ certPath, keyPath, null, AgentIdentityCacheUtils.getCachedAgentIdentityInfo());
+ }
+
+ private static @Nullable CertInfo loadAndVerifyCredentials(
+ final @Nullable String certPath,
+ final @Nullable String keyPath,
+ final @Nullable FileMetadata configMetaBefore,
+ final @Nullable CachedAgentIdentityInfo initialCached)
+ throws IOException {
+ if (Strings.isNullOrEmpty(certPath)) {
+ return null;
+ }
+
+ // Even when isCachedInfoValid() in getAgentIdentityCertInfo() returned false, the cache can
+ // still match here in three cases:
+ // 1. The config file was deleted after caching while the cert/key files on disk remain
+ // unchanged (getPathsFromConfigWithRetry fell back to cached paths + configMetadata).
+ // 2. Another thread completed rotation and updated the cache while this thread was resolving
+ // paths in resolveCertAndKeyPaths().
+ // 3. Package-private loadAndVerifyCredentials(certPath, keyPath) was invoked directly.
+ CachedAgentIdentityInfo cached = AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached);
+ if (AgentIdentityCacheUtils.isPostResolutionCacheHit(
+ cached, certPath, keyPath, configMetaBefore)) {
+ return cached.shouldRequestBoundToken ? cached.certInfo : null;
+ }
+
+ Exception lastException = null;
+ int retries = 0;
+ while (retries < CERT_KEY_MATCH_RETRIES) {
+ try {
+ FileMetadata certMeta = FileMetadata.of(certPath);
+ String certContent = AgentIdentityCertificateValidationUtils.readCertificateChain(certPath);
+ X509Certificate cert =
+ AgentIdentityCertificateValidationUtils.parseCertificateContent(certContent);
+
+ if (!AgentIdentityCertificateValidationUtils.shouldRequestBoundToken(cert)) {
+ if (AgentIdentityCacheUtils.tryUpdateCache(
+ configMetaBefore, certMeta, null, false, null)) {
+ return null;
+ }
+ lastException = new IOException("Certificate file modified during read.");
+ } else {
+ FileMetadata keyMeta = FileMetadata.of(keyPath);
+ PrivateKey privateKey =
+ AgentIdentityCertificateValidationUtils.readPrivateKey(
+ keyPath, cert.getPublicKey().getAlgorithm());
+
+ if (AgentIdentityCertificateValidationUtils.verifyKeyPair(cert, privateKey)) {
+ CertInfo info = new CertInfo(cert, certContent);
+ if (AgentIdentityCacheUtils.tryUpdateCache(
+ configMetaBefore, certMeta, keyMeta, true, info)) {
+ return info;
+ }
+ lastException = new IOException("Certificate or key file modified during read.");
+ LoggingUtils.log(
+ LOGGER_PROVIDER,
+ Level.FINE,
+ Collections.emptyMap(),
+ "Cert or key file modified during read, retrying...");
+ } else {
+ lastException = new IOException("Certificate and private key do not match.");
+ LoggingUtils.log(
+ LOGGER_PROVIDER,
+ Level.FINE,
+ Collections.emptyMap(),
+ "Cert and key mismatch, retrying...");
+ }
+ }
+ } catch (AccessDeniedException e) {
+ throw new IOException(
+ "Permission denied reading certificate or key files for Agent Identity.", e);
+ } catch (UnsupportedKeyAlgorithmException | InvalidCertificateException e) {
+ throw e;
+ } catch (Exception e) {
+ lastException = e;
+ LoggingUtils.log(
+ LOGGER_PROVIDER,
+ Level.FINE,
+ Collections.emptyMap(),
+ "Failed to read or verify cert/key, retrying: " + e.getMessage());
+ }
+
+ retries++;
+ if (retries < CERT_KEY_MATCH_RETRIES) {
+ try {
+ timeService.sleep(CERT_KEY_MATCH_RETRY_INTERVAL_MS); // 0.1 seconds backoff
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException("Interrupted while waiting for cert/key match.", e);
+ }
+ }
+ }
+
+ // If files were transiently missing, unreadable, or mid-rotation (lastException != null)
+ // and we already have a verified credential cached in memory, fall back to cached credentials
+ // rather than failing or caching an unbound token. Re-read cachedAgentIdentityInfo in case
+ // another thread completed rotation while this thread was sleeping.
+ CachedAgentIdentityInfo fallbackCached =
+ AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached);
+ if (lastException != null
+ && fallbackCached != null
+ && fallbackCached.shouldRequestBoundToken
+ && fallbackCached.certInfo != null) {
+ LoggingUtils.log(
+ LOGGER_PROVIDER,
+ Level.FINE,
+ Collections.emptyMap(),
+ "Agent Identity certificate/key files transiently unavailable during rotation; falling"
+ + " back to cached credentials.");
+ return fallbackCached.certInfo;
+ }
+
+ throw new GoogleAuthException(
+ true,
+ CERT_KEY_MATCH_RETRIES,
+ String.format(
+ "Agent Identity certificate and private key mismatch or read"
+ + " failure after %d attempts.",
+ CERT_KEY_MATCH_RETRIES),
+ lastException);
+ }
+
+ /**
+ * Checks if runtime token binding is enabled.
+ *
+ *
Checks {@link #GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN} first; if unset, falls back to the
+ * legacy {@link #GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES}. Defaults to {@code
+ * true} if neither is set.
+ */
+ private static boolean isTokenBindingEnabled() {
+ String enableRuntimeBoundToken = getTrimmedEnv(GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN);
+ if (!Strings.isNullOrEmpty(enableRuntimeBoundToken)) {
+ return !"false".equalsIgnoreCase(enableRuntimeBoundToken);
+ }
+ String legacyPreventSharing =
+ getTrimmedEnv(GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES);
+ if (!Strings.isNullOrEmpty(legacyPreventSharing)) {
+ return !"false".equalsIgnoreCase(legacyPreventSharing);
+ }
+ return true;
+ }
+
+ /**
+ * Reads the certificate path from the config file with retry logic to handle startup delivery and
+ * rotation race conditions.
+ */
+ private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry(
+ final String certConfigPath, final @Nullable CachedAgentIdentityInfo initialCached)
+ throws IOException {
+ if (isMtlsExplicitlyDisabled()) {
+ try {
+ if (AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(certConfigPath))) {
+ ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath);
+ if (paths.hasWorkloadConfig() && Files.exists(Paths.get(paths.getCertPath()))) {
+ return paths;
+ }
+ }
+ } catch (Exception ignored) {
+ // Do not fail when mTLS is explicitly disabled
+ }
+ return new ResolvedCertAndKeyPaths(null, null, false);
+ }
+
+ boolean inWellKnownDir = isPathInWellKnownDir(certConfigPath);
+ boolean shouldPoll = !initialStartupCompleted && inWellKnownDir;
+ int maxCycles = shouldPoll ? TOTAL_POLL_CYCLES : 1;
+ boolean warned = false;
+ IOException lastParseException = null;
+
+ for (int cycle = 0; cycle < maxCycles; cycle++) {
+ try {
+ if (AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(certConfigPath))) {
+ ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath);
+ if (!paths.hasWorkloadConfig()) {
+ // Valid non-workload config (e.g. enterprise certs) - exit early without polling!
+ initialStartupCompleted = true;
+ return paths;
+ }
+ // The config file itself may reside outside wellKnownDir while referencing cert_path or
+ // key_path inside wellKnownDir that are still being delivered at startup.
+ if (!initialStartupCompleted
+ && !shouldPoll
+ && (isPathInWellKnownDir(paths.getCertPath())
+ || isPathInWellKnownDir(paths.getKeyPath()))) {
+ shouldPoll = true;
+ maxCycles = TOTAL_POLL_CYCLES;
+ }
+ boolean certReady =
+ AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(paths.getCertPath()));
+ boolean keyReady =
+ AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(paths.getKeyPath()));
+ if (certReady && keyReady) {
+ initialStartupCompleted = true;
+ return paths;
+ }
+ }
+ } catch (AccessDeniedException e) {
+ String failedFile = e.getFile() != null ? e.getFile() : certConfigPath;
+ throw new IOException(
+ "Permission denied reading certificate config file: " + failedFile, e);
+ } catch (IncompleteWorkloadConfigException e) {
+ // Incomplete workload config (missing cert_path or key_path) will not become ready via
+ // startup polling; break out of the polling loop immediately so initial startup throws
+ // without waiting 30s (or falls back to a previously cached config in steady state).
+ lastParseException = e;
+ break;
+ } catch (IOException e) {
+ CachedAgentIdentityInfo latestCached =
+ AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached);
+ if (!shouldPoll && cycle + 1 >= maxCycles && latestCached == null) {
+ initialStartupCompleted = true;
+ throw e; // Fail fast on malformed JSON syntax errors when not polling
+ }
+ lastParseException = e;
+ // When polling is active, fall through to retry (handles partial/empty JSON during startup)
+ }
+ if (cycle + 1 < maxCycles) {
+ if (shouldPoll && !warned) {
+ LoggingUtils.log(
+ LOGGER_PROVIDER,
+ Level.FINE,
+ Collections.emptyMap(),
+ String.format(
+ "Agent Identity certificate config or referenced credential files not yet ready"
+ + " 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(getSleepIntervalMs(cycle));
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ throw new IOException(
+ "Interrupted while waiting for Agent Identity certificate files for bound"
+ + " token request.",
+ e);
+ }
+ }
+ }
+ initialStartupCompleted = true;
+
+ CachedAgentIdentityInfo fallbackCached =
+ AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached);
+ if (fallbackCached != null
+ && fallbackCached.configMetadata != null
+ && fallbackCached.certMetadata != null
+ && fallbackCached.keyMetadata != null) {
+ LoggingUtils.log(
+ LOGGER_PROVIDER,
+ Level.FINE,
+ Collections.emptyMap(),
+ String.format(
+ "Agent Identity certificate config at %s is unavailable; falling back to cached"
+ + " certificate and key paths.",
+ certConfigPath));
+ return new ResolvedCertAndKeyPaths(
+ fallbackCached.certMetadata.getPath(),
+ fallbackCached.keyMetadata.getPath(),
+ true,
+ fallbackCached.configMetadata);
+ }
+
+ 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_ENABLE_RUNTIME_BOUND_TOKEN
+ + " to false to fall back to unbound tokens.",
+ lastParseException);
+ }
+
+ /** Searches for certificates at well-known locations. */
+ private static ResolvedCertAndKeyPaths getWellKnownCertificatePathWithRetry(
+ final @Nullable CachedAgentIdentityInfo initialCached) throws IOException {
+ if (isMtlsExplicitlyDisabled()) {
+ initialStartupCompleted = true;
+ return new ResolvedCertAndKeyPaths(null, null, false);
+ }
+
+ // Startup polling is only performed when GOOGLE_API_CERTIFICATE_CONFIG is set and points to the
+ // well-known directory (see getPathsFromConfigWithRetry). When GOOGLE_API_CERTIFICATE_CONFIG is
+ // unset, GKE mounts the Pod Certificate volume before the container starts (and on GCE an empty
+ // well-known directory may exist without any certificates ever being delivered), so we perform
+ // a single immediate check without startup polling.
+ // The GKE credential bundle is only used when no well-known gcloud certificate configuration
+ // file exists (it would otherwise be the transport's mTLS certificate source).
+ boolean gkeBundleAllowed =
+ !MtlsUtils.hasCertificateConfiguration(environmentProvider, propertyProvider);
+ String gkeBundlePath = gkeCredentialBundlePath;
+ if (gkeBundleAllowed && MtlsUtils.isGkeCredentialBundleReady(gkeBundlePath)) {
+ initialStartupCompleted = true;
+ // GKE combined bundle file contains both certificate chain and private key
+ return new ResolvedCertAndKeyPaths(gkeBundlePath, gkeBundlePath, false);
+ }
+
+ if (!isMtlsExplicitlyEnabled()) {
+ // Without a config file or GOOGLE_API_USE_CLIENT_CERTIFICATE="true", only the GKE credential
+ // bundle can auto-enable mTLS. If a GKE credential was previously cached and the bundle is
+ // transiently unavailable (e.g. mid-rotation), return the cached paths so
+ // loadAndVerifyCredentials can fall back to the cached credential.
+ initialStartupCompleted = true;
+ CachedAgentIdentityInfo fallbackCached =
+ AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached);
+ if (gkeBundleAllowed
+ && fallbackCached != null
+ && fallbackCached.canUseWellKnownFallback()
+ && fallbackCached.isGkeCredentialBundle) {
+ return new ResolvedCertAndKeyPaths(
+ fallbackCached.certMetadata.getPath(), fallbackCached.keyMetadata.getPath(), false);
+ }
+ return new ResolvedCertAndKeyPaths(null, null, false);
+ }
+
+ String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString();
+ String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString();
+ String keyOnlyPath = Paths.get(wellKnownDir, "private_key.pem").toString();
+
+ try {
+ if (AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(bundlePath))) {
+ initialStartupCompleted = true;
+ // Combined bundle file contains both certificate chain and private key
+ return new ResolvedCertAndKeyPaths(bundlePath, bundlePath, false);
+ }
+ if (AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(certOnlyPath))
+ && AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(keyOnlyPath))) {
+ initialStartupCompleted = true;
+ return new ResolvedCertAndKeyPaths(certOnlyPath, keyOnlyPath, false);
+ }
+ } catch (AccessDeniedException e) {
+ initialStartupCompleted = true;
+ LoggingUtils.log(
+ LOGGER_PROVIDER,
+ Level.FINE,
+ Collections.emptyMap(),
+ "Permission denied reading well-known certificates. Falling back to unbound token.");
+ return new ResolvedCertAndKeyPaths(null, null, false);
+ } catch (Exception e) {
+ LoggingUtils.log(
+ LOGGER_PROVIDER,
+ Level.FINE,
+ Collections.emptyMap(),
+ "Unexpected error checking well-known certificates: " + e.getMessage());
+ }
+ initialStartupCompleted = true;
+
+ CachedAgentIdentityInfo fallbackCached =
+ AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached);
+ if (fallbackCached != null
+ && fallbackCached.canUseWellKnownFallback()
+ // A cached GKE credential is only reused while the GKE bundle is still allowed (no
+ // certificate configuration exists), matching the certificate the transport presents.
+ && (!fallbackCached.isGkeCredentialBundle || gkeBundleAllowed)) {
+ return new ResolvedCertAndKeyPaths(
+ fallbackCached.certMetadata.getPath(), fallbackCached.keyMetadata.getPath(), false);
+ }
+
+ return new ResolvedCertAndKeyPaths(null, null, false);
+ }
+
+ /**
+ * Determines if mTLS should be enabled based on environment variables and certificate presence.
+ *
+ *
Note that {@code configExists} is required even when {@code certsPresent} is {@code true}:
+ * when {@code GOOGLE_API_USE_CLIENT_CERTIFICATE} is unset (Case 3), mTLS is only inferred if a
+ * workload config exists ({@code configExists == true}), whereas when {@code
+ * GOOGLE_API_USE_CLIENT_CERTIFICATE="true"} (Case 1), mTLS is enabled whenever {@code
+ * certsPresent == true} even from the well-known directory ({@code configExists == false}).
+ *
+ *
Callers pass {@code configExists == true} for the GKE credential bundle as well (see {@link
+ * #GKE_CREDENTIAL_BUNDLE_FILE}), since its presence auto-enables mTLS on its own.
+ */
+ static boolean shouldEnableMtls(final boolean certsPresent, final boolean configExists)
+ throws IOException {
+ String useClientCert = getUseClientCertificateEnv();
+
+ // 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 (any non-empty value other than "true")
+ else if (!Strings.isNullOrEmpty(useClientCert)) {
+ if (certsPresent && !mtlsDisabledLogged) {
+ mtlsDisabledLogged = true;
+ // Log that we are ignoring present certs because it was explicitly disabled
+ LoggingUtils.log(
+ LOGGER_PROVIDER,
+ Level.FINE,
+ Collections.emptyMap(),
+ "Token binding protection is disabled because mTLS was explicitly disabled"
+ + " via GOOGLE_API_USE_CLIENT_CERTIFICATE.");
+ }
+ return false;
+ }
+ // Case 3: Environment variable is unset
+ else {
+ if (configExists) {
+ if (certsPresent) {
+ // Infer mTLS is enabled because workload config and certs are present
+ return true;
+ }
+ // Config exists but files are missing - fail fast
+ throw new IOException(
+ "Certificate intent inferred via config, but cert files are missing.");
+ }
+ // No workload config to infer mTLS from, do not enable
+ return false;
+ }
+ }
+
+ /** Retrieves the bound token payload (certificate chain) if applicable. */
+ static @Nullable String getBoundTokenPayload() throws IOException {
+ CertInfo info = getAgentIdentityCertInfo();
+ return info != null ? info.getCertContent() : null;
+ }
+
+ /** Extracts the certificate and private key paths from the JSON configuration file. */
+ private static ResolvedCertAndKeyPaths extractPathsFromConfig(final String certConfigPath)
+ throws IOException {
+ try {
+ FileMetadata configMetadata = FileMetadata.of(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");
+ if (certConfigsObj instanceof Map, ?>) {
+ Map, ?> certConfigs = (Map, ?>) certConfigsObj;
+ Object workloadObj = certConfigs.get("workload");
+ if (workloadObj instanceof Map, ?>) {
+ Map, ?> workload = (Map, ?>) workloadObj;
+ String certPath =
+ workload.get("cert_path") instanceof String
+ ? (String) workload.get("cert_path")
+ : null;
+ String keyPath =
+ workload.get("key_path") instanceof String
+ ? (String) workload.get("key_path")
+ : null;
+ if (Strings.isNullOrEmpty(certPath) || Strings.isNullOrEmpty(keyPath)) {
+ throw new IncompleteWorkloadConfigException(
+ "Workload certificate config is missing cert_path or key_path.");
+ }
+ return new ResolvedCertAndKeyPaths(certPath, keyPath, true, configMetadata);
+ }
+ }
+ return new ResolvedCertAndKeyPaths(null, null, false, configMetadata);
+ }
+ } catch (AccessDeniedException | IncompleteWorkloadConfigException e) {
+ throw e;
+ } catch (Exception e) {
+ throw new IOException("Failed to parse Agent Identity config JSON", e);
+ }
+ }
+
+ /** Resets all static state and overrides for testing. */
+ @VisibleForTesting
+ static void resetForTest() {
+ wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/";
+ gkeCredentialBundlePath = Paths.get(wellKnownDir, GKE_CREDENTIAL_BUNDLE_FILE).toString();
+ environmentProvider = SystemEnvironmentProvider.getInstance();
+ propertyProvider = SystemPropertyProvider.getInstance();
+ timeService = Thread::sleep;
+ resetCachedState();
+ }
+
+ private static void resetCachedState() {
+ AgentIdentityCacheUtils.clearCachedAgentIdentityInfo();
+ initialStartupCompleted = false;
+ mtlsDisabledLogged = false;
+ mtlsDisabledEvaluated = false;
+ }
+
+ /** Returns the current well-known certificate directory path. */
+ static String getWellKnownDir() {
+ return wellKnownDir;
+ }
+
+ /** Sets the well-known certificate directory path for testing. */
+ @VisibleForTesting
+ static void setWellKnownDir(final String dir) {
+ wellKnownDir = dir;
+ gkeCredentialBundlePath = Paths.get(dir, GKE_CREDENTIAL_BUNDLE_FILE).toString();
+ resetCachedState();
+ }
+
+ /** Sets the environment variable provider for testing. */
+ @VisibleForTesting
+ static void setEnvironmentProvider(final EnvironmentProvider provider) {
+ environmentProvider = provider;
+ resetCachedState();
+ }
+
+ /**
+ * Sets the system property provider for testing. Used to resolve the well-known gcloud
+ * certificate configuration file location.
+ */
+ @VisibleForTesting
+ static void setPropertyProvider(final PropertyProvider provider) {
+ propertyProvider = provider;
+ resetCachedState();
+ }
+
+ /** Sets the time and sleep service for testing. */
+ @VisibleForTesting
+ static void setTimeService(final TimeService service) {
+ timeService = service;
+ resetCachedState();
+ }
+
+ /** Returns whether the message for explicitly disabled mTLS has been logged. */
+ @VisibleForTesting
+ static boolean isMtlsDisabledLogged() {
+ return mtlsDisabledLogged;
+ }
+}
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java
index 5b2ad1169ed0..dae2ddb57f7b 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CertificateIdentityPoolSubjectTokenSupplier.java
@@ -51,7 +51,6 @@
import java.util.Base64;
import java.util.List;
import java.util.regex.Matcher;
-import java.util.regex.Pattern;
import org.jspecify.annotations.NullMarked;
/**
@@ -66,9 +65,6 @@ public class CertificateIdentityPoolSubjectTokenSupplier
private final IdentityPoolCredentialSource credentialSource;
- private static final Pattern PEM_CERT_PATTERN =
- Pattern.compile("-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", Pattern.DOTALL);
-
CertificateIdentityPoolSubjectTokenSupplier(IdentityPoolCredentialSource credentialSource) {
this.credentialSource = checkNotNull(credentialSource, "credentialSource cannot be null");
// This check ensures that the credential source was intended for certificate usage.
@@ -222,7 +218,8 @@ private void populateCertChainFromTrustChain(
// elsewhere in the chain.
if (encodedCurrentCert.equals(encodedLeafCert)) {
throw new IllegalArgumentException(
- "The leaf certificate should only appear at the beginning of the trust chain file, or be omitted entirely.");
+ "The leaf certificate should only appear at the beginning of the trust chain file, or"
+ + " be omitted entirely.");
}
// Add the current certificate to the chain.
@@ -260,7 +257,7 @@ static List readTrustChain(String trustChainPath)
// Split the file content into PEM certificate blocks.
String content = new String(trustChainData, StandardCharsets.UTF_8);
- Matcher matcher = PEM_CERT_PATTERN.matcher(content);
+ Matcher matcher = OAuth2Utils.PEM_CERT_PATTERN.matcher(content);
while (matcher.find()) {
String pemCertBlock = matcher.group(0);
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 a3b87c0c6489..7495e3d08827 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
@@ -34,11 +34,14 @@
import static com.google.common.base.MoreObjects.firstNonNull;
import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpContent;
import com.google.api.client.http.HttpHeaders;
+import com.google.api.client.http.HttpMediaType;
import com.google.api.client.http.HttpRequest;
import com.google.api.client.http.HttpResponse;
import com.google.api.client.http.HttpResponseException;
import com.google.api.client.http.HttpStatusCodes;
+import com.google.api.client.http.json.JsonHttpContent;
import com.google.api.client.json.JsonObjectParser;
import com.google.api.client.util.GenericData;
import com.google.auth.CredentialTypeForMetrics;
@@ -68,6 +71,7 @@
import java.util.Collections;
import java.util.Date;
import java.util.List;
+import java.util.Map;
import java.util.Objects;
import java.util.logging.Level;
import java.util.logging.Logger;
@@ -77,7 +81,11 @@
/**
* OAuth2 credentials representing the built-in service account for a Google Compute Engine VM.
*
- *
Fetches access tokens from the Google Compute Engine metadata server.
+ *
Fetches access tokens from the Google Compute Engine metadata server. When a workload
+ * certificate for an agent identity is available, requests certificate-bound access tokens and ID
+ * tokens by default (see {@code IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN} and the Google
+ * Auth Library guide).
*
*
These credentials use the IAM API to sign data. See {@link #sign(byte[])} for more details.
*/
@@ -320,7 +328,8 @@ public String getUniverseDomain() throws IOException {
private String getUniverseDomainFromMetadata() throws IOException {
HttpResponse response =
- getMetadataResponse(getUniverseDomainUrl(), RequestType.UNTRACKED, false);
+ getMetadataResponse(
+ buildMetadataRequest(getUniverseDomainUrl()), RequestType.UNTRACKED, false);
int statusCode = response.getStatusCode();
if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) {
return Credentials.GOOGLE_DEFAULT_UNIVERSE;
@@ -379,7 +388,9 @@ private String getUniverseDomainFromMetadata() throws IOException {
private @Nullable String getProjectIdFromMetadata() {
try {
- HttpResponse response = getMetadataResponse(getProjectIdUrl(), RequestType.UNTRACKED, false);
+ HttpResponse response =
+ getMetadataResponse(
+ buildMetadataRequest(getProjectIdUrl()), RequestType.UNTRACKED, false);
int statusCode = response.getStatusCode();
if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) {
LoggingUtils.log(
@@ -421,10 +432,23 @@ private String getUniverseDomainFromMetadata() throws IOException {
/** Refresh the access token by getting it from the GCE metadata server */
@Override
public AccessToken refreshAccessToken() throws IOException {
- HttpResponse response =
- getMetadataResponse(createTokenUrlWithScopes(), RequestType.ACCESS_TOKEN_REQUEST, true);
+ String tokenUrl = createTokenUrlWithScopes();
+ String boundTokenPayload = AgentIdentityUtils.getBoundTokenPayload();
+ HttpRequest request =
+ boundTokenPayload != null
+ ? buildBoundTokenRequest(tokenUrl, boundTokenPayload)
+ : buildMetadataRequest(tokenUrl);
+ HttpResponse response = getMetadataResponse(request, RequestType.ACCESS_TOKEN_REQUEST, true);
int statusCode = response.getStatusCode();
if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) {
+ if (boundTokenPayload != null) {
+ throw new IOException(
+ String.format(
+ "Error code %s trying to get bound security access token from Compute Engine"
+ + " metadata for the default service account. The Compute Engine metadata"
+ + " server endpoint does not support bound tokens.",
+ statusCode));
+ }
throw new IOException(
String.format(
"Error code %s trying to get security access token from Compute Engine metadata for"
@@ -470,6 +494,8 @@ public AccessToken refreshAccessToken() throws IOException {
*
* IdTokenProvider.Option.FORMAT_FULL
* IdTokenProvider.Option.LICENSES_TRUE
+ * IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN (request an ID token that is not bound to the
+ * agent identity certificate)
* If no options are set, the defaults are "&format=standard&licenses=false"
* @throws IOException if the attempt to get an IdToken failed
* @return IdToken object which includes the raw id_token, JsonWebSignature
@@ -477,7 +503,14 @@ public AccessToken refreshAccessToken() throws IOException {
@Override
public IdToken idTokenWithAudience(
String targetAudience, @Nullable List options) throws IOException {
+ // Checked before getBoundTokenPayload() so an opted-out target skips the certificate lookup.
+ boolean bindIdToken =
+ options == null || !options.contains(IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN);
+ String boundTokenPayload = bindIdToken ? AgentIdentityUtils.getBoundTokenPayload() : null;
GenericUrl documentUrl = new GenericUrl(getIdentityDocumentUrl());
+ if (boundTokenPayload != null) {
+ documentUrl.set("format", "full");
+ }
if (options != null) {
if (options.contains(IdTokenProvider.Option.FORMAT_FULL)) {
documentUrl.set("format", "full");
@@ -489,10 +522,21 @@ public IdToken idTokenWithAudience(
}
}
documentUrl.set("audience", targetAudience);
- HttpResponse response =
- getMetadataResponse(documentUrl.toString(), RequestType.ID_TOKEN_REQUEST, true);
+ HttpRequest request =
+ boundTokenPayload != null
+ ? buildBoundTokenRequest(documentUrl.toString(), boundTokenPayload)
+ : buildMetadataRequest(documentUrl.toString());
+ HttpResponse response = getMetadataResponse(request, RequestType.ID_TOKEN_REQUEST, true);
int statusCode = response.getStatusCode();
if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) {
+ if (boundTokenPayload != null) {
+ throw new IOException(
+ String.format(
+ "Error code %s trying to get bound identity token from Compute Engine metadata."
+ + " The Compute Engine metadata server endpoint does not support bound"
+ + " tokens.",
+ statusCode));
+ }
throw new IOException(
String.format(
"Error code %s trying to get identity token from"
@@ -519,11 +563,25 @@ public IdToken idTokenWithAudience(
return IdToken.create(rawToken);
}
+ private HttpRequest buildMetadataRequest(String url) throws IOException {
+ return transportFactory.create().createRequestFactory().buildGetRequest(new GenericUrl(url));
+ }
+
+ private HttpRequest buildBoundTokenRequest(String url, String boundTokenPayload)
+ throws IOException {
+ Map payload = Collections.singletonMap("certificate_chain", boundTokenPayload);
+ HttpContent content =
+ new JsonHttpContent(OAuth2Utils.JSON_FACTORY, payload)
+ .setMediaType(new HttpMediaType("application/json"));
+ return transportFactory
+ .create()
+ .createRequestFactory()
+ .buildPostRequest(new GenericUrl(url), content);
+ }
+
private HttpResponse getMetadataResponse(
- String url, RequestType requestType, boolean shouldSendMetricsHeader) throws IOException {
- GenericUrl genericUrl = new GenericUrl(url);
- HttpRequest request =
- transportFactory.create().createRequestFactory().buildGetRequest(genericUrl);
+ HttpRequest request, RequestType requestType, boolean shouldSendMetricsHeader)
+ throws IOException {
// Disable automatic logging by google-http-java-client to prevent leakage of sensitive tokens.
// Client Library Debug Logging via LoggingUtils is used instead where appropriate.
request.setLoggingEnabled(false);
@@ -840,7 +898,8 @@ public byte[] sign(byte[] toSign) {
private String getDefaultServiceAccount() throws IOException {
HttpResponse response =
- getMetadataResponse(getDefaultServiceAccountUrl(), RequestType.UNTRACKED, false);
+ getMetadataResponse(
+ buildMetadataRequest(getDefaultServiceAccountUrl()), RequestType.UNTRACKED, false);
int statusCode = response.getStatusCode();
if (statusCode == HttpStatusCodes.STATUS_CODE_NOT_FOUND) {
throw new IOException(
diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdTokenProvider.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdTokenProvider.java
index d1dc1112b85f..e1673f19d3da 100644
--- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdTokenProvider.java
+++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdTokenProvider.java
@@ -48,6 +48,7 @@ public interface IdTokenProvider {
*
*
FORMAT_FULL
*
LICENSES_TRUE
+ *
DISABLE_BOUND_ID_TOKEN
*
*
*
@@ -60,7 +61,28 @@ public interface IdTokenProvider {
public enum Option {
FORMAT_FULL("formatFull"),
LICENSES_TRUE("licensesTrue"),
- INCLUDE_EMAIL("includeEmail");
+ INCLUDE_EMAIL("includeEmail"),
+ /**
+ * Requests an ID token that is not bound to the workload's agent identity certificate.
+ *
+ *
This option is only supported by {@link ComputeEngineCredentials}; other {@link
+ * IdTokenProvider} implementations do not request bound ID tokens and ignore this option.
+ *
+ *
When an agent identity certificate is available, {@link ComputeEngineCredentials} requests
+ * certificate-bound ID tokens by default. A bound ID token is only accepted by targets that
+ * authenticate the caller over mTLS with the same certificate. Pass this option for targets
+ * that are called over standard (non-mTLS) HTTPS, for example a Cloud Run service reached
+ * through its {@code *.run.app} URL or a custom domain.
+ *
+ *
If the certificate on disk differs from the active one, this refresh is treated like a
+ * rotation refresh: channels that fail to be recreated are dropped (and the pool refilled), so
+ * the switch is completed and the generation advanced only once every channel left in the pool
+ * uses the new certificate. Otherwise a channel that fails to be recreated keeps its slot, and
+ * the generation is not advanced, so that a periodic refresh without a rotation does not make
+ * in-flight {@code UNAUTHENTICATED} failures eligible for a rotation retry.
+ */
private void refreshSafely() {
try {
- refresh();
+ // See refresh(): a generation change while waiting for the lock means a concurrent refresh
+ // just recreated every channel on a new certificate.
+ long generationBeforeLock = generation.get();
+ synchronized (entryWriteLock) {
+ if (workloadCertPath == null) {
+ refreshAll();
+ return;
+ }
+ if (generation.get() != generationBeforeLock) {
+ LOG.fine(
+ "Skipping pre-emptive channel refresh: channels were just recreated by a concurrent"
+ + " refresh");
+ return;
+ }
+ String currentDiskFingerprint = rotationTracker.readDiskFingerprint();
+ if (currentDiskFingerprint.isEmpty()) {
+ // The configured certificate could not be read, which normally means it is being
+ // rewritten during a rotation. Skip this refresh rather than recreate channels from a
+ // partially written certificate; the next periodic or reactive refresh recreates them.
+ LOG.fine(
+ "Skipping pre-emptive channel refresh: the workload certificate could not be read;"
+ + " channels will be recreated on the next refresh");
+ return;
+ }
+ boolean certChanged = !rotationTracker.isAlreadyActive(currentDiskFingerprint);
+ if (refreshAll(/* dropUnrefreshedChannels= */ certChanged) && certChanged) {
+ completeCertificateSwitch(currentDiskFingerprint);
+ }
+ }
} catch (Exception e) {
- LOG.log(Level.WARNING, "Failed to pre-emptively refresh channnels", e);
+ LOG.log(Level.WARNING, "Failed to pre-emptively refresh channels", e);
}
}
+ @VisibleForTesting
+ void invalidateDiskFingerprintCache() {
+ rotationTracker.invalidateCache();
+ }
+
+ boolean shouldRefresh() {
+ return rotationTracker.shouldRefresh();
+ }
+
/**
* Replace all of the channels in the channel pool with fresh ones. This is meant to mitigate the
- * hourly GFE disconnects by giving clients the ability to prime the channel on reconnect.
+ * hourly GFE disconnects by giving clients the ability to prime the channel on reconnect, and to
+ * pick up a rotated mTLS workload certificate.
*
- *
This is done on a best effort basis. If the replacement channel fails to construct, the old
- * channel will continue to be used.
+ *
This is done on a best effort basis. When no workload certificate is configured, a channel
+ * whose replacement fails to construct continues to be used. When a workload certificate is
+ * configured, a channel whose replacement fails to construct is dropped so that no traffic is
+ * routed to a channel using the old certificate; if no replacement can be constructed at all, the
+ * pool is left unchanged.
*/
@InternalApi("Visible for testing")
void refresh() {
+ // A generation change while waiting for the lock means a concurrent refresh already switched
+ // every channel to a new certificate, so there is no need to read the certificate again.
+ long generationBeforeLock = generation.get();
// Note: synchronization is necessary in case refresh is called concurrently:
// - thread1 fails to replace a single entry
// - thread2 succeeds replacing an entry
@@ -443,28 +536,179 @@ void refresh() {
// - then thread2 will shut down channel that thread1 will put back into circulation (after it
// replaces the list)
synchronized (entryWriteLock) {
+ if (isShutdown) {
+ return;
+ }
+ if (workloadCertPath == null) {
+ refreshAll();
+ return;
+ }
+ if (generation.get() != generationBeforeLock) {
+ LOG.fine(
+ "Channel pool was already refreshed by a concurrent thread, skipping duplicate"
+ + " refresh");
+ return;
+ }
+ String currentDiskFingerprint = rotationTracker.readDiskFingerprint();
+ if (currentDiskFingerprint.isEmpty()) {
+ return;
+ }
+
+ // Double-check fingerprint inside the lock
+ if (rotationTracker.isAlreadyActive(currentDiskFingerprint)) {
+ LOG.fine(
+ "Channel pool was already refreshed by a concurrent thread, skipping duplicate"
+ + " refresh");
+ return;
+ }
+
+ // Drop any channel that fails to refresh so that no traffic is routed to the old certificate.
+ if (refreshAll(/* dropUnrefreshedChannels= */ true)) {
+ completeCertificateSwitch(currentDiskFingerprint);
+ }
+ }
+ }
+
+ /**
+ * Records that every channel in the pool now uses the certificate with the given fingerprint.
+ * Must be called while holding {@code entryWriteLock}, after the channels have been swapped.
+ *
+ *
The generation is incremented before the fingerprint is marked active, so that a concurrent
+ * failing RPC that no longer sees a pending rotation ({@link #shouldRefresh()} is {@code false})
+ * is guaranteed to see the new generation and be retried on the new channels.
+ */
+ private void completeCertificateSwitch(String newFingerprint) {
+ generation.incrementAndGet();
+ rotationTracker.markRefreshed(newFingerprint);
+ LOG.fine("Channel pool switched to certificate with fingerprint: " + newFingerprint);
+ }
+
+ @InternalApi("Visible for testing")
+ @Nullable String getWorkloadCertPath() {
+ return workloadCertPath;
+ }
+
+ @InternalApi("Visible for testing")
+ boolean refreshAll() {
+ return refreshAll(/* dropUnrefreshedChannels= */ false);
+ }
+
+ /**
+ * Replaces the channels in the pool with freshly created ones.
+ *
+ * @param dropUnrefreshedChannels if {@code false}, a channel that fails to be recreated keeps its
+ * slot in the pool. If {@code true} (used for certificate rotation), only newly created
+ * channels are kept so no traffic is routed to a channel using the old certificate, and the
+ * pool is then refilled asynchronously to its size before the refresh.
+ * @return if {@code dropUnrefreshedChannels} is {@code false}, whether every channel was
+ * recreated; otherwise, whether at least one channel was recreated (i.e. every channel left
+ * in the pool was newly created)
+ */
+ @InternalApi("Visible for testing")
+ boolean refreshAll(boolean dropUnrefreshedChannels) {
+ synchronized (entryWriteLock) {
+ if (isShutdown) {
+ return false;
+ }
LOG.fine("Refreshing all channels");
- ArrayList newEntries = new ArrayList<>(entries.get());
+ // All writes to entries happen under entryWriteLock, so this snapshot is the current pool.
+ ImmutableList currentEntries = entries.get();
+ List keptEntries = new ArrayList<>(currentEntries.size());
+ List retiredEntries = new ArrayList<>(currentEntries.size());
+ List createdEntries = new ArrayList<>(currentEntries.size());
+ boolean allCreated = !currentEntries.isEmpty();
- for (int i = 0; i < newEntries.size(); i++) {
- try {
- newEntries.set(i, new Entry(channelFactory.createSingleChannel()));
- } catch (IOException e) {
- LOG.log(Level.WARNING, "Failed to refresh channel, leaving old channel", e);
+ try {
+ for (Entry oldEntry : currentEntries) {
+ try {
+ Entry newEntry = new Entry(channelFactory.createSingleChannel());
+ createdEntries.add(newEntry);
+ keptEntries.add(newEntry);
+ retiredEntries.add(oldEntry);
+ } catch (Exception e) {
+ allCreated = false;
+ if (dropUnrefreshedChannels) {
+ retiredEntries.add(oldEntry);
+ } else {
+ keptEntries.add(oldEntry);
+ }
+ LOG.log(
+ Level.WARNING,
+ dropUnrefreshedChannels
+ ? "Failed to refresh channel, dropping old channel"
+ : "Failed to refresh channel, leaving old channel",
+ e);
+ }
}
- }
- ImmutableList replacedEntries = entries.getAndSet(ImmutableList.copyOf(newEntries));
+ if (createdEntries.isEmpty()) {
+ return false;
+ }
+
+ entries.set(ImmutableList.copyOf(keptEntries));
+ createdEntries.clear(); // Ownership transferred to pool
+
+ // Shutdown the channels that were cycled out.
+ retiredEntries.forEach(Entry::requestShutdown);
- // Shutdown the channels that were cycled out.
- for (Entry e : replacedEntries) {
- if (!newEntries.contains(e)) {
+ // Restore the pool to its pre-refresh size right away, rather than leave a dynamically
+ // sized
+ // pool to grow back over several resize() runs while traffic queues on fewer channels.
+ if (dropUnrefreshedChannels && !allCreated) {
+ scheduleRefill(currentEntries.size());
+ }
+ return dropUnrefreshedChannels || allCreated;
+ } finally {
+ // If an Error aborted before the swap, shut down newly created channels so they don't leak
+ for (Entry e : createdEntries) {
e.requestShutdown();
}
}
}
}
+ /**
+ * Schedules a one-shot task that restores the pool to {@code targetSize} channels after a
+ * certificate rotation refresh dropped channels that failed to refresh.
+ */
+ private void scheduleRefill(int targetSize) {
+ try {
+ backgroundExecutorProvider.getExecutor().execute(() -> refillSafely(targetSize));
+ } catch (RuntimeException e) {
+ LOG.log(Level.WARNING, "Failed to schedule channel pool refill", e);
+ }
+ }
+
+ private void refillSafely(int targetSize) {
+ try {
+ synchronized (entryWriteLock) {
+ if (isShutdown) {
+ return;
+ }
+ if (entries.get().size() < targetSize) {
+ expand(targetSize);
+ }
+ }
+ } catch (Exception e) {
+ LOG.log(Level.WARNING, "Failed to refill channel pool", e);
+ }
+ }
+
+ /**
+ * Returns the current channel pool generation counter.
+ *
+ *
The generation is a monotonically increasing counter incremented each time the pool switches
+ * every channel to a new mTLS certificate (a reactive refresh after a rotation, or a periodic
+ * refresh that picks up a rotated certificate). Periodic refreshes that do not change the
+ * certificate do not increment it. Retry loops ({@code AttemptCallable} and {@code
+ * ServerStreamingAttemptCallable}) snapshot the generation before starting an RPC attempt and
+ * compare it after an {@code UNAUTHENTICATED} failure to determine whether the pool rotated to a
+ * new certificate during or after the attempt.
+ */
+ long getGeneration() {
+ return generation.get();
+ }
+
/**
* Get and retain a Channel Entry. The returned Entry will have its rpc count incremented,
* preventing it from getting recycled.
@@ -616,17 +860,30 @@ public ClientCall newCall(
MethodDescriptor methodDescriptor, CallOptions callOptions) {
Entry entry = getRetainedEntry(affinity);
-
- return new ReleasingClientCall<>(entry.channel.newCall(methodDescriptor, callOptions), entry);
+ try {
+ return new ReleasingClientCall<>(
+ entry.channel.newCall(methodDescriptor, callOptions), entry);
+ } catch (Throwable t) {
+ entry.release();
+ throw t;
+ }
}
}
- /** ClientCall wrapper that makes sure to decrement the outstanding RPC count on completion. */
+ /**
+ * ClientCall wrapper that makes sure to decrement the outstanding RPC count on completion.
+ *
+ *
Contract: Exactly one call to {@link #start(Listener, Metadata)} is required to balance
+ * reference counts. Early cancellation before {@code start()} is recorded and safely decrements
+ * the reference count when {@code start()} is subsequently invoked.
+ */
static class ReleasingClientCall extends SimpleForwardingClientCall {
- private @Nullable CancellationException cancellationException;
+ private final Object callLock = new Object();
+ private volatile @Nullable CancellationException cancellationException;
final Entry entry;
private final AtomicBoolean wasClosed = new AtomicBoolean();
private final AtomicBoolean wasReleased = new AtomicBoolean();
+ private final AtomicBoolean wasStarted = new AtomicBoolean();
public ReleasingClientCall(ClientCall delegate, Entry entry) {
super(delegate);
@@ -635,51 +892,81 @@ public ReleasingClientCall(ClientCall delegate, Entry entry) {
@Override
public void start(Listener responseListener, Metadata headers) {
- if (cancellationException != null) {
- throw new IllegalStateException("Call is already cancelled", cancellationException);
- }
- try {
- super.start(
- new SimpleForwardingClientCallListener(responseListener) {
- @Override
- public void onClose(Status status, Metadata trailers) {
- if (!wasClosed.compareAndSet(false, true)) {
- LOG.log(
- Level.WARNING,
- "Call is being closed more than once. Please make sure that onClose() is not being manually called.");
- return;
- }
- try {
- super.onClose(status, trailers);
- } finally {
- if (wasReleased.compareAndSet(false, true)) {
- entry.release();
- } else {
+ synchronized (callLock) {
+ if (!wasStarted.compareAndSet(false, true)) {
+ throw new IllegalStateException("Call is already started");
+ }
+ if (cancellationException != null) {
+ if (wasReleased.compareAndSet(false, true)) {
+ entry.release();
+ }
+ throw new IllegalStateException("Call is already cancelled", cancellationException);
+ }
+ try {
+ super.start(
+ new SimpleForwardingClientCallListener(responseListener) {
+ @Override
+ public void onClose(Status status, Metadata trailers) {
+ if (!wasClosed.compareAndSet(false, true)) {
LOG.log(
Level.WARNING,
- "Entry was released before the call is closed. This may be due to an exception on start of the call.");
+ "Call is being closed more than once. Please make sure that onClose() is"
+ + " not being manually called.");
+ return;
+ }
+ try {
+ super.onClose(status, trailers);
+ } finally {
+ if (wasReleased.compareAndSet(false, true)) {
+ entry.release();
+ } else {
+ LOG.log(
+ Level.WARNING,
+ "Entry was released before the call is closed. This may be due to an"
+ + " exception on start of the call.");
+ }
}
}
- }
- },
- headers);
- } catch (Exception e) {
- // In case start failed, make sure to release
- if (wasReleased.compareAndSet(false, true)) {
- entry.release();
- } else {
- LOG.log(
- Level.WARNING,
- "The entry is already released. This indicates that onClose() has already been called previously");
+ },
+ headers);
+ } catch (Throwable t) {
+ // In case start failed, make sure to release
+ if (wasReleased.compareAndSet(false, true)) {
+ entry.release();
+ } else {
+ LOG.log(
+ Level.WARNING,
+ "The entry is already released. This indicates that onClose() has already been"
+ + " called previously");
+ }
+ throw t;
}
- throw e;
}
}
@Override
public void cancel(@Nullable String message, @Nullable Throwable cause) {
- this.cancellationException = new CancellationException(message);
- super.cancel(message, cause);
+ boolean releaseImmediately = false;
+ try {
+ synchronized (callLock) {
+ this.cancellationException = new CancellationException(message);
+ if (!wasStarted.get()) {
+ releaseImmediately = true;
+ }
+ if (delegate() != null) {
+ super.cancel(message, cause);
+ }
+ }
+ } catch (Throwable t) {
+ if (!wasStarted.get()) {
+ releaseImmediately = true;
+ }
+ throw t;
+ } finally {
+ if (releaseImmediately && wasReleased.compareAndSet(false, true)) {
+ entry.release();
+ }
+ }
}
}
}
diff --git a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcCallContext.java b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcCallContext.java
index 2337ca2f6f07..8d4080247ec4 100644
--- a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcCallContext.java
+++ b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcCallContext.java
@@ -98,6 +98,7 @@ public final class GrpcCallContext implements ApiCallContext {
private final ApiCallContextOptions options;
private final EndpointContext endpointContext;
private final boolean isDirectPath;
+ @Nullable private final TransportChannel transportChannel;
/** Returns an empty instance with a null channel and default {@link CallOptions}. */
public static GrpcCallContext createDefault() {
@@ -114,7 +115,8 @@ public static GrpcCallContext createDefault() {
null,
null,
null,
- false);
+ false,
+ null);
}
/** Returns an instance with the given channel and {@link CallOptions}. */
@@ -132,7 +134,8 @@ public static GrpcCallContext of(Channel channel, CallOptions callOptions) {
null,
null,
null,
- false);
+ false,
+ null);
}
private GrpcCallContext(
@@ -148,7 +151,8 @@ private GrpcCallContext(
@Nullable RetrySettings retrySettings,
@Nullable Set retryableCodes,
@Nullable EndpointContext endpointContext,
- boolean isDirectPath) {
+ boolean isDirectPath,
+ @Nullable TransportChannel transportChannel) {
this.channel = channel;
this.credentials = credentials;
Preconditions.checkNotNull(callOptions);
@@ -168,6 +172,7 @@ private GrpcCallContext(
this.endpointContext =
endpointContext == null ? EndpointContext.getDefaultInstance() : endpointContext;
this.isDirectPath = isDirectPath;
+ this.transportChannel = transportChannel;
}
/**
@@ -209,7 +214,13 @@ public GrpcCallContext withCredentials(Credentials newCredentials) {
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath);
+ isDirectPath,
+ transportChannel);
+ }
+
+ @Override
+ public TransportChannel getTransportChannel() {
+ return transportChannel;
}
@Override
@@ -233,7 +244,8 @@ public GrpcCallContext withTransportChannel(TransportChannel inputChannel) {
retrySettings,
retryableCodes,
endpointContext,
- transportChannel.isDirectPath());
+ transportChannel.isDirectPath(),
+ inputChannel);
}
@Override
@@ -252,7 +264,8 @@ public GrpcCallContext withEndpointContext(EndpointContext endpointContext) {
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath);
+ isDirectPath,
+ transportChannel);
}
/** This method is obsolete. Use {@link #withTimeoutDuration(java.time.Duration)} instead. */
@@ -287,7 +300,8 @@ public GrpcCallContext withTimeoutDuration(java.time.@Nullable Duration timeout)
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath);
+ isDirectPath,
+ transportChannel);
}
@Override
@@ -333,7 +347,8 @@ public GrpcCallContext withStreamWaitTimeoutDuration(
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath);
+ isDirectPath,
+ transportChannel);
}
/**
@@ -368,7 +383,8 @@ public GrpcCallContext withStreamIdleTimeoutDuration(
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath);
+ isDirectPath,
+ transportChannel);
}
@BetaApi("The surface for channel affinity is not stable yet and may change in the future.")
@@ -386,7 +402,8 @@ public GrpcCallContext withChannelAffinity(@Nullable Integer affinity) {
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath);
+ isDirectPath,
+ transportChannel);
}
@BetaApi("The surface for extra headers is not stable yet and may change in the future.")
@@ -408,7 +425,8 @@ public GrpcCallContext withExtraHeaders(Map> extraHeaders)
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath);
+ isDirectPath,
+ transportChannel);
}
@Override
@@ -431,7 +449,8 @@ public GrpcCallContext withRetrySettings(RetrySettings retrySettings) {
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath);
+ isDirectPath,
+ transportChannel);
}
@Override
@@ -454,7 +473,8 @@ public GrpcCallContext withRetryableCodes(Set retryableCodes) {
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath);
+ isDirectPath,
+ transportChannel);
}
@Override
@@ -541,6 +561,12 @@ public ApiCallContext merge(ApiCallContext inputCallContext) {
newCallOptions = newCallOptions.withOption(TRACER_KEY, newTracer);
}
+ TransportChannel newTransportChannel = grpcCallContext.transportChannel;
+ if (newTransportChannel == null
+ && (grpcCallContext.channel == null || grpcCallContext.channel.equals(channel))) {
+ newTransportChannel = transportChannel;
+ }
+
// The EndpointContext is not updated as there should be no reason for a user
// to update this.
return new GrpcCallContext(
@@ -556,7 +582,8 @@ public ApiCallContext merge(ApiCallContext inputCallContext) {
newRetrySettings,
newRetryableCodes,
endpointContext,
- newIsDirectPath);
+ newIsDirectPath,
+ newTransportChannel);
}
/** The {@link Channel} set on this context. */
@@ -634,7 +661,8 @@ public GrpcCallContext withChannel(@Nullable Channel newChannel) {
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath);
+ isDirectPath,
+ (newChannel != null && newChannel.equals(channel)) ? transportChannel : null);
}
/** Returns a new instance with the call options set to the given call options. */
@@ -652,7 +680,8 @@ public GrpcCallContext withCallOptions(CallOptions newCallOptions) {
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath);
+ isDirectPath,
+ transportChannel);
}
public GrpcCallContext withRequestParamsDynamicHeaderOption(String requestParams) {
@@ -696,7 +725,8 @@ public GrpcCallContext withOption(Key key, T value) {
retrySettings,
retryableCodes,
endpointContext,
- isDirectPath);
+ isDirectPath,
+ transportChannel);
}
/** {@inheritDoc} */
@@ -757,7 +787,8 @@ public int hashCode() {
options,
retrySettings,
retryableCodes,
- endpointContext);
+ endpointContext,
+ transportChannel);
}
@Override
@@ -781,7 +812,8 @@ public boolean equals(@Nullable Object o) {
&& Objects.equals(options, that.options)
&& Objects.equals(retrySettings, that.retrySettings)
&& Objects.equals(retryableCodes, that.retryableCodes)
- && Objects.equals(endpointContext, that.endpointContext);
+ && Objects.equals(endpointContext, that.endpointContext)
+ && Objects.equals(transportChannel, that.transportChannel);
}
Metadata getMetadata() {
diff --git a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcTransportChannel.java b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcTransportChannel.java
index e0a520facb17..63180a8149f6 100644
--- a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcTransportChannel.java
+++ b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/GrpcTransportChannel.java
@@ -68,6 +68,32 @@ public Channel getChannel() {
return getManagedChannel();
}
+ @Override
+ public void refresh() {
+ Channel channel = getChannel();
+ if (channel instanceof ChannelPool) {
+ ((ChannelPool) channel).refresh();
+ }
+ }
+
+ @Override
+ public boolean shouldRefresh() {
+ Channel channel = getChannel();
+ if (channel instanceof ChannelPool) {
+ return ((ChannelPool) channel).shouldRefresh();
+ }
+ return false;
+ }
+
+ @Override
+ public long getGeneration() {
+ Channel channel = getChannel();
+ if (channel instanceof ChannelPool) {
+ return ((ChannelPool) channel).getGeneration();
+ }
+ return 0;
+ }
+
@Override
public void shutdown() {
getManagedChannel().shutdown();
diff --git a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java
index f300ac611da8..91299357da10 100644
--- a/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java
+++ b/sdk-platform-java/gax-java/gax-grpc/src/main/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProvider.java
@@ -46,7 +46,6 @@
import com.google.auth.ApiKeyCredentials;
import com.google.auth.Credentials;
import com.google.auth.mtls.CertificateSourceUnavailableException;
-import com.google.auth.mtls.DefaultMtlsProviderFactory;
import com.google.auth.mtls.MtlsProvider;
import com.google.auth.oauth2.ComputeEngineCredentials;
import com.google.auth.oauth2.SecureSessionAgent;
@@ -400,12 +399,21 @@ public TransportChannel getTransportChannel() throws IOException {
}
private TransportChannel createChannel() throws IOException {
+ // Only track the workload certificate for rotation when the pool's channels actually present
+ // it, mirroring the credential selection in createSingleChannel(): DirectPath channels use
+ // GoogleDefaultChannelCredentials (ALTS) rather than the client certificate, and without an
+ // mtlsProvider there is no client certificate KeyStore (S2A or plain TLS is used instead).
+ String workloadCertPath =
+ !this.canUseDirectPath() && mtlsProvider != null
+ ? certificateBasedAccess.getWorkloadCertPath()
+ : null;
return GrpcTransportChannel.newBuilder()
.setManagedChannel(
ChannelPool.create(
channelPoolSettings,
InstantiatingGrpcChannelProvider.this::createSingleChannel,
- backgroundExecutor))
+ backgroundExecutor,
+ workloadCertPath))
.setDirectPath(this.canUseDirectPath())
.build();
}
@@ -464,8 +472,9 @@ private void logDirectPathMisconfig() {
level,
"Env var "
+ DIRECT_PATH_ENV_ENABLE_XDS
- + " was found and set to TRUE, but DirectPath was not enabled for this client. If this is intended for "
- + "this client, please note that this is a misconfiguration and set the attemptDirectPath option as well.");
+ + " was found and set to TRUE, but DirectPath was not enabled for this client. If"
+ + " this is intended for this client, please note that this is a misconfiguration"
+ + " and set the attemptDirectPath option as well.");
}
// Case 2: Direct Path xDS was enabled via Builder. Direct Path Traffic Director must be set
// (enabled with `setAttemptDirectPath(true)`) along with xDS.
@@ -473,7 +482,9 @@ private void logDirectPathMisconfig() {
else if (isDirectPathXdsEnabledViaBuilderOption()) {
LOG.log(
level,
- "DirectPath is misconfigured. The DirectPath XDS option was set, but the attemptDirectPath option was not. Please set both the attemptDirectPath and attemptDirectPathXds options.");
+ "DirectPath is misconfigured. The DirectPath XDS option was set, but the"
+ + " attemptDirectPath option was not. Please set both the attemptDirectPath and"
+ + " attemptDirectPathXds options.");
}
} else {
// Case 3: credential is not correctly set
@@ -664,7 +675,8 @@ ChannelCredentials createS2ASecuredChannelCredentials() {
// Fallback to plaintext connection to S2A.
LOG.log(
Level.INFO,
- "Cannot establish an mTLS connection to S2A because autoconfig endpoint did not return a mtls address to reach S2A.");
+ "Cannot establish an mTLS connection to S2A because autoconfig endpoint did not"
+ + " return a mtls address to reach S2A.");
s2aChannelCredentials = createPlaintextToS2AChannelCredentials(plaintextAddress);
return s2aChannelCredentials;
}
@@ -683,7 +695,9 @@ ChannelCredentials createS2ASecuredChannelCredentials() {
// Fallback to plaintext-to-S2A connection on error.
LOG.log(
Level.WARNING,
- "Cannot establish an mTLS connection to S2A due to error creating MTLS to MDS TlsChannelCredentials credentials, falling back to plaintext connection to S2A: "
+ "Cannot establish an mTLS connection to S2A due to error creating MTLS to MDS"
+ + " TlsChannelCredentials credentials, falling back to plaintext connection to"
+ + " S2A: "
+ ignore.getMessage());
s2aChannelCredentials = createPlaintextToS2AChannelCredentials(plaintextAddress);
return s2aChannelCredentials;
@@ -753,6 +767,8 @@ public ManagedChannelBuilder> createChannelBuilder() throws IOException {
if (channelCredentials != null) {
// Create the channel using channel credentials created via DCA.
builder = Grpc.newChannelBuilder(endpoint, channelCredentials);
+ } else if (mtlsProvider != null && certificateBasedAccess.useMtlsClientCertificate()) {
+ throw new IOException("Failed to initialize mTLS channel credentials");
} else {
// Could not create channel credentials via DCA. In accordance with
// https://google.aip.dev/auth/4115, if credentials not available through
@@ -1391,17 +1407,23 @@ public InstantiatingGrpcChannelProvider build() {
if (mtlsProvider == null) {
// Attempt to create default MtlsProvider from environment.
try {
- mtlsProvider = DefaultMtlsProviderFactory.create();
+ mtlsProvider = certificateBasedAccess.createDefaultMtlsProvider();
} catch (CertificateSourceUnavailableException e) {
// This is okay. Leave mtlsProvider as null so that we will not auto-upgrade
// to mTLS endpoints. See https://google.aip.dev/auth/4114.
} catch (IOException e) {
- LOG.log(
- Level.WARNING,
- "DefaultMtlsProviderFactory encountered unexpected IOException: " + e.getMessage());
- LOG.log(
- Level.WARNING,
- "mTLS configuration was detected on the device, but mTLS failed to initialize. Falling back to non-mTLS channel.");
+ mtlsProvider =
+ new MtlsProvider() {
+ @Override
+ public boolean isAvailable() throws IOException {
+ throw e;
+ }
+
+ @Override
+ public KeyStore getKeyStore() throws IOException {
+ throw e;
+ }
+ };
}
}
}
diff --git a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/ChannelPoolTest.java b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/ChannelPoolTest.java
index 5bfdc7754759..ed658c2f45d8 100644
--- a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/ChannelPoolTest.java
+++ b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/ChannelPoolTest.java
@@ -47,6 +47,7 @@
import com.google.api.gax.rpc.StreamController;
import com.google.api.gax.rpc.UnaryCallSettings;
import com.google.api.gax.rpc.UnaryCallable;
+import com.google.api.gax.rpc.mtls.CertificateRotationTracker;
import com.google.api.gax.util.FakeLogHandler;
import com.google.auth.Credentials;
import com.google.common.collect.ImmutableList;
@@ -61,16 +62,27 @@
import io.grpc.Status;
import io.grpc.stub.ClientCalls;
import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.StandardCopyOption;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.concurrent.CancellationException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.RejectedExecutionException;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
+import java.util.logging.Level;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;
@@ -81,13 +93,18 @@
class ChannelPoolTest {
private static final int DEFAULT_AWAIT_TERMINATION_SEC = 10;
private ChannelPool pool;
+ private Path tempCert;
@AfterEach
- void cleanup() throws InterruptedException {
+ void cleanup() throws InterruptedException, IOException {
if (pool != null) {
pool.shutdown();
pool.awaitTermination(DEFAULT_AWAIT_TERMINATION_SEC, TimeUnit.SECONDS);
}
+ if (tempCert != null) {
+ Files.deleteIfExists(tempCert);
+ tempCert = null;
+ }
}
@Test
@@ -101,6 +118,7 @@ void testAuthority() throws IOException {
ChannelPool.create(
ChannelPoolSettings.staticallySized(2),
new FakeChannelFactory(Arrays.asList(sub1, sub2)),
+ null,
null);
assertThat(pool.authority()).isEqualTo("myAuth");
}
@@ -117,6 +135,7 @@ void testRoundRobin() throws IOException {
ChannelPool.create(
ChannelPoolSettings.staticallySized(channels.size()),
new FakeChannelFactory(channels),
+ null,
null);
verifyTargetChannel(pool, channels, sub1);
@@ -195,6 +214,7 @@ void ensureEvenDistribution() throws InterruptedException, IOException {
ChannelPool.create(
ChannelPoolSettings.staticallySized(numChannels),
new FakeChannelFactory(Arrays.asList(channels)),
+ null,
null);
int numThreads = 20;
@@ -233,6 +253,7 @@ void channelPrimerShouldCallPoolConstruction() throws IOException {
.setPreemptiveRefreshEnabled(true)
.build(),
new FakeChannelFactory(Arrays.asList(channel1, channel2), mockChannelPrimer),
+ null,
null);
Mockito.verify(mockChannelPrimer, Mockito.times(2))
.primeChannel(Mockito.any(ManagedChannel.class));
@@ -273,7 +294,8 @@ void channelPrimerIsCalledPeriodically() throws IOException {
.setPreemptiveRefreshEnabled(true)
.build(),
channelFactory,
- provider);
+ provider,
+ null);
// 1 call during the creation
Mockito.verify(mockChannelPrimer, Mockito.times(1))
.primeChannel(Mockito.any(ManagedChannel.class));
@@ -297,7 +319,7 @@ void callShouldCompleteAfterCreation() throws IOException {
ManagedChannel replacementChannel = mock(ManagedChannel.class);
FakeChannelFactory channelFactory =
new FakeChannelFactory(ImmutableList.of(underlyingChannel, replacementChannel));
- pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null);
+ pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
// create a mock call when new call comes to the underlying channel
MockClientCall mockClientCall = new MockClientCall<>(1, Status.OK);
@@ -322,7 +344,7 @@ void callShouldCompleteAfterCreation() throws IOException {
ClientCall call =
pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
- pool.refresh();
+ pool.refreshAll();
// shutdown is not called because there is still an outstanding call, even if it hasn't started
Mockito.verify(underlyingChannel, Mockito.after(200).never()).shutdown();
@@ -346,7 +368,7 @@ void callShouldCompleteAfterStarted() throws IOException {
FakeChannelFactory channelFactory =
new FakeChannelFactory(ImmutableList.of(underlyingChannel, replacementChannel));
- pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null);
+ pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
// create a mock call when new call comes to the underlying channel
MockClientCall mockClientCall = new MockClientCall<>(1, Status.OK);
@@ -373,7 +395,7 @@ void callShouldCompleteAfterStarted() throws IOException {
// start clientCall
call.start(listener, new Metadata());
- pool.refresh();
+ pool.refreshAll();
// shutdown is not called because there is still an outstanding call
Mockito.verify(underlyingChannel, Mockito.after(200).never()).shutdown();
@@ -391,7 +413,7 @@ void channelShouldShutdown() throws IOException {
FakeChannelFactory channelFactory =
new FakeChannelFactory(ImmutableList.of(underlyingChannel, replacementChannel));
- pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null);
+ pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
// create a mock call when new call comes to the underlying channel
MockClientCall mockClientCall = new MockClientCall<>(1, Status.OK);
@@ -422,11 +444,1009 @@ void channelShouldShutdown() throws IOException {
call.sendMessage("message");
// shutdown is not called because it has not been shutdown yet
Mockito.verify(underlyingChannel, Mockito.after(200).never()).shutdown();
- pool.refresh();
+ pool.refreshAll();
// shutdown is called because the outstanding call has completed
Mockito.verify(underlyingChannel, Mockito.atLeastOnce()).shutdown();
}
+ @Test
+ void testCancelBeforeStartReleasesChannelEntry() throws IOException {
+ ManagedChannel underlyingChannel = mock(ManagedChannel.class);
+ ManagedChannel replacementChannel = mock(ManagedChannel.class);
+ FakeChannelFactory channelFactory =
+ new FakeChannelFactory(ImmutableList.of(underlyingChannel, replacementChannel));
+ pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
+
+ ClientCall call =
+ pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
+
+ pool.refreshAll();
+ Mockito.verify(underlyingChannel, Mockito.never()).shutdown();
+
+ call.cancel("Cancelled early", null);
+ Mockito.verify(underlyingChannel, Mockito.times(1)).shutdown();
+ }
+
+ @Test
+ void channelReactiveMTlsRefresh_swapsChannelsOnlyWhenCertChanges()
+ throws IOException, InterruptedException {
+ ManagedChannel underlyingChannel1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel underlyingChannel2 = Mockito.mock(ManagedChannel.class);
+
+ FakeChannelFactory channelFactory =
+ new FakeChannelFactory(ImmutableList.of(underlyingChannel1, underlyingChannel2));
+
+ // Create a temp file to act as the cert
+ tempCert = Files.createTempFile("cert", ".pem");
+
+ Path clientCert = Paths.get("src", "test", "resources", "client_cert.pem");
+ Files.copy(clientCert, tempCert, StandardCopyOption.REPLACE_EXISTING);
+
+ ChannelPoolSettings channelPoolSettings =
+ ChannelPoolSettings.builder().setInitialChannelCount(1).build();
+
+ pool = ChannelPool.create(channelPoolSettings, channelFactory, null, tempCert.toString());
+
+ // Initially uses channel1
+ pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
+ Mockito.verify(underlyingChannel1, Mockito.times(1))
+ .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
+
+ // Try a reactive refresh *without* changing the cert content (should no-op)
+ pool.refresh();
+
+ // Verify it's STILL channel1
+ pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
+ Mockito.verify(underlyingChannel1, Mockito.times(2))
+ .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
+
+ // The ChannelPool caches fingerprints for 1000ms, wait for it to expire
+ pool.invalidateDiskFingerprintCache();
+
+ Path rootCert = Paths.get("src", "test", "resources", "root_cert.pem");
+ Files.copy(rootCert, tempCert, StandardCopyOption.REPLACE_EXISTING);
+
+ // Try a reactive refresh *with* a changed cert content (should swap channels)
+ pool.refresh();
+
+ // Verify it is NOW channel2
+ pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
+ Mockito.verify(underlyingChannel2, Mockito.times(1))
+ .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
+ }
+
+ @Test
+ void channelReactiveMTlsRefresh_failedCreationDoesNotMutateFingerprintAndAllowsRetry()
+ throws IOException {
+ ManagedChannel channel1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel channel2 = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory =
+ Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
+
+ // Initial creation returns channel1, refresh attempt 1 throws IOException, refresh attempt 2
+ // returns channel2
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(channel1)
+ .thenThrow(new IOException("Transient channel creation error"))
+ .thenReturn(channel2);
+
+ tempCert = Files.createTempFile("cert", ".pem");
+ Path clientCert = Paths.get("src", "test", "resources", "client_cert.pem");
+ Files.copy(clientCert, tempCert, StandardCopyOption.REPLACE_EXISTING);
+
+ ChannelPoolSettings channelPoolSettings =
+ ChannelPoolSettings.builder().setInitialChannelCount(1).build();
+
+ pool = ChannelPool.create(channelPoolSettings, channelFactory, null, tempCert.toString());
+
+ // Initially uses channel1
+ pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
+ Mockito.verify(channel1, Mockito.times(1))
+ .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
+
+ // Rotate cert on disk
+ pool.invalidateDiskFingerprintCache();
+ Path rootCert = Paths.get("src", "test", "resources", "root_cert.pem");
+ Files.copy(rootCert, tempCert, StandardCopyOption.REPLACE_EXISTING);
+
+ // Refresh attempt 1: createSingleChannel throws IOException.
+ // Refresh should fail to replace channel and MUST NOT record the new cert fingerprint as
+ // active.
+ pool.refresh();
+
+ // Verify still channel1
+ pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
+ Mockito.verify(channel1, Mockito.times(2))
+ .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
+
+ // Refresh attempt 2: with the same cert file on disk (cache expired), channelFactory now
+ // succeeds.
+ // If the fingerprint had been mutated on the failed attempt, this call would be skipped as a
+ // duplicate!
+ pool.invalidateDiskFingerprintCache();
+ pool.refresh();
+
+ // Verify it has now swapped to channel2!
+ pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
+ Mockito.verify(channel2, Mockito.times(1))
+ .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
+ }
+
+ private void writeCert(String resourceName) throws IOException {
+ if (tempCert == null) {
+ tempCert = Files.createTempFile("cert", ".pem");
+ }
+ Files.copy(
+ Paths.get("src", "test", "resources", resourceName),
+ tempCert,
+ StandardCopyOption.REPLACE_EXISTING);
+ }
+
+ /** Creates an mTLS pool backed by {@code executor} and then rotates the certificate on disk. */
+ private ChannelPool createMtlsPoolAndRotateCert(
+ ChannelPoolSettings settings,
+ ChannelFactory channelFactory,
+ ScheduledExecutorService executor)
+ throws IOException {
+ writeCert("client_cert.pem");
+ pool =
+ new ChannelPool(
+ settings, channelFactory, FixedExecutorProvider.create(executor), tempCert.toString());
+ pool.invalidateDiskFingerprintCache();
+ writeCert("root_cert.pem");
+ assertThat(pool.shouldRefresh()).isTrue();
+ return pool;
+ }
+
+ private static ScheduledExecutorService mockExecutor() {
+ return Mockito.mock(
+ ScheduledExecutorService.class, Mockito.withSettings().withoutAnnotations());
+ }
+
+ private static ChannelFactory mockChannelFactory() {
+ return Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
+ }
+
+ @Test
+ void channelReactiveMTlsRefresh_partialFailureInStaticPool_dropsUnrefreshedChannelsAndRefills()
+ throws IOException {
+ ScheduledExecutorService executor = mockExecutor();
+ ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel rotated1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel refilled = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(initial1, initial2)
+ .thenReturn(rotated1)
+ .thenThrow(new IOException("Transient failure on second sub-channel"))
+ .thenReturn(refilled);
+
+ createMtlsPoolAndRotateCert(ChannelPoolSettings.staticallySized(2), channelFactory, executor);
+ long genBefore = pool.getGeneration();
+
+ pool.refresh();
+
+ // Only the newly created channel is kept; both channels on the old certificate are retired.
+ assertThat(pool.entries.get()).hasSize(1);
+ Mockito.verify(initial1).shutdown();
+ Mockito.verify(initial2).shutdown();
+ Mockito.verify(rotated1, Mockito.never()).shutdown();
+ pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
+ Mockito.verify(rotated1)
+ .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
+ assertThat(pool.getGeneration()).isEqualTo(genBefore + 1);
+ // Every channel left in the pool uses the new certificate, so it is recorded as active.
+ pool.invalidateDiskFingerprintCache();
+ assertThat(pool.shouldRefresh()).isFalse();
+
+ // A one-time refill restores the configured size.
+ ArgumentCaptor refillTask = ArgumentCaptor.forClass(Runnable.class);
+ Mockito.verify(executor).execute(refillTask.capture());
+ refillTask.getValue().run();
+ assertThat(pool.entries.get()).hasSize(2);
+ Mockito.verify(channelFactory, Mockito.times(5)).createSingleChannel();
+
+ // Running the refill again on a full pool creates no channels.
+ refillTask.getValue().run();
+ assertThat(pool.entries.get()).hasSize(2);
+ Mockito.verify(channelFactory, Mockito.times(5)).createSingleChannel();
+ Mockito.verify(refilled, Mockito.never()).shutdown();
+ Mockito.verify(executor, Mockito.times(1)).execute(Mockito.any(Runnable.class));
+ }
+
+ @Test
+ void channelReactiveMTlsRefresh_fullSuccess_doesNotScheduleRefill() throws IOException {
+ ScheduledExecutorService executor = mockExecutor();
+ ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel rotated1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel rotated2 = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(initial1, initial2, rotated1, rotated2);
+
+ createMtlsPoolAndRotateCert(ChannelPoolSettings.staticallySized(2), channelFactory, executor);
+
+ pool.refresh();
+
+ assertThat(pool.entries.get()).hasSize(2);
+ Mockito.verify(initial1).shutdown();
+ Mockito.verify(initial2).shutdown();
+ pool.invalidateDiskFingerprintCache();
+ assertThat(pool.shouldRefresh()).isFalse();
+ Mockito.verify(executor, Mockito.never()).execute(Mockito.any(Runnable.class));
+ }
+
+ @Test
+ void refreshAll_partialFailureWithoutRotation_keepsOldChannelAndDoesNotScheduleRefill()
+ throws IOException {
+ // Non-rotation refreshes (e.g. the preemptive refresh) keep per-slot fallback even for mTLS.
+ ScheduledExecutorService executor = mockExecutor();
+ ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel refreshed1 = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(initial1, initial2)
+ .thenReturn(refreshed1)
+ .thenThrow(new IOException("Transient failure on second sub-channel"));
+
+ writeCert("client_cert.pem");
+ pool =
+ new ChannelPool(
+ ChannelPoolSettings.staticallySized(2),
+ channelFactory,
+ FixedExecutorProvider.create(executor),
+ tempCert.toString());
+ long genBefore = pool.getGeneration();
+ List before = pool.entries.get();
+
+ assertThat(pool.refreshAll()).isFalse();
+
+ assertThat(pool.entries.get()).hasSize(2);
+ // Each channel keeps its slot, so affinity-to-index mapping is unchanged.
+ assertThat(pool.entries.get().get(0)).isNotSameInstanceAs(before.get(0));
+ assertThat(pool.entries.get().get(1)).isSameInstanceAs(before.get(1));
+ Mockito.verify(initial1).shutdown();
+ Mockito.verify(initial2, Mockito.never()).shutdown();
+ assertThat(pool.getGeneration()).isEqualTo(genBefore);
+ Mockito.verify(executor, Mockito.never()).execute(Mockito.any(Runnable.class));
+ }
+
+ @Test
+ void channelReactiveMTlsRefresh_allChannelsFail_leavesPoolUnchangedAndDoesNotRefill()
+ throws IOException {
+ ScheduledExecutorService executor = mockExecutor();
+ ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(initial1, initial2)
+ .thenThrow(new IOException("Failure on first sub-channel"))
+ .thenThrow(new IOException("Failure on second sub-channel"));
+
+ createMtlsPoolAndRotateCert(ChannelPoolSettings.staticallySized(2), channelFactory, executor);
+ long genBefore = pool.getGeneration();
+ List before = pool.entries.get();
+
+ pool.refresh();
+
+ // Nothing could be rebuilt, so the pool keeps its old channels and the rotation stays pending.
+ assertThat(pool.entries.get()).containsExactlyElementsIn(before).inOrder();
+ Mockito.verify(initial1, Mockito.never()).shutdown();
+ Mockito.verify(initial2, Mockito.never()).shutdown();
+ assertThat(pool.getGeneration()).isEqualTo(genBefore);
+ pool.invalidateDiskFingerprintCache();
+ assertThat(pool.shouldRefresh()).isTrue();
+ Mockito.verify(executor, Mockito.never()).execute(Mockito.any(Runnable.class));
+ }
+
+ @Test
+ void channelReactiveMTlsRefresh_partialFailureInDynamicPool_refillsToPreRefreshSize()
+ throws IOException {
+ ScheduledExecutorService executor = mockExecutor();
+ ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel rotated1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel refilled = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(initial1, initial2)
+ .thenReturn(rotated1)
+ .thenThrow(new IOException("Transient failure on second sub-channel"))
+ .thenReturn(refilled);
+
+ createMtlsPoolAndRotateCert(
+ ChannelPoolSettings.builder()
+ .setInitialChannelCount(2)
+ .setMinChannelCount(2)
+ .setMaxChannelCount(4)
+ .setMinRpcsPerChannel(1)
+ .setMaxRpcsPerChannel(2)
+ .build(),
+ channelFactory,
+ executor);
+
+ pool.refresh();
+
+ assertThat(pool.entries.get()).hasSize(1);
+ Mockito.verify(initial1).shutdown();
+ Mockito.verify(initial2).shutdown();
+ pool.invalidateDiskFingerprintCache();
+ assertThat(pool.shouldRefresh()).isFalse();
+
+ // Dynamic pools are refilled right away too, rather than waiting for resize().
+ ArgumentCaptor refillTask = ArgumentCaptor.forClass(Runnable.class);
+ Mockito.verify(executor).execute(refillTask.capture());
+ refillTask.getValue().run();
+ assertThat(pool.entries.get()).hasSize(2);
+ Mockito.verify(refilled, Mockito.never()).shutdown();
+ }
+
+ @Test
+ void channelReactiveMTlsRefresh_partialFailureAfterResize_refillsToPreRefreshSize()
+ throws IOException {
+ ScheduledExecutorService executor = mockExecutor();
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(
+ // initial pool of 4
+ Mockito.mock(ManagedChannel.class),
+ Mockito.mock(ManagedChannel.class),
+ Mockito.mock(ManagedChannel.class),
+ Mockito.mock(ManagedChannel.class),
+ // rotation refresh: 1 of the 2 remaining channels is recreated
+ Mockito.mock(ManagedChannel.class))
+ .thenThrow(new IOException("Transient failure on second sub-channel"))
+ .thenReturn(Mockito.mock(ManagedChannel.class));
+
+ createMtlsPoolAndRotateCert(
+ ChannelPoolSettings.builder()
+ .setInitialChannelCount(4)
+ .setMinChannelCount(2)
+ .setMaxChannelCount(6)
+ .setMinRpcsPerChannel(1)
+ .setMaxRpcsPerChannel(2)
+ .build(),
+ channelFactory,
+ executor);
+ // With no load, resize() shrinks the pool below its initial channel count.
+ pool.resize();
+ assertThat(pool.entries.get()).hasSize(2);
+
+ pool.refresh();
+ assertThat(pool.entries.get()).hasSize(1);
+
+ // The refill targets the size before the refresh, not the initial channel count.
+ ArgumentCaptor refillTask = ArgumentCaptor.forClass(Runnable.class);
+ Mockito.verify(executor).execute(refillTask.capture());
+ refillTask.getValue().run();
+ assertThat(pool.entries.get()).hasSize(2);
+ Mockito.verify(channelFactory, Mockito.times(7)).createSingleChannel();
+ }
+
+ @Test
+ void refill_afterShutdown_createsNoChannels() throws IOException {
+ ScheduledExecutorService executor = mockExecutor();
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(
+ Mockito.mock(ManagedChannel.class),
+ Mockito.mock(ManagedChannel.class),
+ Mockito.mock(ManagedChannel.class))
+ .thenThrow(new IOException("Transient failure on second sub-channel"))
+ .thenReturn(Mockito.mock(ManagedChannel.class));
+
+ createMtlsPoolAndRotateCert(ChannelPoolSettings.staticallySized(2), channelFactory, executor);
+ pool.refresh();
+ ArgumentCaptor refillTask = ArgumentCaptor.forClass(Runnable.class);
+ Mockito.verify(executor).execute(refillTask.capture());
+
+ pool.shutdown();
+ refillTask.getValue().run();
+
+ assertThat(pool.entries.get()).hasSize(1);
+ Mockito.verify(channelFactory, Mockito.times(4)).createSingleChannel();
+ }
+
+ @Test
+ void resize_afterShutdown_createsNoChannels() throws IOException {
+ ScheduledExecutorService executor = mockExecutor();
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(
+ Mockito.mock(ManagedChannel.class),
+ Mockito.mock(ManagedChannel.class),
+ Mockito.mock(ManagedChannel.class))
+ .thenThrow(new IOException("Transient failure on second sub-channel"))
+ .thenReturn(Mockito.mock(ManagedChannel.class));
+ createMtlsPoolAndRotateCert(
+ ChannelPoolSettings.builder()
+ .setInitialChannelCount(2)
+ .setMinChannelCount(2)
+ .setMaxChannelCount(4)
+ .setMinRpcsPerChannel(1)
+ .setMaxRpcsPerChannel(2)
+ .build(),
+ channelFactory,
+ executor);
+ ArgumentCaptor resizeTask = ArgumentCaptor.forClass(Runnable.class);
+ Mockito.verify(executor)
+ .scheduleAtFixedRate(
+ resizeTask.capture(), Mockito.anyLong(), Mockito.anyLong(), Mockito.any());
+ // Drop the pool below its minimum size, so that resize() would expand it.
+ pool.refresh();
+ assertThat(pool.entries.get()).hasSize(1);
+
+ // A resize run that was already waiting for the lock when the pool was shut down.
+ pool.shutdown();
+ resizeTask.getValue().run();
+
+ assertThat(pool.entries.get()).hasSize(1);
+ Mockito.verify(channelFactory, Mockito.times(4)).createSingleChannel();
+ }
+
+ @Test
+ void refill_channelCreationFailure_isLoggedAndDoesNotThrow() throws IOException {
+ ScheduledExecutorService executor = mockExecutor();
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(
+ Mockito.mock(ManagedChannel.class),
+ Mockito.mock(ManagedChannel.class),
+ Mockito.mock(ManagedChannel.class))
+ .thenThrow(new IOException("Transient failure on second sub-channel"))
+ .thenThrow(new IOException("Checked failure during refill"))
+ .thenThrow(new RuntimeException("Unchecked failure during refill"))
+ .thenReturn(Mockito.mock(ManagedChannel.class));
+
+ createMtlsPoolAndRotateCert(ChannelPoolSettings.staticallySized(2), channelFactory, executor);
+ pool.refresh();
+ ArgumentCaptor refillTask = ArgumentCaptor.forClass(Runnable.class);
+ Mockito.verify(executor).execute(refillTask.capture());
+
+ // Checked and unchecked failures are both handled by expand(); the pool stays usable at its
+ // reduced size.
+ FakeLogHandler logHandler = new FakeLogHandler();
+ ChannelPool.LOG.addHandler(logHandler);
+ try {
+ refillTask.getValue().run();
+ assertThat(pool.entries.get()).hasSize(1);
+
+ refillTask.getValue().run();
+ assertThat(pool.entries.get()).hasSize(1);
+ } finally {
+ ChannelPool.LOG.removeHandler(logHandler);
+ }
+ assertThat(logHandler.getAllMessages())
+ .containsExactly("Failed to add channel", "Failed to add channel");
+
+ refillTask.getValue().run();
+ assertThat(pool.entries.get()).hasSize(2);
+ }
+
+ @Test
+ void refill_failureAfterPartialProgress_keepsCreatedChannels() throws IOException {
+ ScheduledExecutorService executor = mockExecutor();
+ ManagedChannel refilled = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(
+ Mockito.mock(ManagedChannel.class),
+ Mockito.mock(ManagedChannel.class),
+ Mockito.mock(ManagedChannel.class),
+ Mockito.mock(ManagedChannel.class))
+ .thenThrow(new IOException("Transient failure on second sub-channel"))
+ .thenThrow(new IOException("Transient failure on third sub-channel"))
+ .thenReturn(refilled)
+ .thenThrow(new RuntimeException("Unchecked failure during refill"));
+
+ createMtlsPoolAndRotateCert(ChannelPoolSettings.staticallySized(3), channelFactory, executor);
+ pool.refresh();
+ assertThat(pool.entries.get()).hasSize(1);
+ ArgumentCaptor refillTask = ArgumentCaptor.forClass(Runnable.class);
+ Mockito.verify(executor).execute(refillTask.capture());
+
+ refillTask.getValue().run();
+
+ // The channel created before the failure is added to the pool rather than orphaned.
+ assertThat(pool.entries.get()).hasSize(2);
+ Mockito.verify(refilled, Mockito.never()).shutdown();
+ Mockito.verify(channelFactory, Mockito.times(8)).createSingleChannel();
+ }
+
+ @Test
+ void channelReactiveMTlsRefresh_refillRejectedByExecutor_stillCompletesRefresh()
+ throws IOException {
+ ScheduledExecutorService executor = mockExecutor();
+ Mockito.doThrow(new RejectedExecutionException("Executor shut down"))
+ .when(executor)
+ .execute(Mockito.any(Runnable.class));
+ ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(initial1, initial2, Mockito.mock(ManagedChannel.class))
+ .thenThrow(new IOException("Transient failure on second sub-channel"));
+
+ createMtlsPoolAndRotateCert(ChannelPoolSettings.staticallySized(2), channelFactory, executor);
+ long genBefore = pool.getGeneration();
+
+ pool.refresh();
+
+ Mockito.verify(executor).execute(Mockito.any(Runnable.class));
+ assertThat(pool.entries.get()).hasSize(1);
+ Mockito.verify(initial1).shutdown();
+ Mockito.verify(initial2).shutdown();
+ assertThat(pool.getGeneration()).isEqualTo(genBefore + 1);
+ pool.invalidateDiskFingerprintCache();
+ assertThat(pool.shouldRefresh()).isFalse();
+ }
+
+ @Test
+ void refreshAll_runtimeExceptionOrError_doesNotLeakCreatedChannels() throws IOException {
+ ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel createdBeforeRuntimeEx = Mockito.mock(ManagedChannel.class);
+ ManagedChannel createdBeforeError = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory =
+ Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
+
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(initial1, initial2)
+ .thenReturn(createdBeforeRuntimeEx)
+ .thenThrow(new RuntimeException("Unchecked runtime exception"))
+ .thenReturn(createdBeforeError)
+ .thenThrow(new AssertionError("Simulated Error during refresh"));
+
+ pool = ChannelPool.create(ChannelPoolSettings.staticallySized(2), channelFactory, null, null);
+
+ // Case 1: RuntimeException on channel 1 after creating channel 0 -> caught as Exception,
+ // partial progress committed
+ boolean allCreated = pool.refreshAll();
+ assertThat(allCreated).isFalse();
+ Mockito.verify(initial1).shutdown();
+
+ // Case 2: Error on channel 1 after creating channel 0 -> aborts, finally block must shut down
+ // createdBeforeError
+ assertThrows(AssertionError.class, () -> pool.refreshAll());
+ Mockito.verify(createdBeforeError).shutdown();
+ }
+
+ @Test
+ void refresh_onShutdownPool_noOpsAndCreatesNoChannels() throws IOException {
+ ManagedChannel channel1 = mock(ManagedChannel.class);
+ ManagedChannel channel2 = mock(ManagedChannel.class);
+ ChannelFactory channelFactory =
+ Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
+ Mockito.when(channelFactory.createSingleChannel()).thenReturn(channel1, channel2);
+
+ pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
+ Mockito.verify(channelFactory, Mockito.times(1)).createSingleChannel();
+
+ pool.shutdown();
+ assertThat(pool.isShutdown()).isTrue();
+
+ // Invoking refresh or refreshAll on shut down pool must no-op and never create new subchannels
+ pool.refresh();
+ boolean refreshed = pool.refreshAll();
+ assertThat(refreshed).isFalse();
+ Mockito.verify(channelFactory, Mockito.times(1)).createSingleChannel();
+ assertThat(pool.isShutdown()).isTrue();
+ }
+
+ @Test
+ void refreshAll_doesNotIncrementGeneration() throws IOException {
+ ManagedChannel channel1 = mock(ManagedChannel.class);
+ ManagedChannel channel2 = mock(ManagedChannel.class);
+ ChannelFactory channelFactory =
+ Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
+ Mockito.when(channelFactory.createSingleChannel()).thenReturn(channel1, channel2);
+
+ pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
+ assertThat(pool.getGeneration()).isEqualTo(0);
+
+ // Only a switch to a new certificate advances the generation.
+ assertThat(pool.refreshAll()).isTrue();
+ Mockito.verify(channel1).shutdown();
+ assertThat(pool.getGeneration()).isEqualTo(0);
+ }
+
+ /**
+ * Creates an mTLS pool with preemptive refresh enabled and returns the scheduled periodic refresh
+ * task.
+ */
+ private Runnable createPreemptiveRefreshMtlsPool(int size, ChannelFactory channelFactory)
+ throws IOException {
+ return createPreemptiveRefreshMtlsPool(size, channelFactory, mockExecutor());
+ }
+
+ private Runnable createPreemptiveRefreshMtlsPool(
+ int size, ChannelFactory channelFactory, ScheduledExecutorService executor)
+ throws IOException {
+ List refreshTasks = new ArrayList<>();
+ Mockito.doAnswer(
+ invocation -> {
+ refreshTasks.add(invocation.getArgument(0));
+ return Mockito.mock(
+ ScheduledFuture.class, Mockito.withSettings().withoutAnnotations());
+ })
+ .when(executor)
+ .scheduleAtFixedRate(
+ Mockito.any(Runnable.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.any());
+ writeCert("client_cert.pem");
+ pool =
+ new ChannelPool(
+ ChannelPoolSettings.staticallySized(size).toBuilder()
+ .setPreemptiveRefreshEnabled(true)
+ .build(),
+ channelFactory,
+ FixedExecutorProvider.create(executor),
+ tempCert.toString());
+ assertThat(refreshTasks).hasSize(1);
+ return refreshTasks.get(0);
+ }
+
+ private String readCertFingerprint() {
+ return new CertificateRotationTracker(tempCert.toString()).readDiskFingerprint();
+ }
+
+ @Test
+ void preemptiveRefresh_withoutRotation_doesNotIncrementGeneration() throws IOException {
+ ManagedChannel initial = Mockito.mock(ManagedChannel.class);
+ ManagedChannel refreshed = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel()).thenReturn(initial, refreshed);
+ Runnable preemptiveRefresh = createPreemptiveRefreshMtlsPool(1, channelFactory);
+
+ FakeLogHandler logHandler = new FakeLogHandler();
+ Level originalLevel = ChannelPool.LOG.getLevel();
+ ChannelPool.LOG.setLevel(Level.FINE);
+ ChannelPool.LOG.addHandler(logHandler);
+ try {
+ pool.invalidateDiskFingerprintCache();
+ preemptiveRefresh.run();
+ } finally {
+ ChannelPool.LOG.removeHandler(logHandler);
+ ChannelPool.LOG.setLevel(originalLevel);
+ }
+
+ // The channels are replaced, but the certificate did not change.
+ Mockito.verify(initial).shutdown();
+ assertThat(pool.getGeneration()).isEqualTo(0);
+ assertThat(logHandler.getAllMessages()).contains("Refreshing all channels");
+ assertThat(String.join("\n", logHandler.getAllMessages()))
+ .doesNotContain("Channel pool switched to certificate");
+ }
+
+ @Test
+ void preemptiveRefresh_pickingUpRotatedCert_incrementsGeneration() throws IOException {
+ ManagedChannel initial = Mockito.mock(ManagedChannel.class);
+ ManagedChannel rotated = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel()).thenReturn(initial, rotated);
+ Runnable preemptiveRefresh = createPreemptiveRefreshMtlsPool(1, channelFactory);
+
+ pool.invalidateDiskFingerprintCache();
+ writeCert("root_cert.pem");
+ preemptiveRefresh.run();
+
+ Mockito.verify(initial).shutdown();
+ assertThat(pool.getGeneration()).isEqualTo(1);
+ pool.invalidateDiskFingerprintCache();
+ assertThat(pool.shouldRefresh()).isFalse();
+ }
+
+ @Test
+ void preemptiveRefresh_whenCertUnreadable_skipsRefreshAndLogs() throws IOException {
+ ManagedChannel initial = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel()).thenReturn(initial);
+ Runnable preemptiveRefresh = createPreemptiveRefreshMtlsPool(1, channelFactory);
+
+ // An empty certificate file is what a reader sees while the certificate is being rewritten.
+ Files.write(tempCert, new byte[0]);
+ FakeLogHandler logHandler = new FakeLogHandler();
+ Level originalLevel = ChannelPool.LOG.getLevel();
+ ChannelPool.LOG.setLevel(Level.FINE);
+ ChannelPool.LOG.addHandler(logHandler);
+ try {
+ pool.invalidateDiskFingerprintCache();
+ preemptiveRefresh.run();
+ } finally {
+ ChannelPool.LOG.removeHandler(logHandler);
+ ChannelPool.LOG.setLevel(originalLevel);
+ }
+
+ Mockito.verify(channelFactory, Mockito.times(1)).createSingleChannel();
+ Mockito.verify(initial, Mockito.never()).shutdown();
+ assertThat(pool.getGeneration()).isEqualTo(0);
+ assertThat(String.join("\n", logHandler.getAllMessages()))
+ .contains("Skipping pre-emptive channel refresh");
+ assertThat(logHandler.getAllMessages()).doesNotContain("Refreshing all channels");
+ }
+
+ @Test
+ void preemptiveRefresh_partialFailureOnCertChange_dropsOldChannelsAndCompletesSwitch()
+ throws IOException {
+ ScheduledExecutorService executor = mockExecutor();
+ ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel rotated1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel refilled = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(initial1, initial2)
+ .thenReturn(rotated1)
+ .thenThrow(new IOException("Transient failure on second sub-channel"))
+ .thenReturn(refilled);
+ Runnable preemptiveRefresh = createPreemptiveRefreshMtlsPool(2, channelFactory, executor);
+
+ pool.invalidateDiskFingerprintCache();
+ writeCert("root_cert.pem");
+ preemptiveRefresh.run();
+
+ // The channel that failed to refresh still uses the old certificate, so it is dropped and
+ // every channel left in the pool uses the new certificate.
+ Mockito.verify(initial1).shutdown();
+ Mockito.verify(initial2).shutdown();
+ assertThat(pool.entries.get()).hasSize(1);
+ assertThat(pool.getGeneration()).isEqualTo(1);
+ pool.invalidateDiskFingerprintCache();
+ assertThat(pool.shouldRefresh()).isFalse();
+
+ // The pool is refilled to its size before the refresh.
+ ArgumentCaptor refillTask = ArgumentCaptor.forClass(Runnable.class);
+ Mockito.verify(executor).execute(refillTask.capture());
+ refillTask.getValue().run();
+ assertThat(pool.entries.get()).hasSize(2);
+ Mockito.verify(refilled, Mockito.never()).shutdown();
+ }
+
+ @Test
+ void preemptiveRefresh_allChannelsFailOnCertChange_leavesRotationPending() throws IOException {
+ ScheduledExecutorService executor = mockExecutor();
+ ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(initial1, initial2)
+ .thenThrow(new IOException("Transient failure"));
+ Runnable preemptiveRefresh = createPreemptiveRefreshMtlsPool(2, channelFactory, executor);
+ List entriesBefore = pool.entries.get();
+
+ pool.invalidateDiskFingerprintCache();
+ writeCert("root_cert.pem");
+ preemptiveRefresh.run();
+
+ // No channel could be recreated, so the pool is left unchanged and the rotation stays pending
+ // for the next periodic or reactive refresh.
+ assertThat(pool.entries.get()).containsExactlyElementsIn(entriesBefore).inOrder();
+ Mockito.verify(initial1, Mockito.never()).shutdown();
+ Mockito.verify(initial2, Mockito.never()).shutdown();
+ assertThat(pool.getGeneration()).isEqualTo(0);
+ pool.invalidateDiskFingerprintCache();
+ assertThat(pool.shouldRefresh()).isTrue();
+ Mockito.verify(executor, Mockito.never()).execute(Mockito.any(Runnable.class));
+ }
+
+ @Test
+ void preemptiveRefresh_partialFailureWithoutCertChange_keepsOldChannel() throws IOException {
+ ScheduledExecutorService executor = mockExecutor();
+ ManagedChannel initial1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel initial2 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel refreshed1 = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(initial1, initial2)
+ .thenReturn(refreshed1)
+ .thenThrow(new IOException("Transient failure on second sub-channel"));
+ Runnable preemptiveRefresh = createPreemptiveRefreshMtlsPool(2, channelFactory, executor);
+ ChannelPool.Entry initialEntry2 = pool.entries.get().get(1);
+
+ pool.invalidateDiskFingerprintCache();
+ preemptiveRefresh.run();
+
+ // The certificate did not change, so the channel that failed to refresh keeps its slot.
+ Mockito.verify(initial1).shutdown();
+ Mockito.verify(initial2, Mockito.never()).shutdown();
+ assertThat(pool.entries.get()).hasSize(2);
+ assertThat(pool.entries.get().get(1)).isSameInstanceAs(initialEntry2);
+ assertThat(pool.getGeneration()).isEqualTo(0);
+ Mockito.verify(executor, Mockito.never()).execute(Mockito.any(Runnable.class));
+ }
+
+ /** Waits until {@code thread} is blocked waiting for a monitor lock. */
+ private static void awaitBlocked(Thread thread) throws InterruptedException {
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (thread.getState() != Thread.State.BLOCKED) {
+ assertThat(System.nanoTime()).isLessThan(deadline);
+ Thread.sleep(1);
+ }
+ }
+
+ /**
+ * Starts a reactive refresh that holds the pool lock while it recreates the channel on the
+ * rotated certificate, runs {@code waiter} on another thread until it blocks on the lock, and
+ * rotates the certificate again before letting the first refresh complete. A waiter that re-read
+ * the certificate would see the second rotation and refresh again.
+ */
+ private void runWhileConcurrentSwitchHoldsLock(
+ CountDownLatch switchStarted, CountDownLatch releaseSwitch, Runnable waiter)
+ throws Exception {
+ Thread switchingThread = new Thread(pool::refresh);
+ Thread waitingThread = new Thread(waiter);
+ try {
+ switchingThread.start();
+ assertThat(switchStarted.await(5, TimeUnit.SECONDS)).isTrue();
+ waitingThread.start();
+ awaitBlocked(waitingThread);
+ writeCert("client_cert.pem");
+ } finally {
+ releaseSwitch.countDown();
+ }
+ switchingThread.join(5000);
+ waitingThread.join(5000);
+ assertThat(switchingThread.isAlive()).isFalse();
+ assertThat(waitingThread.isAlive()).isFalse();
+ }
+
+ @Test
+ void refresh_concurrentSwitchWhileWaitingForLock_skipsWithoutReadingDisk() throws Exception {
+ CountDownLatch switchStarted = new CountDownLatch(1);
+ CountDownLatch releaseSwitch = new CountDownLatch(1);
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(Mockito.mock(ManagedChannel.class))
+ .thenAnswer(
+ invocation -> {
+ switchStarted.countDown();
+ releaseSwitch.await();
+ return Mockito.mock(ManagedChannel.class);
+ })
+ .thenReturn(Mockito.mock(ManagedChannel.class));
+ createMtlsPoolAndRotateCert(
+ ChannelPoolSettings.staticallySized(1), channelFactory, mockExecutor());
+
+ runWhileConcurrentSwitchHoldsLock(switchStarted, releaseSwitch, pool::refresh);
+
+ // Only the initial channel and the concurrent switch created channels; the waiter skipped.
+ Mockito.verify(channelFactory, Mockito.times(2)).createSingleChannel();
+ assertThat(pool.getGeneration()).isEqualTo(1);
+ // The second rotation is still detected for the next UNAUTHENTICATED failure.
+ pool.invalidateDiskFingerprintCache();
+ assertThat(pool.shouldRefresh()).isTrue();
+ }
+
+ @Test
+ void preemptiveRefresh_concurrentSwitchWhileWaitingForLock_skipsRefresh() throws Exception {
+ CountDownLatch switchStarted = new CountDownLatch(1);
+ CountDownLatch releaseSwitch = new CountDownLatch(1);
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(Mockito.mock(ManagedChannel.class))
+ .thenAnswer(
+ invocation -> {
+ switchStarted.countDown();
+ releaseSwitch.await();
+ return Mockito.mock(ManagedChannel.class);
+ })
+ .thenReturn(Mockito.mock(ManagedChannel.class));
+ Runnable preemptiveRefresh = createPreemptiveRefreshMtlsPool(1, channelFactory);
+ pool.invalidateDiskFingerprintCache();
+ writeCert("root_cert.pem");
+
+ runWhileConcurrentSwitchHoldsLock(switchStarted, releaseSwitch, preemptiveRefresh);
+
+ // The channels were just recreated by the concurrent switch, so the periodic refresh skipped.
+ Mockito.verify(channelFactory, Mockito.times(2)).createSingleChannel();
+ assertThat(pool.getGeneration()).isEqualTo(1);
+ pool.invalidateDiskFingerprintCache();
+ assertThat(pool.shouldRefresh()).isTrue();
+ }
+
+ @Test
+ void refresh_whenCertUnchanged_noOpsAndDoesNotIncrementGeneration() throws IOException {
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(Mockito.mock(ManagedChannel.class), Mockito.mock(ManagedChannel.class));
+ writeCert("client_cert.pem");
+ pool =
+ new ChannelPool(
+ ChannelPoolSettings.staticallySized(1),
+ channelFactory,
+ FixedExecutorProvider.create(mockExecutor()),
+ tempCert.toString());
+
+ pool.invalidateDiskFingerprintCache();
+ pool.refresh();
+
+ assertThat(pool.getGeneration()).isEqualTo(0);
+ Mockito.verify(channelFactory, Mockito.times(1)).createSingleChannel();
+ }
+
+ @Test
+ void refresh_onRotation_logsNewCertificateFingerprint() throws IOException {
+ ChannelFactory channelFactory = mockChannelFactory();
+ Mockito.when(channelFactory.createSingleChannel())
+ .thenReturn(Mockito.mock(ManagedChannel.class), Mockito.mock(ManagedChannel.class));
+ writeCert("client_cert.pem");
+ String oldFingerprint = readCertFingerprint();
+ createMtlsPoolAndRotateCert(
+ ChannelPoolSettings.staticallySized(1), channelFactory, mockExecutor());
+ String newFingerprint = readCertFingerprint();
+ assertThat(newFingerprint).isNotEqualTo(oldFingerprint);
+
+ FakeLogHandler logHandler = new FakeLogHandler();
+ Level originalLevel = ChannelPool.LOG.getLevel();
+ ChannelPool.LOG.setLevel(Level.FINE);
+ ChannelPool.LOG.addHandler(logHandler);
+ try {
+ pool.refresh();
+ } finally {
+ ChannelPool.LOG.removeHandler(logHandler);
+ ChannelPool.LOG.setLevel(originalLevel);
+ }
+
+ assertThat(pool.getGeneration()).isEqualTo(1);
+ assertThat(logHandler.getAllMessages())
+ .contains("Channel pool switched to certificate with fingerprint: " + newFingerprint);
+ assertThat(String.join("\n", logHandler.getAllMessages())).doesNotContain(oldFingerprint);
+ }
+
+ @Test
+ void shutdown_interruptsInProgressRefresh() throws Exception {
+ CountDownLatch refreshStarted = new CountDownLatch(1);
+ CountDownLatch release = new CountDownLatch(1);
+ AtomicBoolean refreshInterrupted = new AtomicBoolean();
+ AtomicInteger createdChannels = new AtomicInteger();
+ ChannelFactory channelFactory =
+ () -> {
+ if (createdChannels.getAndIncrement() > 0) {
+ // The preemptive refresh blocks while creating its replacement channel.
+ refreshStarted.countDown();
+ try {
+ release.await();
+ } catch (InterruptedException e) {
+ refreshInterrupted.set(true);
+ Thread.currentThread().interrupt();
+ throw new IOException("Interrupted while creating channel", e);
+ }
+ }
+ return Mockito.mock(ManagedChannel.class);
+ };
+ ScheduledExecutorService realExecutor = Executors.newSingleThreadScheduledExecutor();
+ ScheduledExecutorService executor = mockExecutor();
+ Mockito.doAnswer(
+ invocation ->
+ realExecutor.schedule(
+ (Runnable) invocation.getArgument(0), 0, TimeUnit.MILLISECONDS))
+ .when(executor)
+ .scheduleAtFixedRate(
+ Mockito.any(Runnable.class), Mockito.anyLong(), Mockito.anyLong(), Mockito.any());
+ try {
+ pool =
+ new ChannelPool(
+ ChannelPoolSettings.staticallySized(1).toBuilder()
+ .setPreemptiveRefreshEnabled(true)
+ .build(),
+ channelFactory,
+ FixedExecutorProvider.create(executor),
+ null);
+ assertThat(refreshStarted.await(5, TimeUnit.SECONDS)).isTrue();
+
+ // The refresh holds the pool's write lock; shutdown must interrupt it rather than wait.
+ Assertions.assertTimeoutPreemptively(Duration.ofSeconds(5), () -> pool.shutdown());
+
+ assertThat(refreshInterrupted.get()).isTrue();
+ assertThat(pool.isShutdown()).isTrue();
+ } finally {
+ release.countDown();
+ realExecutor.shutdownNow();
+ }
+ }
+
@Test
void channelRefreshShouldSwapChannels() throws IOException {
ManagedChannel underlyingChannel1 = mock(ManagedChannel.class);
@@ -450,7 +1470,8 @@ void channelRefreshShouldSwapChannels() throws IOException {
.setPreemptiveRefreshEnabled(true)
.build(),
channelFactory,
- provider);
+ provider,
+ null);
Mockito.reset(underlyingChannel1);
pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
@@ -459,7 +1480,7 @@ void channelRefreshShouldSwapChannels() throws IOException {
.newCall(Mockito.>any(), Mockito.any(CallOptions.class));
// swap channel
- pool.refresh();
+ pool.refreshAll();
pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
@@ -467,6 +1488,37 @@ void channelRefreshShouldSwapChannels() throws IOException {
.newCall(Mockito.>any(), Mockito.any(CallOptions.class));
}
+ @Test
+ void testRefreshWithNullWorkloadCertPathSwapsChannel() throws IOException {
+ ScheduledExecutorService executor =
+ Mockito.mock(ScheduledExecutorService.class, Mockito.withSettings().withoutAnnotations());
+ FixedExecutorProvider provider = FixedExecutorProvider.create(executor);
+ ManagedChannel underlyingChannel1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel underlyingChannel2 = Mockito.mock(ManagedChannel.class);
+ FakeChannelFactory channelFactory =
+ new FakeChannelFactory(ImmutableList.of(underlyingChannel1, underlyingChannel2));
+ pool =
+ new ChannelPool(
+ ChannelPoolSettings.staticallySized(1).toBuilder()
+ .setPreemptiveRefreshEnabled(true)
+ .build(),
+ channelFactory,
+ provider,
+ null);
+ Mockito.reset(underlyingChannel1);
+
+ pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
+ Mockito.verify(underlyingChannel1, Mockito.only())
+ .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
+
+ // Calling refresh() when workloadCertPath is null should fall back to refreshAll()
+ pool.refresh();
+
+ pool.newCall(FakeMethodDescriptor.create(), CallOptions.DEFAULT);
+ Mockito.verify(underlyingChannel2, Mockito.only())
+ .newCall(Mockito.>any(), Mockito.any(CallOptions.class));
+ }
+
@Test
void channelCountShouldNotChangeWhenOutstandingRpcsAreWithinLimits() throws Exception {
ScheduledExecutorService executor =
@@ -486,7 +1538,8 @@ void channelCountShouldNotChangeWhenOutstandingRpcsAreWithinLimits() throws Exce
.setMaxRpcsPerChannel(2)
.build(),
channelFactory,
- provider);
+ provider,
+ null);
assertThat(pool.entries.get()).hasSize(2);
// Start the minimum number of
@@ -553,7 +1606,8 @@ void customResizeDeltaIsRespected() throws Exception {
.setMaxResizeDelta(5)
.build(),
channelFactory,
- provider);
+ provider,
+ null);
assertThat(pool.entries.get()).hasSize(2);
// Add 20 RPCs to push expansion
@@ -586,7 +1640,8 @@ void removedIdleChannelsAreShutdown() throws Exception {
.setMaxRpcsPerChannel(2)
.build(),
channelFactory,
- provider);
+ provider,
+ null);
assertThat(pool.entries.get()).hasSize(2);
// With no outstanding RPCs, the pool should shrink
@@ -614,7 +1669,8 @@ void removedActiveChannelsAreShutdown() throws Exception {
.setMaxRpcsPerChannel(2)
.build(),
channelFactory,
- provider);
+ provider,
+ null);
assertThat(pool.entries.get()).hasSize(2);
// Start 2 RPCs
@@ -652,7 +1708,7 @@ void testReleasingClientCallCancelEarly() throws IOException {
Mockito.when(fakeChannel.newCall(Mockito.any(), Mockito.any())).thenReturn(mockClientCall);
ChannelPoolSettings channelPoolSettings = ChannelPoolSettings.staticallySized(1);
ChannelFactory factory = new FakeChannelFactory(ImmutableList.of(fakeChannel));
- pool = ChannelPool.create(channelPoolSettings, factory, null);
+ pool = ChannelPool.create(channelPoolSettings, factory, null, null);
EndpointContext endpointContext =
Mockito.mock(EndpointContext.class, Mockito.withSettings().withoutAnnotations());
@@ -717,7 +1773,8 @@ void repeatedResizingLogsWarningOnExpand() throws Exception {
.setMaxChannelCount(10)
.build(),
channelFactory,
- provider);
+ provider,
+ null);
assertThat(pool.entries.get()).hasSize(1);
FakeLogHandler logHandler = new FakeLogHandler();
@@ -769,7 +1826,8 @@ void repeatedResizingLogsWarningOnShrink() throws Exception {
.setMaxChannelCount(10)
.build(),
channelFactory,
- provider);
+ provider,
+ null);
assertThat(pool.entries.get()).hasSize(10);
FakeLogHandler logHandler = new FakeLogHandler();
@@ -805,7 +1863,7 @@ void testDoubleRelease() throws Exception {
ChannelPoolSettings channelPoolSettings = ChannelPoolSettings.staticallySized(1);
ChannelFactory factory = new FakeChannelFactory(ImmutableList.of(fakeChannel));
- pool = ChannelPool.create(channelPoolSettings, factory, null);
+ pool = ChannelPool.create(channelPoolSettings, factory, null, null);
EndpointContext endpointContext =
Mockito.mock(EndpointContext.class, Mockito.withSettings().withoutAnnotations());
@@ -843,7 +1901,8 @@ void testDoubleRelease() throws Exception {
// Ensure that the channel pool properly logged the double call and kept the refCount correct
assertThat(logHandler.getAllMessages())
.contains(
- "Call is being closed more than once. Please make sure that onClose() is not being manually called.");
+ "Call is being closed more than once. Please make sure that onClose() is not being"
+ + " manually called.");
assertThat(pool.entries.get()).hasSize(1);
ChannelPool.Entry entry = pool.entries.get().get(0);
assertThat(entry.outstandingRpcs.get()).isEqualTo(0);
@@ -879,7 +1938,8 @@ void minChannelsClampedToMaxChannelCountUnderHighLoad() throws Exception {
.setMaxChannelCount(5)
.build(),
channelFactory,
- provider);
+ provider,
+ null);
assertThat(pool.entries.get()).hasSize(1);
// Add 20 RPCs, which would require 10 channels (20/2)
@@ -914,7 +1974,8 @@ void maxChannelsClampedToMinChannelCountUnderLowLoad() throws Exception {
.setMaxChannelCount(10)
.build(),
channelFactory,
- provider);
+ provider,
+ null);
assertThat(pool.entries.get()).hasSize(5);
// With no outstanding RPCs, the pool should want to shrink to 0
@@ -924,4 +1985,272 @@ void maxChannelsClampedToMinChannelCountUnderLowLoad() throws Exception {
// Should be clamped to minChannelCount = 3
assertThat(pool.entries.get()).hasSize(3);
}
+
+ @Test
+ void shouldRefresh_doesNotCacheNegativeResultAndDetectsSubsequentRotationImmediately()
+ throws IOException {
+ ManagedChannel initial = Mockito.mock(ManagedChannel.class);
+ ManagedChannel rotated = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory =
+ Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
+ Mockito.when(channelFactory.createSingleChannel()).thenReturn(initial, rotated);
+
+ tempCert = Files.createTempFile("cert", ".pem");
+ Path clientCert = Paths.get("src", "test", "resources", "client_cert.pem");
+ Files.copy(clientCert, tempCert, StandardCopyOption.REPLACE_EXISTING);
+
+ pool =
+ ChannelPool.create(
+ ChannelPoolSettings.staticallySized(1), channelFactory, null, tempCert.toString());
+
+ // First check returns false (unchanged disk cert)
+ assertThat(pool.shouldRefresh()).isFalse();
+
+ // Immediately rotate cert on disk WITHOUT invalidating the 1-second cache
+ Path rootCert = Paths.get("src", "test", "resources", "root_cert.pem");
+ Files.copy(rootCert, tempCert, StandardCopyOption.REPLACE_EXISTING);
+
+ // Must immediately detect rotation because negative/unchanged disk checks are not cached for 1s
+ assertThat(pool.shouldRefresh()).isTrue();
+
+ // Refresh should update activeCertFingerprint and clear any cached positive check
+ pool.refresh();
+ assertThat(pool.shouldRefresh()).isFalse();
+ }
+
+ @Test
+ void newCall_whenDelegateThrowsError_releasesEntryAndShutsDownRetiredChannel()
+ throws IOException {
+ ManagedChannel initial = Mockito.mock(ManagedChannel.class);
+ ManagedChannel rotated = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory =
+ Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
+ Mockito.when(channelFactory.createSingleChannel()).thenReturn(initial, rotated);
+ Mockito.when(initial.newCall(Mockito.any(), Mockito.any()))
+ .thenThrow(new LinkageError("Simulated native/JNI linkage error"));
+
+ pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
+
+ assertThrows(LinkageError.class, () -> pool.newCall(METHOD_RECOGNIZE, CallOptions.DEFAULT));
+
+ // Rotating the pool should immediately shut down initial channel because its ref count is 0
+ pool.refresh();
+ Mockito.verify(initial).shutdown();
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void start_whenDelegateThrowsError_releasesEntryAndShutsDownRetiredChannel() throws IOException {
+ ManagedChannel initial = Mockito.mock(ManagedChannel.class);
+ ManagedChannel rotated = Mockito.mock(ManagedChannel.class);
+ ClientCall mockCall = Mockito.mock(ClientCall.class);
+ ChannelFactory channelFactory =
+ Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
+ Mockito.when(channelFactory.createSingleChannel()).thenReturn(initial, rotated);
+ Mockito.when(initial.newCall(Mockito.any(), Mockito.any())).thenReturn((ClientCall) mockCall);
+ Mockito.doThrow(new AssertionError("Simulated Error in start"))
+ .when(mockCall)
+ .start(Mockito.any(), Mockito.any());
+
+ pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
+
+ ClientCall call = pool.newCall(METHOD_RECOGNIZE, CallOptions.DEFAULT);
+ // Rotate pool while call is retained
+ pool.refresh();
+ Mockito.verify(initial, Mockito.never()).shutdown();
+
+ // Calling start() throws Error, which must release the retained entry and trigger shutdown
+ assertThrows(
+ AssertionError.class,
+ () -> call.start(new ClientCall.Listener() {}, new Metadata()));
+ Mockito.verify(initial).shutdown();
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void cancel_whenDelegateThrowsException_releasesEntryAndShutsDownRetiredChannel()
+ throws IOException {
+ ManagedChannel initial = Mockito.mock(ManagedChannel.class);
+ ManagedChannel rotated = Mockito.mock(ManagedChannel.class);
+ ClientCall mockCall = Mockito.mock(ClientCall.class);
+ ChannelFactory channelFactory =
+ Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
+ Mockito.when(channelFactory.createSingleChannel()).thenReturn(initial, rotated);
+ Mockito.when(initial.newCall(Mockito.any(), Mockito.any())).thenReturn((ClientCall) mockCall);
+ Mockito.doThrow(new RuntimeException("Simulated cancel exception"))
+ .when(mockCall)
+ .cancel(Mockito.any(), Mockito.any());
+
+ pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
+
+ ClientCall call = pool.newCall(METHOD_RECOGNIZE, CallOptions.DEFAULT);
+ pool.refresh();
+ Mockito.verify(initial, Mockito.never()).shutdown();
+
+ assertThrows(RuntimeException.class, () -> call.cancel("cancelled", null));
+ Mockito.verify(initial).shutdown();
+ }
+
+ @Test
+ @SuppressWarnings("unchecked")
+ void concurrentStartAndCancel_neverLeaksOrDoubleReleasesEntry() throws Exception {
+ ManagedChannel initial = Mockito.mock(ManagedChannel.class);
+ ManagedChannel rotated = Mockito.mock(ManagedChannel.class);
+ ChannelFactory channelFactory =
+ Mockito.mock(ChannelFactory.class, Mockito.withSettings().withoutAnnotations());
+ Mockito.when(channelFactory.createSingleChannel()).thenReturn(initial, rotated);
+
+ Mockito.when(initial.newCall(Mockito.any(), Mockito.any()))
+ .thenAnswer(
+ invocation ->
+ new ClientCall() {
+ private Listener listener;
+ private boolean cancelled;
+
+ @Override
+ public synchronized void start(
+ Listener responseListener, Metadata headers) {
+ this.listener = responseListener;
+ if (cancelled) {
+ responseListener.onClose(Status.CANCELLED, new Metadata());
+ }
+ }
+
+ @Override
+ public synchronized void cancel(String message, Throwable cause) {
+ cancelled = true;
+ if (listener != null) {
+ listener.onClose(Status.CANCELLED, new Metadata());
+ }
+ }
+
+ @Override
+ public void request(int numMessages) {}
+
+ @Override
+ public void halfClose() {}
+
+ @Override
+ public void sendMessage(Color message) {}
+ });
+
+ pool = ChannelPool.create(ChannelPoolSettings.staticallySized(1), channelFactory, null, null);
+
+ int iterations = 100;
+ ExecutorService executor = Executors.newFixedThreadPool(2);
+ try {
+ for (int i = 0; i < iterations; i++) {
+ ClientCall call = pool.newCall(METHOD_RECOGNIZE, CallOptions.DEFAULT);
+ CyclicBarrier barrier = new CyclicBarrier(2);
+ Future> f1 =
+ executor.submit(
+ () -> {
+ try {
+ barrier.await();
+ call.start(new ClientCall.Listener() {}, new Metadata());
+ } catch (Exception ignored) {
+ }
+ });
+ Future> f2 =
+ executor.submit(
+ () -> {
+ try {
+ barrier.await();
+ call.cancel("cancel", null);
+ } catch (Exception ignored) {
+ }
+ });
+ f1.get(5, TimeUnit.SECONDS);
+ f2.get(5, TimeUnit.SECONDS);
+ }
+ } finally {
+ executor.shutdownNow();
+ }
+
+ // Every start/cancel race must leave the entry with no outstanding RPCs: a leak would leave it
+ // positive and a double release would make it negative.
+ assertThat(pool.entries.get().get(0).outstandingRpcs.get()).isEqualTo(0);
+
+ // Rotate pool: initial channel must shut down cleanly, proving outstandingRpcs == 0 (no leaks
+ // or negative counts)
+ pool.refresh();
+ Mockito.verify(initial).shutdown();
+ }
+
+ @Test
+ void cancel_whenStartedAndSuperCancelThrows_doesNotReleasePrematurelyUntilOnClose()
+ throws Exception {
+ ManagedChannel initial = Mockito.mock(ManagedChannel.class);
+ ManagedChannel replacement = Mockito.mock(ManagedChannel.class);
+ @SuppressWarnings("unchecked")
+ ClientCall delegateCall = Mockito.mock(ClientCall.class);
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> listenerCaptor =
+ ArgumentCaptor.forClass(ClientCall.Listener.class);
+ Mockito.doThrow(new RuntimeException("cancel failure"))
+ .when(delegateCall)
+ .cancel(Mockito.any(), Mockito.any());
+ Mockito.when(initial.newCall(Mockito.eq(METHOD_RECOGNIZE), Mockito.any()))
+ .thenReturn(delegateCall);
+
+ AtomicInteger createCount = new AtomicInteger(0);
+ pool =
+ new ChannelPool(
+ ChannelPoolSettings.staticallySized(1),
+ () -> createCount.getAndIncrement() == 0 ? initial : replacement,
+ FixedExecutorProvider.create(Mockito.mock(ScheduledExecutorService.class)),
+ null);
+
+ ClientCall call = pool.newCall(METHOD_RECOGNIZE, CallOptions.DEFAULT);
+ call.start(new ClientCall.Listener() {}, new Metadata());
+ Mockito.verify(delegateCall).start(listenerCaptor.capture(), Mockito.any());
+
+ assertThrows(RuntimeException.class, () -> call.cancel("abort", null));
+
+ // Rotate pool while call is still active (onClose hasn't fired yet):
+ // initial channel must NOT be shut down yet because call is still active
+ pool.refreshAll();
+ Mockito.verify(initial, Mockito.never()).shutdown();
+
+ // Once onClose fires, entry is released and initial channel shuts down
+ listenerCaptor.getValue().onClose(Status.CANCELLED, new Metadata());
+ Mockito.verify(initial).shutdown();
+ }
+
+ @Test
+ void start_whenCalledTwice_throwsIllegalStateExceptionAndDoesNotReleaseFirstCallEntry()
+ throws Exception {
+ ManagedChannel initial = Mockito.mock(ManagedChannel.class);
+ ManagedChannel replacement = Mockito.mock(ManagedChannel.class);
+ @SuppressWarnings("unchecked")
+ ClientCall delegateCall = Mockito.mock(ClientCall.class);
+ @SuppressWarnings("unchecked")
+ ArgumentCaptor> listenerCaptor =
+ ArgumentCaptor.forClass(ClientCall.Listener.class);
+ Mockito.when(initial.newCall(Mockito.eq(METHOD_RECOGNIZE), Mockito.any()))
+ .thenReturn(delegateCall);
+
+ AtomicInteger createCount = new AtomicInteger(0);
+ pool =
+ new ChannelPool(
+ ChannelPoolSettings.staticallySized(1),
+ () -> createCount.getAndIncrement() == 0 ? initial : replacement,
+ FixedExecutorProvider.create(Mockito.mock(ScheduledExecutorService.class)),
+ null);
+
+ ClientCall call = pool.newCall(METHOD_RECOGNIZE, CallOptions.DEFAULT);
+ call.start(new ClientCall.Listener() {}, new Metadata());
+ Mockito.verify(delegateCall).start(listenerCaptor.capture(), Mockito.any());
+
+ // Duplicate start() must throw IllegalStateException without releasing the entry
+ assertThrows(
+ IllegalStateException.class,
+ () -> call.start(new ClientCall.Listener() {}, new Metadata()));
+
+ pool.refreshAll();
+ Mockito.verify(initial, Mockito.never()).shutdown();
+
+ listenerCaptor.getValue().onClose(Status.OK, new Metadata());
+ Mockito.verify(initial).shutdown();
+ }
}
diff --git a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcCallContextTest.java b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcCallContextTest.java
index e20767fdb8ed..0222d9178078 100644
--- a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcCallContextTest.java
+++ b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcCallContextTest.java
@@ -31,6 +31,7 @@
import static com.google.api.gax.util.TimeConversionTestUtils.testDurationMethod;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.mockito.Mockito.mock;
@@ -52,6 +53,7 @@
import io.grpc.Metadata.Key;
import io.grpc.auth.MoreCallCredentials;
import java.io.IOException;
+import java.time.Duration;
import java.util.ArrayList;
import java.util.Collections;
import java.util.HashMap;
@@ -210,6 +212,20 @@ void testWithLongerTimeout() {
.isEqualTo(java.time.Duration.ofSeconds(5));
}
+ @Test
+ void testWithNullOrZeroTimeoutClearsExistingTimeout() {
+ GrpcCallContext ctxWithTimeout =
+ GrpcCallContext.createDefault().withTimeoutDuration(Duration.ofSeconds(5));
+
+ // Sanity check
+ Truth.assertThat(ctxWithTimeout.getTimeoutDuration()).isEqualTo(Duration.ofSeconds(5));
+
+ Duration nullTimeout = null;
+ Truth.assertThat(ctxWithTimeout.withTimeoutDuration(nullTimeout).getTimeoutDuration()).isNull();
+ Truth.assertThat(ctxWithTimeout.withTimeoutDuration(Duration.ZERO).getTimeoutDuration())
+ .isNull();
+ }
+
@Test
void testMergeWithNullTimeout() {
java.time.Duration timeout = java.time.Duration.ofSeconds(10);
@@ -494,4 +510,64 @@ private static Map> createTestExtraHeaders(String... keyVal
}
return extraHeaders;
}
+
+ @Test
+ public void testEqualsAndHashCode() {
+ ManagedChannel managedChannel1 = Mockito.mock(ManagedChannel.class);
+ ManagedChannel managedChannel2 = Mockito.mock(ManagedChannel.class);
+
+ GrpcTransportChannel transportChannel1 = GrpcTransportChannel.create(managedChannel1);
+ GrpcTransportChannel transportChannel2 = GrpcTransportChannel.create(managedChannel2);
+
+ GrpcCallContext context1 =
+ GrpcCallContext.createDefault().withTransportChannel(transportChannel1);
+ GrpcCallContext context2 =
+ GrpcCallContext.createDefault().withTransportChannel(transportChannel1);
+ GrpcCallContext context3 =
+ GrpcCallContext.createDefault().withTransportChannel(transportChannel2);
+
+ assertEquals(context1, context2);
+ assertEquals(context1.hashCode(), context2.hashCode());
+
+ assertNotEquals(context1, context3);
+ }
+
+ @Test
+ public void testMergeWithCustomChannelClearsTransportChannel() {
+ ManagedChannel defaultChannel = Mockito.mock(ManagedChannel.class);
+ ManagedChannel customChannel = Mockito.mock(ManagedChannel.class);
+ GrpcTransportChannel transportChannel = GrpcTransportChannel.create(defaultChannel);
+
+ GrpcCallContext baseContext =
+ GrpcCallContext.createDefault().withTransportChannel(transportChannel);
+ GrpcCallContext overrideContext = GrpcCallContext.of(customChannel, CallOptions.DEFAULT);
+
+ GrpcCallContext mergedContext = (GrpcCallContext) baseContext.merge(overrideContext);
+ assertEquals(customChannel, mergedContext.getChannel());
+ assertNull(mergedContext.getTransportChannel());
+ }
+
+ @Test
+ public void testWithChannelWithCustomChannelClearsTransportChannel() {
+ ManagedChannel defaultChannel = Mockito.mock(ManagedChannel.class);
+ ManagedChannel customChannel = Mockito.mock(ManagedChannel.class);
+ GrpcTransportChannel transportChannel = GrpcTransportChannel.create(defaultChannel);
+
+ GrpcCallContext baseContext =
+ GrpcCallContext.createDefault().withTransportChannel(transportChannel);
+ GrpcCallContext updatedContext = baseContext.withChannel(customChannel);
+
+ assertEquals(customChannel, updatedContext.getChannel());
+ assertNull(updatedContext.getTransportChannel());
+
+ // Clearing channel via withChannel(null) also clears transportChannel
+ GrpcCallContext nullChannelContext = baseContext.withChannel(null);
+ assertNull(nullChannelContext.getChannel());
+ assertNull(nullChannelContext.getTransportChannel());
+
+ // Merging a cleared context into defaultContext falls back to defaultContext's transportChannel
+ GrpcCallContext mergedWithNullChannel = (GrpcCallContext) baseContext.merge(nullChannelContext);
+ assertEquals(defaultChannel, mergedWithNullChannel.getChannel());
+ assertEquals(transportChannel, mergedWithNullChannel.getTransportChannel());
+ }
}
diff --git a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcClientCallsTest.java b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcClientCallsTest.java
index 2aa9279e249f..6877eb1dbe1e 100644
--- a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcClientCallsTest.java
+++ b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/GrpcClientCallsTest.java
@@ -125,6 +125,7 @@ void testAffinity() throws IOException {
ChannelPool.create(
ChannelPoolSettings.staticallySized(2),
new FakeChannelFactory(Arrays.asList(channel0, channel1)),
+ null,
null);
GrpcCallContext context = defaultCallContext.withChannel(pool);
diff --git a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProviderTest.java b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProviderTest.java
index 0b460d6d8e4f..5ffb6943a4fe 100644
--- a/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProviderTest.java
+++ b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProviderTest.java
@@ -48,9 +48,11 @@
import com.google.api.gax.rpc.internal.EnvironmentProvider;
import com.google.api.gax.rpc.mtls.AbstractMtlsTransportChannelTest;
import com.google.api.gax.rpc.mtls.CertificateBasedAccess;
+import com.google.api.gax.rpc.testing.FakeMtlsProvider;
import com.google.auth.ApiKeyCredentials;
import com.google.auth.Credentials;
import com.google.auth.http.AuthHttpConstants;
+import com.google.auth.mtls.CertificateSourceUnavailableException;
import com.google.auth.mtls.MtlsProvider;
import com.google.auth.oauth2.CloudShellCredentials;
import com.google.auth.oauth2.ComputeEngineCredentials;
@@ -663,7 +665,9 @@ private void createAndCloseTransportChannel(InstantiatingGrpcChannelProvider pro
createAndCloseTransportChannel(provider);
assertThat(logHandler.getAllMessages())
.contains(
- "DirectPath is misconfigured. The DirectPath XDS option was set, but the attemptDirectPath option was not. Please set both the attemptDirectPath and attemptDirectPathXds options.");
+ "DirectPath is misconfigured. The DirectPath XDS option was set, but the"
+ + " attemptDirectPath option was not. Please set both the attemptDirectPath and"
+ + " attemptDirectPathXds options.");
InstantiatingGrpcChannelProvider.LOG.removeHandler(logHandler);
}
@@ -681,8 +685,10 @@ void testLogDirectPathMisconfig_AttemptDirectPathNotSetAndAttemptDirectPathXdsSe
createAndCloseTransportChannel(provider);
assertThat(logHandler.getAllMessages())
.contains(
- "Env var GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS was found and set to TRUE, but DirectPath was not enabled for this client. If this is intended for "
- + "this client, please note that this is a misconfiguration and set the attemptDirectPath option as well.");
+ "Env var GOOGLE_CLOUD_ENABLE_DIRECT_PATH_XDS was found and set to TRUE, but DirectPath"
+ + " was not enabled for this client. If this is intended for this client, please"
+ + " note that this is a misconfiguration and set the attemptDirectPath option as"
+ + " well.");
InstantiatingGrpcChannelProvider.LOG.removeHandler(logHandler);
}
@@ -877,12 +883,14 @@ public void canUseDirectPath_directPathEnvVarDisabled() throws IOException {
@Test
public void canUseDirectPath_directPathEnvVarNotSet_attemptDirectPathIsTrue() {
System.setProperty("os.name", "Linux");
+ EnvironmentProvider envProvider = name -> null;
InstantiatingGrpcChannelProvider.Builder builder =
InstantiatingGrpcChannelProvider.newBuilder()
.setCertificateBasedAccess(certificateBasedAccess)
.setAttemptDirectPath(true)
.setCredentials(computeEngineCredentials)
- .setEndpoint(DEFAULT_ENDPOINT);
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setEnvProvider(envProvider);
InstantiatingGrpcChannelProvider provider =
new InstantiatingGrpcChannelProvider(builder, GCE_PRODUCTION_NAME_AFTER_2016);
Truth.assertThat(provider.canUseDirectPath()).isTrue();
@@ -891,12 +899,14 @@ public void canUseDirectPath_directPathEnvVarNotSet_attemptDirectPathIsTrue() {
@Test
public void canUseDirectPath_directPathEnvVarNotSet_attemptDirectPathIsFalse() {
System.setProperty("os.name", "Linux");
+ EnvironmentProvider envProvider = name -> null;
InstantiatingGrpcChannelProvider.Builder builder =
InstantiatingGrpcChannelProvider.newBuilder()
.setCertificateBasedAccess(certificateBasedAccess)
.setAttemptDirectPath(false)
.setCredentials(computeEngineCredentials)
- .setEndpoint(DEFAULT_ENDPOINT);
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setEnvProvider(envProvider);
InstantiatingGrpcChannelProvider provider =
new InstantiatingGrpcChannelProvider(builder, GCE_PRODUCTION_NAME_AFTER_2016);
Truth.assertThat(provider.canUseDirectPath()).isFalse();
@@ -1201,7 +1211,8 @@ void createS2ASecuredChannelCredentials_bothS2AAddressesNull_returnsNull() {
assertThat(provider.createS2ASecuredChannelCredentials()).isNotNull();
assertThat(logHandler.getAllMessages())
.contains(
- "Cannot establish an mTLS connection to S2A because autoconfig endpoint did not return a mtls address to reach S2A.");
+ "Cannot establish an mTLS connection to S2A because autoconfig endpoint did not return"
+ + " a mtls address to reach S2A.");
InstantiatingGrpcChannelProvider.LOG.removeHandler(logHandler);
}
@@ -1247,7 +1258,8 @@ void createS2ASecuredChannelCredentials_returnsPlaintextToS2AS2AChannelCredentia
assertThat(provider.createS2ASecuredChannelCredentials()).isNotNull();
assertThat(logHandler.getAllMessages())
.contains(
- "Cannot establish an mTLS connection to S2A because MTLS to MDS credentials do not exist on filesystem, falling back to plaintext connection to S2A");
+ "Cannot establish an mTLS connection to S2A because MTLS to MDS credentials do not"
+ + " exist on filesystem, falling back to plaintext connection to S2A");
InstantiatingGrpcChannelProvider.LOG.removeHandler(logHandler);
}
@@ -1342,6 +1354,140 @@ void testSettingBackgroundExecutor() {
assertThat(provider.getBackgroundExecutor()).isEqualTo(mockExecutor);
}
+ @Test
+ void createChannel_whenDirectPathEnabled_ignoresWorkloadCertPath() throws Exception {
+ System.setProperty("os.name", "Linux");
+ EnvironmentProvider envProvider =
+ mock(EnvironmentProvider.class, Mockito.withSettings().withoutAnnotations());
+ Mockito.when(
+ envProvider.getenv(
+ InstantiatingGrpcChannelProvider.DIRECT_PATH_ENV_DISABLE_DIRECT_PATH))
+ .thenReturn("false");
+ CertificateBasedAccess mtlsCertificateBasedAccess =
+ mock(CertificateBasedAccess.class, Mockito.withSettings().withoutAnnotations());
+ Mockito.when(mtlsCertificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ Mockito.when(mtlsCertificateBasedAccess.getWorkloadCertPath())
+ .thenReturn("/path/to/workload/cert.pem");
+ MtlsProvider mtlsProvider =
+ new FakeMtlsProvider(FakeMtlsProvider.createTestMtlsKeyStore(), "", false);
+
+ InstantiatingGrpcChannelProvider.Builder builder =
+ InstantiatingGrpcChannelProvider.newBuilder()
+ .setCertificateBasedAccess(mtlsCertificateBasedAccess)
+ .setMtlsProvider(mtlsProvider)
+ .setAttemptDirectPath(true)
+ .setCredentials(computeEngineCredentials)
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setEnvProvider(envProvider)
+ .setHeaderProvider(
+ mock(HeaderProvider.class, Mockito.withSettings().withoutAnnotations()));
+ InstantiatingGrpcChannelProvider provider =
+ new InstantiatingGrpcChannelProvider(builder, GCE_PRODUCTION_NAME_AFTER_2016);
+ Truth.assertThat(provider.canUseDirectPath()).isTrue();
+
+ TransportChannel transportChannel = provider.getTransportChannel();
+ try {
+ ChannelPool pool = (ChannelPool) ((GrpcTransportChannel) transportChannel).getChannel();
+ assertThat(pool.getWorkloadCertPath()).isNull();
+ } finally {
+ transportChannel.shutdownNow();
+ }
+ }
+
+ @Test
+ void createChannel_whenMtlsActive_passesWorkloadCertPathToChannelPool() throws Exception {
+ CertificateBasedAccess mtlsCertificateBasedAccess =
+ mock(CertificateBasedAccess.class, Mockito.withSettings().withoutAnnotations());
+ Mockito.when(mtlsCertificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ Mockito.when(mtlsCertificateBasedAccess.getWorkloadCertPath())
+ .thenReturn("/path/to/workload/cert.pem");
+ MtlsProvider mtlsProvider =
+ new FakeMtlsProvider(FakeMtlsProvider.createTestMtlsKeyStore(), "", false);
+
+ InstantiatingGrpcChannelProvider provider =
+ InstantiatingGrpcChannelProvider.newBuilder()
+ .setCertificateBasedAccess(mtlsCertificateBasedAccess)
+ .setMtlsProvider(mtlsProvider)
+ .setAttemptDirectPath(false)
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setHeaderProvider(
+ mock(HeaderProvider.class, Mockito.withSettings().withoutAnnotations()))
+ .build();
+
+ TransportChannel transportChannel = provider.getTransportChannel();
+ try {
+ ChannelPool pool = (ChannelPool) ((GrpcTransportChannel) transportChannel).getChannel();
+ assertThat(pool.getWorkloadCertPath()).isEqualTo("/path/to/workload/cert.pem");
+ } finally {
+ transportChannel.shutdownNow();
+ }
+ }
+
+ @Test
+ void createChannelBuilder_whenMtlsActiveAndKeyStoreNull_throwsIOException() {
+ CertificateBasedAccess mtlsCertificateBasedAccess =
+ mock(CertificateBasedAccess.class, Mockito.withSettings().withoutAnnotations());
+ Mockito.when(mtlsCertificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ MtlsProvider mtlsProviderWithNullKeyStore = new FakeMtlsProvider(null, "", false);
+
+ InstantiatingGrpcChannelProvider provider =
+ InstantiatingGrpcChannelProvider.newBuilder()
+ .setCertificateBasedAccess(mtlsCertificateBasedAccess)
+ .setMtlsProvider(mtlsProviderWithNullKeyStore)
+ .setAttemptDirectPath(false)
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setHeaderProvider(
+ mock(HeaderProvider.class, Mockito.withSettings().withoutAnnotations()))
+ .build();
+
+ IOException thrown = assertThrows(IOException.class, provider::createChannelBuilder);
+ assertThat(thrown).hasMessageThat().contains("Failed to initialize mTLS channel credentials");
+ }
+
+ @Test
+ void createChannelBuilder_whenDefaultMtlsProviderThrowsIOException_propagatesIOException()
+ throws Exception {
+ CertificateBasedAccess mtlsCertificateBasedAccess =
+ mock(CertificateBasedAccess.class, Mockito.withSettings().withoutAnnotations());
+ Mockito.when(mtlsCertificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ IOException expected = new IOException("Corrupt PEM file");
+ Mockito.when(mtlsCertificateBasedAccess.createDefaultMtlsProvider()).thenThrow(expected);
+
+ InstantiatingGrpcChannelProvider provider =
+ InstantiatingGrpcChannelProvider.newBuilder()
+ .setCertificateBasedAccess(mtlsCertificateBasedAccess)
+ .setAttemptDirectPath(false)
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setHeaderProvider(
+ mock(HeaderProvider.class, Mockito.withSettings().withoutAnnotations()))
+ .build();
+
+ IOException thrown = assertThrows(IOException.class, provider::createChannelBuilder);
+ assertThat(thrown).isSameInstanceAs(expected);
+ }
+
+ @Test
+ void
+ createChannelBuilder_whenDefaultMtlsProviderThrowsCertificateSourceUnavailable_fallsBackWithoutMtls()
+ throws Exception {
+ CertificateBasedAccess mtlsCertificateBasedAccess =
+ mock(CertificateBasedAccess.class, Mockito.withSettings().withoutAnnotations());
+ Mockito.when(mtlsCertificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ Mockito.when(mtlsCertificateBasedAccess.createDefaultMtlsProvider())
+ .thenThrow(new CertificateSourceUnavailableException("No certificates on disk"));
+
+ InstantiatingGrpcChannelProvider provider =
+ InstantiatingGrpcChannelProvider.newBuilder()
+ .setCertificateBasedAccess(mtlsCertificateBasedAccess)
+ .setAttemptDirectPath(false)
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setHeaderProvider(
+ mock(HeaderProvider.class, Mockito.withSettings().withoutAnnotations()))
+ .build();
+
+ assertThat(provider.createChannelBuilder()).isNotNull();
+ }
+
private static class FakeLogHandler extends Handler {
List records = new ArrayList<>();
diff --git a/sdk-platform-java/gax-java/gax-httpjson/BUILD.bazel b/sdk-platform-java/gax-java/gax-httpjson/BUILD.bazel
index c4f78edb79a2..e97aedebabfd 100644
--- a/sdk-platform-java/gax-java/gax-httpjson/BUILD.bazel
+++ b/sdk-platform-java/gax-java/gax-httpjson/BUILD.bazel
@@ -53,6 +53,10 @@ java_library(
srcs = glob(["src/test/java/**/*.java"]),
javacopts = _JAVA_COPTS,
plugins = ["//:auto_value_plugin"],
+ resources = glob([
+ "src/test/resources/com/google/api/gax/httpjson/tls13TestCa.pem",
+ "src/test/resources/com/google/api/gax/httpjson/tls13TestServerCertAndKey.pem",
+ ]),
visibility = ["//visibility:public"],
deps = [":gax_httpjson"] + _COMPILE_DEPS + _TEST_COMPILE_DEPS,
)
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java
index daefe8736eda..c5cb09338872 100644
--- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonCallContext.java
@@ -81,6 +81,7 @@ public final class HttpJsonCallContext implements ApiCallContext {
private final @Nullable RetrySettings retrySettings;
private final @Nullable ImmutableSet retryableCodes;
private final EndpointContext endpointContext;
+ @Nullable private final TransportChannel transportChannel;
/** Returns an empty instance. */
public static HttpJsonCallContext createDefault() {
@@ -95,6 +96,7 @@ public static HttpJsonCallContext createDefault() {
null,
null,
null,
+ null,
null);
}
@@ -110,6 +112,7 @@ public static HttpJsonCallContext of(HttpJsonChannel channel, HttpJsonCallOption
null,
null,
null,
+ null,
null);
}
@@ -124,7 +127,8 @@ private HttpJsonCallContext(
@Nullable ApiTracer tracer,
@Nullable RetrySettings defaultRetrySettings,
@Nullable Set defaultRetryableCodes,
- @Nullable EndpointContext endpointContext) {
+ @Nullable EndpointContext endpointContext,
+ @Nullable TransportChannel transportChannel) {
this.channel = channel;
this.callOptions = callOptions;
this.timeout = timeout;
@@ -140,6 +144,7 @@ private HttpJsonCallContext(
// a valid EndpointContext with user configurations after the client has been initialized.
this.endpointContext =
endpointContext == null ? EndpointContext.getDefaultInstance() : endpointContext;
+ this.transportChannel = transportChannel;
}
/**
@@ -219,6 +224,12 @@ public HttpJsonCallContext merge(ApiCallContext inputCallContext) {
newRetryableCodes = this.retryableCodes;
}
+ TransportChannel newTransportChannel = httpJsonCallContext.transportChannel;
+ if (newTransportChannel == null
+ && (httpJsonCallContext.channel == null || httpJsonCallContext.channel.equals(channel))) {
+ newTransportChannel = this.transportChannel;
+ }
+
// The EndpointContext is not updated as there should be no reason for a user
// to update this.
return new HttpJsonCallContext(
@@ -232,7 +243,8 @@ public HttpJsonCallContext merge(ApiCallContext inputCallContext) {
newTracer,
newRetrySettings,
newRetryableCodes,
- endpointContext);
+ endpointContext,
+ newTransportChannel);
}
@Override
@@ -250,7 +262,24 @@ public HttpJsonCallContext withTransportChannel(TransportChannel inputChannel) {
"Expected HttpJsonTransportChannel, got " + inputChannel.getClass().getName());
}
HttpJsonTransportChannel transportChannel = (HttpJsonTransportChannel) inputChannel;
- return withChannel(transportChannel.getChannel());
+ return new HttpJsonCallContext(
+ transportChannel.getChannel(),
+ this.callOptions,
+ this.timeout,
+ this.streamWaitTimeout,
+ this.streamIdleTimeout,
+ this.extraHeaders,
+ this.options,
+ this.tracer,
+ this.retrySettings,
+ this.retryableCodes,
+ this.endpointContext,
+ transportChannel);
+ }
+
+ @Override
+ public TransportChannel getTransportChannel() {
+ return transportChannel;
}
/** This method is obsolete. Use {@link #withTimeoutDuration(java.time.Duration)} instead. */
@@ -274,7 +303,8 @@ public HttpJsonCallContext withEndpointContext(EndpointContext endpointContext)
this.tracer,
this.retrySettings,
this.retryableCodes,
- endpointContext);
+ endpointContext,
+ this.transportChannel);
}
@Override
@@ -300,7 +330,8 @@ public HttpJsonCallContext withTimeoutDuration(java.time.Duration timeout) {
this.tracer,
this.retrySettings,
this.retryableCodes,
- this.endpointContext);
+ this.endpointContext,
+ this.transportChannel);
}
/** This method is obsolete. Use {@link #getTimeoutDuration()} instead. */
@@ -345,7 +376,8 @@ public HttpJsonCallContext withStreamWaitTimeoutDuration(
this.tracer,
this.retrySettings,
this.retryableCodes,
- this.endpointContext);
+ this.endpointContext,
+ this.transportChannel);
}
/** This method is obsolete. Use {@link #getStreamWaitTimeoutDuration()} instead. */
@@ -395,7 +427,8 @@ public HttpJsonCallContext withStreamIdleTimeoutDuration(
this.tracer,
this.retrySettings,
this.retryableCodes,
- this.endpointContext);
+ this.endpointContext,
+ this.transportChannel);
}
/** This method is obsolete. Use {@link #getStreamIdleTimeoutDuration()} instead. */
@@ -432,7 +465,8 @@ public ApiCallContext withExtraHeaders(Map> extraHeaders) {
this.tracer,
this.retrySettings,
this.retryableCodes,
- this.endpointContext);
+ this.endpointContext,
+ this.transportChannel);
}
@BetaApi("The surface for extra headers is not stable yet and may change in the future.")
@@ -456,7 +490,8 @@ public ApiCallContext withOption(Key key, T value) {
this.tracer,
this.retrySettings,
this.retryableCodes,
- this.endpointContext);
+ this.endpointContext,
+ this.transportChannel);
}
/** {@inheritDoc} */
@@ -526,7 +561,8 @@ public HttpJsonCallContext withRetrySettings(RetrySettings retrySettings) {
this.tracer,
retrySettings,
this.retryableCodes,
- this.endpointContext);
+ this.endpointContext,
+ this.transportChannel);
}
@Override
@@ -547,7 +583,8 @@ public HttpJsonCallContext withRetryableCodes(Set retryableCode
this.tracer,
this.retrySettings,
retryableCodes,
- this.endpointContext);
+ this.endpointContext,
+ this.transportChannel);
}
public HttpJsonCallContext withChannel(@Nullable HttpJsonChannel newChannel) {
@@ -562,7 +599,8 @@ public HttpJsonCallContext withChannel(@Nullable HttpJsonChannel newChannel) {
this.tracer,
this.retrySettings,
this.retryableCodes,
- this.endpointContext);
+ this.endpointContext,
+ (newChannel != null && newChannel.equals(this.channel)) ? this.transportChannel : null);
}
public HttpJsonCallContext withCallOptions(HttpJsonCallOptions newCallOptions) {
@@ -577,7 +615,8 @@ public HttpJsonCallContext withCallOptions(HttpJsonCallOptions newCallOptions) {
this.tracer,
this.retrySettings,
this.retryableCodes,
- this.endpointContext);
+ this.endpointContext,
+ this.transportChannel);
}
@Deprecated
@@ -612,7 +651,8 @@ public HttpJsonCallContext withTracer(ApiTracer newTracer) {
newTracer,
this.retrySettings,
this.retryableCodes,
- this.endpointContext);
+ this.endpointContext,
+ this.transportChannel);
}
@Override
@@ -632,7 +672,8 @@ public boolean equals(@Nullable Object o) {
&& Objects.equals(this.tracer, that.tracer)
&& Objects.equals(this.retrySettings, that.retrySettings)
&& Objects.equals(this.retryableCodes, that.retryableCodes)
- && Objects.equals(this.endpointContext, that.endpointContext);
+ && Objects.equals(this.endpointContext, that.endpointContext)
+ && Objects.equals(this.transportChannel, that.transportChannel);
}
@Override
@@ -646,6 +687,7 @@ public int hashCode() {
tracer,
retrySettings,
retryableCodes,
- endpointContext);
+ endpointContext,
+ transportChannel);
}
}
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonTransportChannel.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonTransportChannel.java
index 813622b6a97e..fde0673f650d 100644
--- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonTransportChannel.java
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/HttpJsonTransportChannel.java
@@ -64,6 +64,21 @@ public HttpJsonChannel getChannel() {
return getManagedChannel();
}
+ @Override
+ public void refresh() {
+ getManagedChannel().refresh();
+ }
+
+ @Override
+ public boolean shouldRefresh() {
+ return getManagedChannel().shouldRefresh();
+ }
+
+ @Override
+ public long getGeneration() {
+ return getManagedChannel().getGeneration();
+ }
+
@Override
public void shutdown() {
getManagedChannel().shutdown();
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java
index f0a6aa380d37..0766a8032df1 100644
--- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProvider.java
@@ -40,7 +40,6 @@
import com.google.api.gax.rpc.mtls.CertificateBasedAccess;
import com.google.auth.Credentials;
import com.google.auth.mtls.CertificateSourceUnavailableException;
-import com.google.auth.mtls.DefaultMtlsProviderFactory;
import com.google.auth.mtls.MtlsProvider;
import com.google.common.annotations.VisibleForTesting;
import java.io.IOException;
@@ -50,9 +49,11 @@
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.ScheduledExecutorService;
-import java.util.logging.Level;
+import java.util.function.Supplier;
import java.util.logging.Logger;
+import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
+import javax.net.ssl.TrustManagerFactory;
import org.jspecify.annotations.NullMarked;
import org.jspecify.annotations.Nullable;
@@ -216,49 +217,116 @@ private NetHttpTransport.Builder configureMtls(NetHttpTransport.Builder builder)
// Fall back to standard JDK JSSE if Conscrypt provider is unavailable
return builder;
}
- // Explicitly initialize SSLContext with the Conscrypt provider so that the client certificate
- // key managers
- // and trust manager factory (TMF) are bound to Conscrypt's TLS implementation (supporting PQC
- // key exchange).
+ // NetHttpTransport.Builder.trustCertificates() in google-http-client initializes with the
+ // default SunX509 KeyManagerFactory (via SslUtils.getDefaultKeyManagerFactory()), which is not
+ // supported by Conscrypt. Explicitly initialize SSLContext with the Conscrypt provider so that
+ // the client certificate key managers and trust manager factory (TMF) are bound to Conscrypt's
+ // TLS implementation (supporting PQC key exchange).
SSLContext sslContext = SSLContext.getInstance("TLS", conscryptProvider);
+ // The TrustManagerFactory must come from the same provider as the SSLContext. On TLS 1.3,
+ // Conscrypt passes authType "GENERIC" to the trust manager, which the JDK (SunJSSE) PKIX
+ // trust manager rejects for CA-issued server certificates that carry a KeyUsage extension
+ // (e.g. Google front ends), failing the handshake with "Unknown authType: GENERIC".
+ // Conscrypt's trust manager loads the same default trust store as the JDK.
+ // The KeyManagerFactory must also come from Conscrypt: since JDK 26 (JDK-8359956) the JDK's
+ // SunX509 key manager applies algorithm constraints to the client certificate, and when called
+ // from a Conscrypt handshake it rejects valid certificates (e.g. SHA256withRSA), so no client
+ // certificate is sent.
SslUtils.initSslContext(
sslContext,
null,
- SslUtils.getPkixTrustManagerFactory(),
+ TrustManagerFactory.getInstance("PKIX", conscryptProvider),
mtlsKeyStore,
"",
- SslUtils.getDefaultKeyManagerFactory());
+ KeyManagerFactory.getInstance("PKIX", conscryptProvider));
builder.setSslSocketFactory(sslContext.getSocketFactory());
return builder;
}
- private HttpJsonTransportChannel createChannel() throws IOException, GeneralSecurityException {
- HttpTransport httpTransportToUse = httpTransport;
- if (httpTransportToUse == null) {
- httpTransportToUse = createHttpTransport();
+ private HttpTransport createChannelHttpTransport() throws IOException, GeneralSecurityException {
+ HttpTransport transport = createHttpTransport();
+ if (mtlsProvider != null
+ && certificateBasedAccess.useMtlsClientCertificate()
+ && !((NetHttpTransport) transport).isMtls()) {
+ // mTLS is enabled but the provider returned no client certificate. Fail instead of silently
+ // using a transport without the certificate, matching InstantiatingGrpcChannelProvider.
+ // During certificate rotation, this makes RefreshingHttpJsonChannel keep the current
+ // authenticated transport instead of swapping in one without a client certificate.
+ throw new IOException("Failed to initialize mTLS HttpTransport");
}
+ return transport;
+ }
+
+ private ManagedHttpJsonChannel createSingleManagedChannel()
+ throws IOException, GeneralSecurityException {
+ return buildManagedChannel(
+ httpTransport != null ? httpTransport : createChannelHttpTransport());
+ }
- // Pass the executor to the ManagedChannel. If no executor was provided (or null),
- // the channel will use a default executor for the calls.
- ManagedHttpJsonChannel channel =
- ManagedHttpJsonChannel.newBuilder()
- .setEndpoint(endpoint)
- .setExecutor(executor)
- .setHttpTransport(httpTransportToUse)
- .build();
-
- HttpJsonClientInterceptor headerInterceptor =
- new HttpJsonHeaderInterceptor(headerProvider.getHeaders());
-
- channel = new ManagedHttpJsonInterceptorChannel(channel, new HttpJsonLoggingInterceptor());
- channel = new ManagedHttpJsonInterceptorChannel(channel, headerInterceptor);
- if (interceptorProvider != null && interceptorProvider.getInterceptors() != null) {
- for (HttpJsonClientInterceptor interceptor : interceptorProvider.getInterceptors()) {
- channel = new ManagedHttpJsonInterceptorChannel(channel, interceptor);
+ private ManagedHttpJsonChannel buildManagedChannel(HttpTransport httpTransportToUse) {
+ return ManagedHttpJsonChannel.newBuilder()
+ .setEndpoint(endpoint)
+ .setExecutor(executor)
+ .setHttpTransport(httpTransportToUse)
+ .setManageHttpTransport(httpTransport == null)
+ .build();
+ }
+
+ private HttpJsonTransportChannel createChannel() throws IOException, GeneralSecurityException {
+ boolean isMtlsActive =
+ httpTransport == null
+ && mtlsProvider != null
+ && certificateBasedAccess.useMtlsClientCertificate();
+ String workloadCertPath = isMtlsActive ? certificateBasedAccess.getWorkloadCertPath() : null;
+
+ ManagedHttpJsonChannel baseChannel;
+ if (workloadCertPath != null) {
+ Supplier transportFactory =
+ () -> {
+ try {
+ return createChannelHttpTransport();
+ } catch (IOException | GeneralSecurityException e) {
+ throw new RuntimeException("Failed to create mTLS HttpTransport", e);
+ }
+ };
+ // RefreshingHttpJsonChannel records the baseline certificate fingerprint before creating the
+ // initial transport, so a rotation during startup is detected on the next auth failure.
+ try {
+ baseChannel =
+ new RefreshingHttpJsonChannel(
+ transportFactory, this::buildManagedChannel, workloadCertPath);
+ } catch (RuntimeException e) {
+ if (e.getCause() instanceof IOException) {
+ throw (IOException) e.getCause();
+ }
+ if (e.getCause() instanceof GeneralSecurityException) {
+ throw (GeneralSecurityException) e.getCause();
+ }
+ throw e;
}
+ } else {
+ baseChannel = createSingleManagedChannel();
}
- return HttpJsonTransportChannel.newBuilder().setManagedChannel(channel).build();
+ try {
+ ManagedHttpJsonChannel channel = baseChannel;
+
+ HttpJsonClientInterceptor headerInterceptor =
+ new HttpJsonHeaderInterceptor(headerProvider.getHeaders());
+
+ channel = new ManagedHttpJsonInterceptorChannel(channel, new HttpJsonLoggingInterceptor());
+ channel = new ManagedHttpJsonInterceptorChannel(channel, headerInterceptor);
+ if (interceptorProvider != null && interceptorProvider.getInterceptors() != null) {
+ for (HttpJsonClientInterceptor interceptor : interceptorProvider.getInterceptors()) {
+ channel = new ManagedHttpJsonInterceptorChannel(channel, interceptor);
+ }
+ }
+
+ return HttpJsonTransportChannel.newBuilder().setManagedChannel(channel).build();
+ } catch (Throwable t) {
+ baseChannel.shutdownNow();
+ throw t;
+ }
}
/** The endpoint to be used for the channel. */
@@ -383,18 +451,23 @@ public InstantiatingHttpJsonChannelProvider build() {
if (mtlsProvider == null) {
// Attempt to create default MtlsProvider from environment.
try {
- mtlsProvider = DefaultMtlsProviderFactory.create();
+ mtlsProvider = certificateBasedAccess.createDefaultMtlsProvider();
} catch (CertificateSourceUnavailableException e) {
// This is okay. Leave mtlsProvider as null so that we will not auto-upgrade
// to mTLS endpoints. See https://google.aip.dev/auth/4114.
} catch (IOException e) {
- LOG.log(
- Level.WARNING,
- "DefaultMtlsProviderFactory encountered unexpected IOException: " + e.getMessage());
- LOG.log(
- Level.WARNING,
- "mTLS configuration was detected on the device, but mTLS failed to initialize."
- + " Falling back to non-mTLS channel.");
+ mtlsProvider =
+ new MtlsProvider() {
+ @Override
+ public boolean isAvailable() throws IOException {
+ throw e;
+ }
+
+ @Override
+ public KeyStore getKeyStore() throws IOException {
+ throw e;
+ }
+ };
}
}
}
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java
index 87767bee5c7f..bf79e17d7d89 100644
--- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonChannel.java
@@ -51,12 +51,35 @@ public class ManagedHttpJsonChannel implements HttpJsonChannel, BackgroundResour
private final Executor executor;
private final boolean usingDefaultExecutor;
private final String endpoint;
- private final HttpTransport httpTransport;
+ private volatile HttpTransport httpTransport;
+ private final boolean usingDefaultTransport;
private final ScheduledExecutorService deadlineScheduledExecutorService;
private boolean isTransportShutdown;
protected ManagedHttpJsonChannel() {
- this(null, true, null, null);
+ this(null, true, null, null, true);
+ }
+
+ /**
+ * Constructor for subclasses that delegate all calls to a wrapped channel. The argument is
+ * unused; it only distinguishes this overload from {@link #ManagedHttpJsonChannel()}, which would
+ * otherwise allocate a transport and executor that the wrapper never uses or shuts down.
+ */
+ ManagedHttpJsonChannel(boolean isDelegatingWrapper) {
+ this.executor = null;
+ this.usingDefaultExecutor = false;
+ this.endpoint = null;
+ this.httpTransport = null;
+ this.usingDefaultTransport = false;
+ this.deadlineScheduledExecutorService = null;
+ }
+
+ /**
+ * Returns a monotonic generation counter tracking the number of successful refreshes or channel
+ * rotations performed by this channel. Always {@code 0} for channels that do not refresh.
+ */
+ public long getGeneration() {
+ return 0;
}
String getEndpoint() {
@@ -68,11 +91,20 @@ HttpTransport getHttpTransport() {
return httpTransport;
}
+ /**
+ * Replaces the transport used by calls created after this method returns. Calls that were already
+ * created keep using the transport they were created with.
+ */
+ void setHttpTransport(HttpTransport httpTransport) {
+ this.httpTransport = httpTransport;
+ }
+
private ManagedHttpJsonChannel(
@Nullable Executor executor,
boolean usingDefaultExecutor,
@Nullable String endpoint,
- @Nullable HttpTransport httpTransport) {
+ @Nullable HttpTransport httpTransport,
+ boolean usingDefaultTransport) {
this.executor = executor;
this.usingDefaultExecutor = usingDefaultExecutor;
this.endpoint = endpoint;
@@ -82,6 +114,7 @@ private ManagedHttpJsonChannel(
new NetHttpTransport.Builder())
.build()
: httpTransport;
+ this.usingDefaultTransport = usingDefaultTransport || httpTransport == null;
this.deadlineScheduledExecutorService = Executors.newSingleThreadScheduledExecutor();
}
@@ -98,6 +131,22 @@ public HttpJsonClientCall newCall(
deadlineScheduledExecutorService);
}
+ /**
+ * Refreshes or recreates the underlying transport of this channel if a certificate rotation has
+ * been detected. By default, this is a no-op.
+ */
+ public void refresh() {
+ // No-op: this channel has no certificate to rotate. Overridden by RefreshingHttpJsonChannel.
+ }
+
+ /**
+ * Returns true if a certificate rotation has been detected on disk and this channel should be
+ * refreshed, or false otherwise. Always {@code false} for channels that do not refresh.
+ */
+ public boolean shouldRefresh() {
+ return false;
+ }
+
@VisibleForTesting
Executor getExecutor() {
return executor;
@@ -116,7 +165,9 @@ public synchronized void shutdown() {
((ExecutorService) executor).shutdown();
}
deadlineScheduledExecutorService.shutdown();
- httpTransport.shutdown();
+ if (usingDefaultTransport) {
+ httpTransport.shutdown();
+ }
isTransportShutdown = true;
} catch (IOException e) {
// TODO: Log this scenario once we implemented the Cloud SDK logging.
@@ -158,7 +209,9 @@ public void shutdownNow() {
((ExecutorService) executor).shutdownNow();
}
deadlineScheduledExecutorService.shutdownNow();
- httpTransport.shutdown();
+ if (usingDefaultTransport) {
+ httpTransport.shutdown();
+ }
isTransportShutdown = true;
} catch (IOException e) {
// TODO: Log this scenario once we implemented the Cloud SDK logging.
@@ -205,9 +258,11 @@ public static class Builder {
private String endpoint;
private HttpTransport httpTransport;
private boolean usingDefaultExecutor;
+ private boolean usingDefaultTransport;
private Builder() {
this.usingDefaultExecutor = false;
+ this.usingDefaultTransport = false;
}
public Builder setExecutor(Executor executor) {
@@ -225,6 +280,11 @@ public Builder setHttpTransport(HttpTransport httpTransport) {
return this;
}
+ Builder setManageHttpTransport(boolean manageHttpTransport) {
+ this.usingDefaultTransport = manageHttpTransport;
+ return this;
+ }
+
public ManagedHttpJsonChannel build() {
Preconditions.checkNotNull(endpoint);
@@ -237,14 +297,8 @@ public ManagedHttpJsonChannel build() {
usingDefaultExecutor = true;
}
- if (httpTransport == null) {
- httpTransport =
- HttpJsonConscryptUtils.configureConscryptSecurityProvider(
- new NetHttpTransport.Builder())
- .build();
- }
-
- return new ManagedHttpJsonChannel(executor, usingDefaultExecutor, endpoint, httpTransport);
+ return new ManagedHttpJsonChannel(
+ executor, usingDefaultExecutor, endpoint, httpTransport, usingDefaultTransport);
}
}
}
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonInterceptorChannel.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonInterceptorChannel.java
index eaaa8c3a7c56..9adbfdd5c329 100644
--- a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonInterceptorChannel.java
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/ManagedHttpJsonInterceptorChannel.java
@@ -29,7 +29,9 @@
*/
package com.google.api.gax.httpjson;
+import com.google.api.client.http.HttpTransport;
import com.google.common.annotations.VisibleForTesting;
+import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;
import org.jspecify.annotations.NullMarked;
@@ -41,22 +43,57 @@ class ManagedHttpJsonInterceptorChannel extends ManagedHttpJsonChannel {
ManagedHttpJsonInterceptorChannel(
ManagedHttpJsonChannel channel, HttpJsonClientInterceptor interceptor) {
- super();
+ super(true);
this.channel = channel;
this.interceptor = interceptor;
}
+ /** {@inheritDoc} */
+ @Override
+ public long getGeneration() {
+ return channel.getGeneration();
+ }
+
@VisibleForTesting
ManagedHttpJsonChannel getChannel() {
return channel;
}
+ @Override
+ String getEndpoint() {
+ return channel.getEndpoint();
+ }
+
+ @Override
+ @VisibleForTesting
+ HttpTransport getHttpTransport() {
+ return channel.getHttpTransport();
+ }
+
+ @Override
+ @VisibleForTesting
+ Executor getExecutor() {
+ return channel.getExecutor();
+ }
+
@Override
public HttpJsonClientCall newCall(
ApiMethodDescriptor methodDescriptor, HttpJsonCallOptions callOptions) {
return interceptor.interceptCall(methodDescriptor, callOptions, channel);
}
+ /** {@inheritDoc} */
+ @Override
+ public void refresh() {
+ channel.refresh();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean shouldRefresh() {
+ return channel.shouldRefresh();
+ }
+
@Override
public synchronized void shutdown() {
channel.shutdown();
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/RefreshingHttpJsonChannel.java b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/RefreshingHttpJsonChannel.java
new file mode 100644
index 000000000000..6d20a69139f8
--- /dev/null
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/main/java/com/google/api/gax/httpjson/RefreshingHttpJsonChannel.java
@@ -0,0 +1,213 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ * * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.google.api.gax.httpjson;
+
+import com.google.api.client.http.HttpTransport;
+import com.google.api.core.InternalApi;
+import com.google.api.gax.rpc.mtls.CertificateRotationTracker;
+import com.google.api.gax.rpc.mtls.WorkloadCertificateUtils;
+import com.google.common.annotations.VisibleForTesting;
+import java.util.concurrent.Executor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicLong;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import java.util.logging.Level;
+import java.util.logging.Logger;
+
+/**
+ * An implementation of {@link ManagedHttpJsonChannel} that supports dynamic mTLS certificate
+ * rotation. When the workload certificate on disk changes, {@link #refresh()} replaces the {@link
+ * HttpTransport} of the underlying channel. Calls that were already created keep using the
+ * transport they were created with, so in-flight requests are not interrupted.
+ */
+@InternalApi
+public class RefreshingHttpJsonChannel extends ManagedHttpJsonChannel {
+
+ private static final Logger LOG = Logger.getLogger(RefreshingHttpJsonChannel.class.getName());
+
+ private final CertificateRotationTracker rotationTracker;
+ private final Supplier transportFactory;
+ private final String workloadCertPath;
+ private final ManagedHttpJsonChannel delegate;
+ private final Object refreshLock = new Object();
+ private final AtomicLong generation = new AtomicLong(0);
+
+ /**
+ * @param transportFactory creates a transport configured with the certificate currently on disk
+ * @param channelFactory creates the underlying channel from the initial transport
+ * @param workloadCertPath path of the workload certificate to monitor for rotation
+ */
+ public RefreshingHttpJsonChannel(
+ Supplier transportFactory,
+ Function channelFactory,
+ String workloadCertPath) {
+ super(true);
+ this.transportFactory = transportFactory;
+ this.workloadCertPath = workloadCertPath;
+ // Record the baseline fingerprint before the initial transport loads the certificate from disk,
+ // so a rotation between the two steps is detected as a fingerprint change.
+ this.rotationTracker =
+ new CertificateRotationTracker(this::getWorkloadCertPath, this::getCertificateFingerprint);
+ this.delegate = channelFactory.apply(transportFactory.get());
+ }
+
+ @VisibleForTesting
+ String getWorkloadCertPath() {
+ return workloadCertPath;
+ }
+
+ @VisibleForTesting
+ String getCertificateFingerprint(String certPath) {
+ return WorkloadCertificateUtils.getCertificateFingerprint(certPath);
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public boolean shouldRefresh() {
+ return rotationTracker.shouldRefresh();
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public void refresh() {
+ // A generation change while waiting for the lock means a concurrent refresh already swapped in
+ // a transport with a new certificate, so there is no need to read the certificate again.
+ long generationBeforeLock = generation.get();
+ synchronized (refreshLock) {
+ if (isShutdown()) {
+ return;
+ }
+ if (generation.get() != generationBeforeLock) {
+ LOG.fine(
+ "HTTP/JSON channel was already refreshed by a concurrent thread, skipping duplicate"
+ + " refresh");
+ return;
+ }
+ String currentDiskFingerprint = rotationTracker.readDiskFingerprint();
+ if (currentDiskFingerprint.isEmpty()) {
+ return;
+ }
+
+ // Double-check inside refreshLock
+ if (rotationTracker.isAlreadyActive(currentDiskFingerprint)) {
+ LOG.fine(
+ "HTTP/JSON channel was already refreshed by a concurrent thread, skipping duplicate"
+ + " refresh");
+ return;
+ }
+
+ LOG.info("mTLS certificate rotation detected. Refreshing HTTP/JSON transport.");
+
+ HttpTransport newTransport;
+ try {
+ newTransport = transportFactory.get();
+ } catch (Exception e) {
+ LOG.log(Level.WARNING, "Failed to refresh HTTP/JSON transport, keeping old transport", e);
+ return;
+ }
+ // The previous transport is not shut down: calls created before the swap may still be using
+ // it, and it needs no explicit shutdown because its idle keep-alive connections expire.
+ delegate.setHttpTransport(newTransport);
+ // Order matters: swap the transport, then bump generation, then mark the tracker refreshed,
+ // so any failing RPC that observes the new fingerprint also observes the new generation.
+ generation.incrementAndGet();
+ rotationTracker.markRefreshed(currentDiskFingerprint);
+ }
+ }
+
+ /** {@inheritDoc} */
+ @Override
+ public long getGeneration() {
+ return generation.get();
+ }
+
+ @Override
+ public HttpJsonClientCall newCall(
+ ApiMethodDescriptor methodDescriptor, HttpJsonCallOptions callOptions) {
+ return delegate.newCall(methodDescriptor, callOptions);
+ }
+
+ @Override
+ Executor getExecutor() {
+ return delegate.getExecutor();
+ }
+
+ @Override
+ String getEndpoint() {
+ return delegate.getEndpoint();
+ }
+
+ @Override
+ @VisibleForTesting
+ HttpTransport getHttpTransport() {
+ return delegate.getHttpTransport();
+ }
+
+ @VisibleForTesting
+ void invalidateDiskFingerprintCache() {
+ rotationTracker.invalidateCache();
+ }
+
+ @Override
+ public void shutdown() {
+ // Serialized with refresh() so that a transport swap cannot race with shutdown.
+ synchronized (refreshLock) {
+ delegate.shutdown();
+ }
+ }
+
+ @Override
+ public boolean isShutdown() {
+ return delegate.isShutdown();
+ }
+
+ @Override
+ public boolean isTerminated() {
+ return delegate.isTerminated();
+ }
+
+ @Override
+ public void shutdownNow() {
+ synchronized (refreshLock) {
+ delegate.shutdownNow();
+ }
+ }
+
+ @Override
+ public boolean awaitTermination(long duration, TimeUnit unit) throws InterruptedException {
+ return delegate.awaitTermination(duration, unit);
+ }
+
+ @Override
+ public void close() {
+ shutdown();
+ }
+}
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonCallContextTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonCallContextTest.java
index 08044522e729..6963ca2c1f88 100644
--- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonCallContextTest.java
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/HttpJsonCallContextTest.java
@@ -47,6 +47,7 @@
import com.google.auth.Credentials;
import com.google.common.collect.ImmutableMap;
import com.google.common.truth.Truth;
+import java.time.Duration;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
@@ -207,6 +208,19 @@ void testWithLongerTimeout() {
.isEqualTo(java.time.Duration.ofSeconds(5));
}
+ @Test
+ void testWithNullOrZeroTimeoutClearsExistingTimeout() {
+ HttpJsonCallContext ctxWithTimeout =
+ HttpJsonCallContext.createDefault().withTimeoutDuration(Duration.ofSeconds(5));
+
+ // Sanity check
+ Truth.assertThat(ctxWithTimeout.getTimeoutDuration()).isEqualTo(Duration.ofSeconds(5));
+
+ Duration nullTimeout = null;
+ assertNull(ctxWithTimeout.withTimeoutDuration(nullTimeout).getTimeoutDuration());
+ assertNull(ctxWithTimeout.withTimeoutDuration(Duration.ZERO).getTimeoutDuration());
+ }
+
@Test
void testMergeWithNullTimeout() {
java.time.Duration timeout = java.time.Duration.ofSeconds(10);
@@ -334,4 +348,55 @@ void testMergeOptions() {
assertEquals(testContext2, mergedContext.getOption(contextKey2));
assertEquals(testContext3, mergedContext.getOption(contextKey3));
}
+
+ @Test
+ void testWithChannelClearsStaleTransportChannel() {
+ ManagedHttpJsonChannel channel1 =
+ mock(ManagedHttpJsonChannel.class, Mockito.withSettings().withoutAnnotations());
+ ManagedHttpJsonChannel channel2 =
+ mock(ManagedHttpJsonChannel.class, Mockito.withSettings().withoutAnnotations());
+
+ HttpJsonTransportChannel transportChannel1 =
+ HttpJsonTransportChannel.newBuilder().setManagedChannel(channel1).build();
+
+ HttpJsonCallContext context =
+ HttpJsonCallContext.createDefault().withTransportChannel(transportChannel1);
+ Truth.assertThat(context.getTransportChannel()).isSameInstanceAs(transportChannel1);
+
+ // Retains transportChannel when setting same channel
+ Truth.assertThat(context.withChannel(channel1).getTransportChannel())
+ .isSameInstanceAs(transportChannel1);
+
+ // Clears transportChannel to null when setting null or a different channel
+ HttpJsonCallContext nullChannelContext = context.withChannel(null);
+ Truth.assertThat(nullChannelContext.getChannel()).isNull();
+ Truth.assertThat(nullChannelContext.getTransportChannel()).isNull();
+ Truth.assertThat(context.withChannel(channel2).getTransportChannel()).isNull();
+
+ // Merging a context with a cleared channel into the original context keeps the original
+ // channel and transportChannel
+ HttpJsonCallContext mergedWithNullChannel = context.merge(nullChannelContext);
+ Truth.assertThat(mergedWithNullChannel.getChannel()).isSameInstanceAs(channel1);
+ Truth.assertThat(mergedWithNullChannel.getTransportChannel())
+ .isSameInstanceAs(transportChannel1);
+ }
+
+ @Test
+ void testMergeClearsStaleTransportChannel() {
+ ManagedHttpJsonChannel channel1 =
+ mock(ManagedHttpJsonChannel.class, Mockito.withSettings().withoutAnnotations());
+ ManagedHttpJsonChannel channel2 =
+ mock(ManagedHttpJsonChannel.class, Mockito.withSettings().withoutAnnotations());
+
+ HttpJsonTransportChannel transportChannel1 =
+ HttpJsonTransportChannel.newBuilder().setManagedChannel(channel1).build();
+
+ HttpJsonCallContext context1 =
+ HttpJsonCallContext.createDefault().withTransportChannel(transportChannel1);
+ HttpJsonCallContext context2 = HttpJsonCallContext.createDefault().withChannel(channel2);
+
+ HttpJsonCallContext merged = context1.merge(context2);
+ Truth.assertThat(merged.getChannel()).isSameInstanceAs(channel2);
+ Truth.assertThat(merged.getTransportChannel()).isNull();
+ }
}
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTest.java
index 8c95c1d2e1c4..74a578da24cb 100644
--- a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTest.java
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTest.java
@@ -31,21 +31,34 @@
import static com.google.common.truth.Truth.assertThat;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.mock;
+import com.google.api.client.http.HttpTransport;
import com.google.api.client.http.javanet.NetHttpTransport;
+import com.google.api.gax.rpc.HeaderProvider;
import com.google.api.gax.rpc.TransportChannelProvider;
import com.google.api.gax.rpc.mtls.AbstractMtlsTransportChannelTest;
import com.google.api.gax.rpc.mtls.CertificateBasedAccess;
+import com.google.api.gax.rpc.testing.FakeMtlsProvider;
+import com.google.auth.mtls.CertificateSourceUnavailableException;
import com.google.auth.mtls.MtlsProvider;
import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.security.GeneralSecurityException;
+import java.security.KeyStore;
import java.util.Collections;
import java.util.Map;
import java.util.concurrent.Executor;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.atomic.AtomicReference;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mockito;
class InstantiatingHttpJsonChannelProviderTest extends AbstractMtlsTransportChannelTest {
@@ -55,9 +68,10 @@ class InstantiatingHttpJsonChannelProviderTest extends AbstractMtlsTransportChan
@BeforeEach
public void setup() throws IOException {
- certificateBasedAccess =
- new CertificateBasedAccess(
- name -> name.equals("GOOGLE_API_USE_MTLS_ENDPOINT") ? "never" : "false");
+ certificateBasedAccess = Mockito.mock(CertificateBasedAccess.class);
+ Mockito.when(certificateBasedAccess.getMtlsEndpointUsagePolicy())
+ .thenReturn(CertificateBasedAccess.MtlsEndpointUsagePolicy.NEVER);
+ Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(false);
}
@Test
@@ -179,20 +193,384 @@ void managedChannelUsesCustomExecutor() throws IOException {
instantiatingHttpJsonChannelProvider.getTransportChannel().shutdownNow();
}
- @Override
- protected Object getMtlsObjectFromTransportChannel(
- MtlsProvider provider, CertificateBasedAccess certificateBasedAccess)
+ @Test
+ void managedChannelDoesNotShutdownCustomHttpTransport() throws IOException {
+ HttpTransport mockHttpTransport = Mockito.mock(HttpTransport.class);
+
+ InstantiatingHttpJsonChannelProvider provider =
+ InstantiatingHttpJsonChannelProvider.newBuilder()
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setHttpTransport(mockHttpTransport)
+ .setCertificateBasedAccess(certificateBasedAccess)
+ .build();
+ provider = (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
+
+ HttpJsonTransportChannel httpJsonTransportChannel = provider.getTransportChannel();
+
+ // Verify custom transport is injected (direct ManagedHttpJsonChannel when workloadCertPath is
+ // null)
+ ManagedHttpJsonInterceptorChannel interceptorChannel =
+ (ManagedHttpJsonInterceptorChannel) httpJsonTransportChannel.getManagedChannel();
+ ManagedHttpJsonInterceptorChannel managedHttpJsonChannel =
+ (ManagedHttpJsonInterceptorChannel) interceptorChannel.getChannel();
+ ManagedHttpJsonChannel channel = managedHttpJsonChannel.getChannel();
+
+ assertThat(channel.getHttpTransport()).isEqualTo(mockHttpTransport);
+
+ // Perform a shutdown
+ provider.getTransportChannel().shutdownNow();
+
+ // Verify that shutdown() was NOT called on the custom HttpTransport
+ Mockito.verify(mockHttpTransport, Mockito.never()).shutdown();
+ }
+
+ @Test
+ void channelCreation_withWorkloadCertPath_wrapsWithRefreshingHttpJsonChannel()
throws IOException, GeneralSecurityException {
+ Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ Mockito.when(certificateBasedAccess.getWorkloadCertPath()).thenReturn("fake/cert/path.json");
+ MtlsProvider mtlsProvider =
+ new FakeMtlsProvider(FakeMtlsProvider.createTestMtlsKeyStore(), "", false);
+
+ InstantiatingHttpJsonChannelProvider provider =
+ InstantiatingHttpJsonChannelProvider.newBuilder()
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setMtlsProvider(mtlsProvider)
+ .setCertificateBasedAccess(certificateBasedAccess)
+ .build();
+ provider = (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
+
+ HttpJsonTransportChannel httpJsonTransportChannel = provider.getTransportChannel();
+
+ ManagedHttpJsonInterceptorChannel interceptorChannel =
+ (ManagedHttpJsonInterceptorChannel) httpJsonTransportChannel.getManagedChannel();
+ ManagedHttpJsonInterceptorChannel managedHttpJsonChannel =
+ (ManagedHttpJsonInterceptorChannel) interceptorChannel.getChannel();
+ assertThat(managedHttpJsonChannel.getChannel()).isInstanceOf(RefreshingHttpJsonChannel.class);
+
+ provider.getTransportChannel().shutdownNow();
+ }
+
+ @Test
+ void channelCreation_withCustomHttpTransport_ignoresWorkloadCertPathAndDoesNotWrap()
+ throws IOException, GeneralSecurityException {
+ // mTLS is otherwise fully configured, so only the custom transport prevents wrapping. Lenient
+ // because a custom transport is expected to skip these lookups.
+ Mockito.lenient().when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ Mockito.lenient()
+ .when(certificateBasedAccess.getWorkloadCertPath())
+ .thenReturn("fake/cert/path.json");
+ MtlsProvider mtlsProvider =
+ new FakeMtlsProvider(FakeMtlsProvider.createTestMtlsKeyStore(), "", false);
+ HttpTransport mockHttpTransport = Mockito.mock(HttpTransport.class);
+
+ InstantiatingHttpJsonChannelProvider provider =
+ InstantiatingHttpJsonChannelProvider.newBuilder()
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setHttpTransport(mockHttpTransport)
+ .setMtlsProvider(mtlsProvider)
+ .setCertificateBasedAccess(certificateBasedAccess)
+ .build();
+ provider = (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
+
+ HttpJsonTransportChannel httpJsonTransportChannel = provider.getTransportChannel();
+
+ ManagedHttpJsonInterceptorChannel interceptorChannel =
+ (ManagedHttpJsonInterceptorChannel) httpJsonTransportChannel.getManagedChannel();
+ ManagedHttpJsonInterceptorChannel managedHttpJsonChannel =
+ (ManagedHttpJsonInterceptorChannel) interceptorChannel.getChannel();
+ assertThat(managedHttpJsonChannel.getChannel())
+ .isNotInstanceOf(RefreshingHttpJsonChannel.class);
+ Mockito.verify(certificateBasedAccess, Mockito.never()).getWorkloadCertPath();
+
+ httpJsonTransportChannel.shutdownNow();
+ }
+
+ @Test
+ void getTransportChannel_withMtlsKeyStore_usesMtlsTransport() throws IOException {
+ Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ MtlsProvider mtlsProvider =
+ new FakeMtlsProvider(FakeMtlsProvider.createTestMtlsKeyStore(), "", false);
+
+ InstantiatingHttpJsonChannelProvider provider =
+ InstantiatingHttpJsonChannelProvider.newBuilder()
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setMtlsProvider(mtlsProvider)
+ .setCertificateBasedAccess(certificateBasedAccess)
+ .build();
+ provider = (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
+
+ HttpJsonTransportChannel httpJsonTransportChannel = provider.getTransportChannel();
+ ManagedHttpJsonInterceptorChannel interceptorChannel =
+ (ManagedHttpJsonInterceptorChannel) httpJsonTransportChannel.getManagedChannel();
+ ManagedHttpJsonChannel channel =
+ ((ManagedHttpJsonInterceptorChannel) interceptorChannel.getChannel()).getChannel();
+
+ assertThat(channel).isNotInstanceOf(RefreshingHttpJsonChannel.class);
+ assertThat(((NetHttpTransport) channel.getHttpTransport()).isMtls()).isTrue();
+
+ httpJsonTransportChannel.shutdownNow();
+ }
+
+ @Test
+ void refresh_whenKeyStoreUnavailableDuringRotation_keepsCurrentTransport(@TempDir Path tempDir)
+ throws IOException {
+ Path certPath = tempDir.resolve("cert.pem");
+ Files.write(certPath, "certificate-v1".getBytes(StandardCharsets.UTF_8));
+ Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ Mockito.when(certificateBasedAccess.getWorkloadCertPath()).thenReturn(certPath.toString());
+ KeyStore keyStore = FakeMtlsProvider.createTestMtlsKeyStore();
+ AtomicReference currentKeyStore = new AtomicReference<>(keyStore);
+ MtlsProvider mtlsProvider =
+ new MtlsProvider() {
+ @Override
+ public KeyStore getKeyStore() {
+ return currentKeyStore.get();
+ }
+
+ @Override
+ public boolean isAvailable() {
+ return true;
+ }
+ };
+
+ InstantiatingHttpJsonChannelProvider provider =
+ InstantiatingHttpJsonChannelProvider.newBuilder()
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setMtlsProvider(mtlsProvider)
+ .setCertificateBasedAccess(certificateBasedAccess)
+ .build();
+ provider = (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
+ HttpJsonTransportChannel httpJsonTransportChannel = provider.getTransportChannel();
+ ManagedHttpJsonInterceptorChannel interceptorChannel =
+ (ManagedHttpJsonInterceptorChannel) httpJsonTransportChannel.getManagedChannel();
+ RefreshingHttpJsonChannel channel =
+ (RefreshingHttpJsonChannel)
+ ((ManagedHttpJsonInterceptorChannel) interceptorChannel.getChannel()).getChannel();
+ HttpTransport initialTransport = channel.getHttpTransport();
+ assertThat(((NetHttpTransport) initialTransport).isMtls()).isTrue();
+
+ // The certificate rotates on disk, but the provider cannot return the new key store yet.
+ Files.write(certPath, "certificate-v2".getBytes(StandardCharsets.UTF_8));
+ currentKeyStore.set(null);
+ assertThat(channel.shouldRefresh()).isTrue();
+ channel.refresh();
+
+ // The current authenticated transport is kept instead of one without a client certificate.
+ assertThat(channel.getHttpTransport()).isSameInstanceAs(initialTransport);
+ assertThat(channel.getGeneration()).isEqualTo(0);
+ assertThat(channel.shouldRefresh()).isTrue();
+
+ // Once the key store is available again, the next refresh swaps in a new mTLS transport.
+ currentKeyStore.set(keyStore);
+ channel.refresh();
+ assertThat(channel.getHttpTransport()).isNotSameInstanceAs(initialTransport);
+ assertThat(((NetHttpTransport) channel.getHttpTransport()).isMtls()).isTrue();
+ assertThat(channel.getGeneration()).isEqualTo(1);
+
+ httpJsonTransportChannel.shutdownNow();
+ }
+
+ @Test
+ void getTransportChannel_whenMtlsKeyStoreThrowsIOException_throwsCheckedIOException()
+ throws Exception {
+ Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ Mockito.when(certificateBasedAccess.getWorkloadCertPath()).thenReturn("fake/cert/path.json");
+ MtlsProvider failingMtlsProvider =
+ Mockito.mock(MtlsProvider.class, Mockito.withSettings().withoutAnnotations());
+ Mockito.when(failingMtlsProvider.getKeyStore())
+ .thenThrow(new IOException("Simulated keystore read failure"));
+
+ InstantiatingHttpJsonChannelProvider provider =
+ InstantiatingHttpJsonChannelProvider.newBuilder()
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setMtlsProvider(failingMtlsProvider)
+ .setCertificateBasedAccess(certificateBasedAccess)
+ .build();
+ final InstantiatingHttpJsonChannelProvider finalProvider =
+ (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
+
+ // Must throw checked IOException directly (not wrapped in RuntimeException)
+ IOException thrown = assertThrows(IOException.class, finalProvider::getTransportChannel);
+ assertThat(thrown).hasMessageThat().contains("Simulated keystore read failure");
+ }
+
+ @Test
+ void getTransportChannel_whenMtlsActiveAndKeyStoreNull_throwsIOException() {
+ Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ MtlsProvider providerWithNullKeyStore = new FakeMtlsProvider(null, "", false);
+
+ InstantiatingHttpJsonChannelProvider provider =
+ InstantiatingHttpJsonChannelProvider.newBuilder()
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setMtlsProvider(providerWithNullKeyStore)
+ .setCertificateBasedAccess(certificateBasedAccess)
+ .build();
+ InstantiatingHttpJsonChannelProvider finalProvider =
+ (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
+
+ IOException thrown = assertThrows(IOException.class, finalProvider::getTransportChannel);
+ assertThat(thrown).hasMessageThat().contains("Failed to initialize mTLS HttpTransport");
+ }
+
+ @Test
+ void getTransportChannel_whenRotationEnabledAndKeyStoreNull_throwsIOException() {
+ Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ Mockito.when(certificateBasedAccess.getWorkloadCertPath()).thenReturn("fake/cert/path.json");
+ MtlsProvider providerWithNullKeyStore = new FakeMtlsProvider(null, "", false);
+
+ InstantiatingHttpJsonChannelProvider provider =
+ InstantiatingHttpJsonChannelProvider.newBuilder()
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setMtlsProvider(providerWithNullKeyStore)
+ .setCertificateBasedAccess(certificateBasedAccess)
+ .build();
+ InstantiatingHttpJsonChannelProvider finalProvider =
+ (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
+
+ IOException thrown = assertThrows(IOException.class, finalProvider::getTransportChannel);
+ assertThat(thrown).hasMessageThat().contains("Failed to initialize mTLS HttpTransport");
+ }
+
+ @Test
+ void getTransportChannel_whenKeyStoreUninitialized_wrapsGeneralSecurityException()
+ throws Exception {
+ Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ Mockito.when(certificateBasedAccess.getWorkloadCertPath()).thenReturn("fake/cert/path.json");
+ // A KeyStore that was never loaded makes transport creation fail with a KeyStoreException.
+ KeyStore uninitializedKeyStore = KeyStore.getInstance("PKCS12");
+ MtlsProvider providerWithBadKeyStore = new FakeMtlsProvider(uninitializedKeyStore, "", false);
+
+ InstantiatingHttpJsonChannelProvider provider =
+ InstantiatingHttpJsonChannelProvider.newBuilder()
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setMtlsProvider(providerWithBadKeyStore)
+ .setCertificateBasedAccess(certificateBasedAccess)
+ .build();
+ InstantiatingHttpJsonChannelProvider finalProvider =
+ (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
+
+ // The GeneralSecurityException must be unwrapped from the factory's RuntimeException, so that
+ // getTransportChannel() surfaces it as the direct cause of its checked IOException.
+ IOException thrown = assertThrows(IOException.class, finalProvider::getTransportChannel);
+ assertThat(thrown).hasCauseThat().isInstanceOf(GeneralSecurityException.class);
+ }
+
+ @Test
+ void getTransportChannel_whenMtlsKeyStoreThrowsRuntimeException_propagatesUnwrapped()
+ throws Exception {
+ Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ Mockito.when(certificateBasedAccess.getWorkloadCertPath()).thenReturn("fake/cert/path.json");
+ IllegalStateException failure = new IllegalStateException("Simulated keystore failure");
+ MtlsProvider failingMtlsProvider =
+ Mockito.mock(MtlsProvider.class, Mockito.withSettings().withoutAnnotations());
+ Mockito.when(failingMtlsProvider.getKeyStore()).thenThrow(failure);
+
+ InstantiatingHttpJsonChannelProvider provider =
+ InstantiatingHttpJsonChannelProvider.newBuilder()
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setMtlsProvider(failingMtlsProvider)
+ .setCertificateBasedAccess(certificateBasedAccess)
+ .build();
+ InstantiatingHttpJsonChannelProvider finalProvider =
+ (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
+
+ IllegalStateException thrown =
+ assertThrows(IllegalStateException.class, finalProvider::getTransportChannel);
+ assertThat(thrown).isSameInstanceAs(failure);
+ }
+
+ @Test
+ void getTransportChannel_whenDefaultMtlsProviderThrowsIOException_propagatesIOException()
+ throws Exception {
+ Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ IOException expected = new IOException("Corrupt PEM file");
+ Mockito.when(certificateBasedAccess.createDefaultMtlsProvider()).thenThrow(expected);
+
+ InstantiatingHttpJsonChannelProvider provider =
+ InstantiatingHttpJsonChannelProvider.newBuilder()
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setCertificateBasedAccess(certificateBasedAccess)
+ .build();
+ InstantiatingHttpJsonChannelProvider finalProvider =
+ (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
+
+ IOException thrown = assertThrows(IOException.class, finalProvider::getTransportChannel);
+ assertThat(thrown).isSameInstanceAs(expected);
+ }
+
+ @Test
+ void
+ getTransportChannel_whenDefaultMtlsProviderThrowsCertificateSourceUnavailable_fallsBackWithoutMtls()
+ throws Exception {
+ Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ Mockito.when(certificateBasedAccess.createDefaultMtlsProvider())
+ .thenThrow(new CertificateSourceUnavailableException("No certificates on disk"));
+
+ InstantiatingHttpJsonChannelProvider provider =
+ InstantiatingHttpJsonChannelProvider.newBuilder()
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setCertificateBasedAccess(certificateBasedAccess)
+ .build();
+ InstantiatingHttpJsonChannelProvider finalProvider =
+ (InstantiatingHttpJsonChannelProvider) provider.withHeaders(DEFAULT_HEADER_MAP);
+
+ HttpTransport transport = finalProvider.createHttpTransport();
+ assertThat(transport).isInstanceOf(NetHttpTransport.class);
+ assertThat(((NetHttpTransport) transport).isMtls()).isFalse();
+
+ HttpJsonTransportChannel channel = finalProvider.getTransportChannel();
+ assertThat(channel).isNotNull();
+ channel.shutdownNow();
+ }
+
+ @Test
+ void createHttpTransport_withMtlsKeyStore_returnsMtlsTransport()
+ throws IOException, GeneralSecurityException {
+ Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ MtlsProvider provider =
+ new FakeMtlsProvider(FakeMtlsProvider.createTestMtlsKeyStore(), "", false);
+
InstantiatingHttpJsonChannelProvider channelProvider =
InstantiatingHttpJsonChannelProvider.newBuilder()
- .setEndpoint("localhost:8080")
+ .setEndpoint(DEFAULT_ENDPOINT)
.setMtlsProvider(provider)
.setCertificateBasedAccess(certificateBasedAccess)
- .setHeaderProvider(Collections::emptyMap)
- .setExecutor(Runnable::run)
.build();
- NetHttpTransport transport = (NetHttpTransport) channelProvider.createHttpTransport();
- return (transport != null && transport.isMtls()) ? transport : null;
+
+ HttpTransport transport = channelProvider.createHttpTransport();
+ assertThat(transport).isNotNull();
+ assertThat(transport).isInstanceOf(NetHttpTransport.class);
+ assertThat(((NetHttpTransport) transport).isMtls()).isTrue();
+ }
+
+ @Test
+ void createHttpTransport_whenMtlsProviderNullOrNotUsingClientCert_returnsNonMtlsTransport()
+ throws IOException, GeneralSecurityException {
+ InstantiatingHttpJsonChannelProvider nullMtlsProviderChannelProvider =
+ InstantiatingHttpJsonChannelProvider.newBuilder()
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setMtlsProvider(null)
+ .setCertificateBasedAccess(certificateBasedAccess)
+ .build();
+ NetHttpTransport nullMtlsProviderTransport =
+ (NetHttpTransport) nullMtlsProviderChannelProvider.createHttpTransport();
+ assertThat(nullMtlsProviderTransport).isNotNull();
+ assertThat(nullMtlsProviderTransport.isMtls()).isFalse();
+
+ Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(false);
+ MtlsProvider provider =
+ new FakeMtlsProvider(FakeMtlsProvider.createTestMtlsKeyStore(), "", false);
+ InstantiatingHttpJsonChannelProvider disabledMtlsChannelProvider =
+ InstantiatingHttpJsonChannelProvider.newBuilder()
+ .setEndpoint(DEFAULT_ENDPOINT)
+ .setMtlsProvider(provider)
+ .setCertificateBasedAccess(certificateBasedAccess)
+ .build();
+ NetHttpTransport disabledMtlsTransport =
+ (NetHttpTransport) disabledMtlsChannelProvider.createHttpTransport();
+ assertThat(disabledMtlsTransport).isNotNull();
+ assertThat(disabledMtlsTransport.isMtls()).isFalse();
}
@Test
@@ -214,4 +592,21 @@ void testConfigureConscryptSecurityProvider_returnsConfiguredBuilder() {
HttpJsonConscryptUtils.configureConscryptSecurityProvider(builder);
assertThat(result).isSameInstanceAs(builder);
}
+
+ @Override
+ protected Object getMtlsObjectFromTransportChannel(
+ MtlsProvider provider, CertificateBasedAccess certificateBasedAccess)
+ throws IOException, GeneralSecurityException {
+ InstantiatingHttpJsonChannelProvider channelProvider =
+ InstantiatingHttpJsonChannelProvider.newBuilder()
+ .setEndpoint("localhost:8080")
+ .setMtlsProvider(provider)
+ .setCertificateBasedAccess(certificateBasedAccess)
+ .setHeaderProvider(
+ mock(HeaderProvider.class, Mockito.withSettings().withoutAnnotations()))
+ .setExecutor(mock(Executor.class))
+ .build();
+ NetHttpTransport transport = (NetHttpTransport) channelProvider.createHttpTransport();
+ return (transport != null && transport.isMtls()) ? transport : null;
+ }
}
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTls13Test.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTls13Test.java
new file mode 100644
index 000000000000..99bd98bb9fb6
--- /dev/null
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/InstantiatingHttpJsonChannelProviderTls13Test.java
@@ -0,0 +1,279 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ * * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.google.api.gax.httpjson;
+
+import static com.google.common.truth.Truth.assertThat;
+
+import com.google.api.client.http.GenericUrl;
+import com.google.api.client.http.HttpResponse;
+import com.google.api.client.http.HttpTransport;
+import com.google.api.client.util.SecurityUtils;
+import com.google.api.gax.rpc.mtls.CertificateBasedAccess;
+import com.google.api.gax.rpc.testing.FakeMtlsProvider;
+import java.io.BufferedReader;
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.InputStream;
+import java.io.InputStreamReader;
+import java.io.OutputStream;
+import java.net.InetAddress;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyStore;
+import java.security.cert.CertificateFactory;
+import java.security.cert.X509Certificate;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.Future;
+import java.util.concurrent.TimeUnit;
+import javax.net.ssl.KeyManagerFactory;
+import javax.net.ssl.SSLContext;
+import javax.net.ssl.SSLServerSocket;
+import javax.net.ssl.SSLSocket;
+import javax.net.ssl.TrustManager;
+import javax.net.ssl.X509TrustManager;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.condition.EnabledIf;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mockito;
+
+/**
+ * Handshake test for the Conscrypt-backed mTLS {@link HttpTransport} built by {@link
+ * InstantiatingHttpJsonChannelProvider}.
+ *
+ *
Regression test for "Unknown authType: GENERIC": on TLS 1.3, Conscrypt passes the authType
+ * {@code "GENERIC"} to its trust manager. If the trust manager comes from the JDK (SunJSSE) rather
+ * than Conscrypt, it rejects server certificates that are issued by a trusted CA and carry a
+ * KeyUsage extension (as Google front ends' certificates do), and every mTLS HTTP/JSON handshake
+ * fails. A self-signed server certificate that is itself the trust anchor does not reproduce the
+ * issue, so the server here presents a CA-issued leaf with KeyUsage.
+ *
+ *
The transport always uses the default trust store, so this test points {@code
+ * javax.net.ssl.trustStore} at a temporary store containing only the test CA, and restores the
+ * previous value afterwards.
+ */
+@EnabledIf(
+ value = "isConscryptAndTls13Available",
+ disabledReason = "Conscrypt native library or TLS 1.3 is unavailable on this platform")
+class InstantiatingHttpJsonChannelProviderTls13Test {
+
+ private static final String RESOURCE_DIR = "com/google/api/gax/httpjson/";
+ private static final String TRUST_STORE_PROPERTY = "javax.net.ssl.trustStore";
+ private static final String TRUST_STORE_PASSWORD_PROPERTY = "javax.net.ssl.trustStorePassword";
+ private static final String TRUST_STORE_PASSWORD = "changeit";
+
+ @TempDir File tempDir;
+
+ private String previousTrustStore;
+ private String previousTrustStorePassword;
+ private ExecutorService serverExecutor;
+
+ /** Condition for {@link EnabledIf}; must be static because it is used at class level. */
+ static boolean isConscryptAndTls13Available() {
+ return HttpJsonConscryptUtils.getConscryptProvider() != null && isTls13Supported();
+ }
+
+ @BeforeEach
+ void setUp() throws Exception {
+ previousTrustStore = System.getProperty(TRUST_STORE_PROPERTY);
+ previousTrustStorePassword = System.getProperty(TRUST_STORE_PASSWORD_PROPERTY);
+ File trustStoreFile = writeTrustStoreWithTestCa();
+ System.setProperty(TRUST_STORE_PROPERTY, trustStoreFile.getAbsolutePath());
+ System.setProperty(TRUST_STORE_PASSWORD_PROPERTY, TRUST_STORE_PASSWORD);
+
+ serverExecutor = Executors.newSingleThreadExecutor();
+ }
+
+ @AfterEach
+ void tearDown() {
+ restoreProperty(TRUST_STORE_PROPERTY, previousTrustStore);
+ restoreProperty(TRUST_STORE_PASSWORD_PROPERTY, previousTrustStorePassword);
+ if (serverExecutor != null) {
+ serverExecutor.shutdownNow();
+ }
+ }
+
+ @Test
+ void createHttpTransport_withMtlsAndConscrypt_completesTls13HandshakeWithCaIssuedServerCert()
+ throws Exception {
+ CertificateBasedAccess certificateBasedAccess = Mockito.mock(CertificateBasedAccess.class);
+ Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true);
+ InstantiatingHttpJsonChannelProvider channelProvider =
+ InstantiatingHttpJsonChannelProvider.newBuilder()
+ .setEndpoint("localhost:443")
+ .setMtlsProvider(
+ new FakeMtlsProvider(FakeMtlsProvider.createTestMtlsKeyStore(), "", false))
+ .setCertificateBasedAccess(certificateBasedAccess)
+ .build();
+ HttpTransport transport = channelProvider.createHttpTransport();
+ assertThat(transport).isNotNull();
+
+ final SSLServerSocket serverSocket = createTls13ServerSocket();
+ try {
+ Future clientCertificatePresented =
+ serverExecutor.submit(() -> serveSingleRequest(serverSocket));
+
+ HttpResponse response =
+ transport
+ .createRequestFactory()
+ .buildGetRequest(
+ new GenericUrl("https://localhost:" + serverSocket.getLocalPort() + "/"))
+ .execute();
+ try {
+ assertThat(response.getStatusCode()).isEqualTo(200);
+ assertThat(response.parseAsString()).isEqualTo("ok");
+ } finally {
+ response.disconnect();
+ }
+ // execute() is synchronous so the server task is already done; timeout guards against hangs.
+ assertThat(clientCertificatePresented.get(10, TimeUnit.SECONDS)).isTrue();
+ } finally {
+ serverSocket.close();
+ }
+ }
+
+ /** Accepts one connection, answers one HTTP request, and reports if a client cert was sent. */
+ private static boolean serveSingleRequest(SSLServerSocket serverSocket) throws Exception {
+ SSLSocket socket = (SSLSocket) serverSocket.accept();
+ try {
+ socket.startHandshake();
+ boolean clientCertificatePresented = socket.getSession().getPeerCertificates().length > 0;
+ BufferedReader reader =
+ new BufferedReader(
+ new InputStreamReader(socket.getInputStream(), StandardCharsets.US_ASCII));
+ String line;
+ while ((line = reader.readLine()) != null && !line.isEmpty()) {
+ // Drain request headers.
+ }
+ OutputStream out = socket.getOutputStream();
+ out.write(
+ ("HTTP/1.1 200 OK\r\nContent-Length: 2\r\nConnection: close\r\n\r\nok")
+ .getBytes(StandardCharsets.US_ASCII));
+ out.flush();
+ return clientCertificatePresented;
+ } finally {
+ socket.close();
+ }
+ }
+
+ /**
+ * Creates a JDK TLS 1.3 server that presents a CA-issued leaf certificate with KeyUsage and
+ * requires (but does not validate) a client certificate.
+ */
+ private static SSLServerSocket createTls13ServerSocket() throws Exception {
+ KeyStore serverKeyStore;
+ InputStream certAndKey = openResource("tls13TestServerCertAndKey.pem");
+ try {
+ serverKeyStore = SecurityUtils.createMtlsKeyStore(certAndKey);
+ } finally {
+ certAndKey.close();
+ }
+ KeyManagerFactory keyManagerFactory =
+ KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm());
+ keyManagerFactory.init(serverKeyStore, new char[0]);
+
+ SSLContext serverContext = SSLContext.getInstance("TLSv1.3");
+ serverContext.init(
+ keyManagerFactory.getKeyManagers(), new TrustManager[] {new AcceptAllTrustManager()}, null);
+ SSLServerSocket serverSocket =
+ (SSLServerSocket)
+ serverContext
+ .getServerSocketFactory()
+ .createServerSocket(0, 1, InetAddress.getLoopbackAddress());
+ serverSocket.setEnabledProtocols(new String[] {"TLSv1.3"});
+ serverSocket.setNeedClientAuth(true);
+ return serverSocket;
+ }
+
+ private File writeTrustStoreWithTestCa() throws Exception {
+ X509Certificate caCertificate;
+ InputStream caPem = openResource("tls13TestCa.pem");
+ try {
+ caCertificate =
+ (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(caPem);
+ } finally {
+ caPem.close();
+ }
+ KeyStore trustStore = KeyStore.getInstance(KeyStore.getDefaultType());
+ trustStore.load(null, null);
+ trustStore.setCertificateEntry("gax-test-ca", caCertificate);
+ File trustStoreFile = new File(tempDir, "truststore");
+ OutputStream out = new FileOutputStream(trustStoreFile);
+ try {
+ trustStore.store(out, TRUST_STORE_PASSWORD.toCharArray());
+ } finally {
+ out.close();
+ }
+ return trustStoreFile;
+ }
+
+ private static InputStream openResource(String name) {
+ InputStream stream =
+ InstantiatingHttpJsonChannelProviderTls13Test.class
+ .getClassLoader()
+ .getResourceAsStream(RESOURCE_DIR + name);
+ if (stream == null) {
+ throw new IllegalStateException("Missing test resource: " + RESOURCE_DIR + name);
+ }
+ return stream;
+ }
+
+ private static boolean isTls13Supported() {
+ try {
+ SSLContext.getInstance("TLSv1.3");
+ return true;
+ } catch (Exception e) {
+ return false;
+ }
+ }
+
+ private static void restoreProperty(String key, String value) {
+ if (value == null) {
+ System.clearProperty(key);
+ } else {
+ System.setProperty(key, value);
+ }
+ }
+
+ /** Server-side trust manager: the test only checks that a client certificate is presented. */
+ private static final class AcceptAllTrustManager implements X509TrustManager {
+ @Override
+ public void checkClientTrusted(X509Certificate[] chain, String authType) {}
+
+ @Override
+ public void checkServerTrusted(X509Certificate[] chain, String authType) {}
+
+ @Override
+ public X509Certificate[] getAcceptedIssuers() {
+ return new X509Certificate[0];
+ }
+ }
+}
diff --git a/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/RefreshingHttpJsonChannelTest.java b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/RefreshingHttpJsonChannelTest.java
new file mode 100644
index 000000000000..74f462bf831a
--- /dev/null
+++ b/sdk-platform-java/gax-java/gax-httpjson/src/test/java/com/google/api/gax/httpjson/RefreshingHttpJsonChannelTest.java
@@ -0,0 +1,607 @@
+/*
+ * Copyright 2026 Google LLC
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are
+ * met:
+ *
+ * * Redistributions of source code must retain the above copyright
+ * notice, this list of conditions and the following disclaimer.
+ * * Redistributions in binary form must reproduce the above
+ * copyright notice, this list of conditions and the following disclaimer
+ * in the documentation and/or other materials provided with the
+ * distribution.
+ * * Neither the name of Google LLC nor the names of its
+ * contributors may be used to endorse or promote products derived from
+ * this software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+ * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+ * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+ * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+ * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+ * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+ * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+ * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ */
+package com.google.api.gax.httpjson;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotSame;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import com.google.api.client.http.HttpTransport;
+import com.google.api.client.testing.http.MockHttpTransport;
+import com.google.api.gax.httpjson.testing.MockHttpService;
+import com.google.protobuf.Field;
+import java.util.ArrayDeque;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Queue;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.function.Function;
+import java.util.function.Supplier;
+import org.jspecify.annotations.Nullable;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+class RefreshingHttpJsonChannelTest {
+ private static final ApiMethodDescriptor FAKE_METHOD_DESCRIPTOR =
+ ApiMethodDescriptor.newBuilder()
+ .setFullMethodName("google.cloud.v1.Fake/FakeMethod")
+ .setHttpMethod("POST")
+ .setRequestFormatter(
+ ProtoMessageRequestFormatter.newBuilder()
+ .setPath(
+ "/fake/v1/name/{name}",
+ request -> {
+ Map fields = new HashMap<>();
+ ProtoRestSerializer serializer = ProtoRestSerializer.create();
+ serializer.putPathParam(fields, "name", request.getName());
+ return fields;
+ })
+ .setQueryParamsExtractor(request -> new HashMap<>())
+ .setRequestBodyExtractor(
+ request ->
+ ProtoRestSerializer.create()
+ .toBody("*", request.toBuilder().clearName().build(), false))
+ .build())
+ .setResponseParser(
+ ProtoMessageResponseParser.newBuilder()
+ .setDefaultInstance(Field.getDefaultInstance())
+ .build())
+ .build();
+
+ private static class FakeHttpJsonClientCall
+ extends HttpJsonClientCall {
+ @Override
+ public void start(Listener responseListener, HttpJsonMetadata requestHeaders) {}
+
+ @Override
+ public void request(int numMessages) {}
+
+ @Override
+ public void cancel(@Nullable String message, @Nullable Throwable cause) {}
+
+ @Override
+ public void sendMessage(RequestT message) {}
+
+ @Override
+ public void halfClose() {}
+ }
+
+ private static class FakeManagedHttpJsonChannel extends ManagedHttpJsonChannel {
+ private volatile boolean isShutdown = false;
+ private volatile boolean isTerminated = false;
+ private HttpJsonClientCall, ?> nextCall = null;
+
+ @Override
+ String getEndpoint() {
+ return "https://fake.endpoint:443";
+ }
+
+ @Override
+ public void shutdown() {
+ isShutdown = true;
+ }
+
+ @Override
+ public void shutdownNow() {
+ isShutdown = true;
+ isTerminated = true;
+ }
+
+ @Override
+ public boolean isShutdown() {
+ return isShutdown;
+ }
+
+ @Override
+ public boolean isTerminated() {
+ return isTerminated;
+ }
+
+ @Override
+ public boolean awaitTermination(long duration, TimeUnit unit) {
+ return isTerminated;
+ }
+
+ @Override
+ @SuppressWarnings("unchecked")
+ public HttpJsonClientCall newCall(
+ ApiMethodDescriptor methodDescriptor,
+ HttpJsonCallOptions callOptions) {
+ if (nextCall != null) {
+ return (HttpJsonClientCall) nextCall;
+ }
+ return new FakeHttpJsonClientCall<>();
+ }
+ }
+
+ private AtomicInteger transportFactoryCount;
+ private HttpTransport lastCreatedTransport;
+ private FakeManagedHttpJsonChannel lastCreatedChannel;
+ private String testCertPath;
+ private String testFingerprint;
+ private boolean shouldThrowOnFactory;
+ private List createdChannels;
+
+ private Supplier transportFactory =
+ () -> {
+ if (shouldThrowOnFactory) {
+ throw new RuntimeException("Simulated factory failure");
+ }
+ transportFactoryCount.incrementAndGet();
+ lastCreatedTransport = new MockHttpTransport();
+ return lastCreatedTransport;
+ };
+
+ private Function channelFactory =
+ transport -> {
+ lastCreatedChannel = new FakeManagedHttpJsonChannel();
+ lastCreatedChannel.setHttpTransport(transport);
+ return lastCreatedChannel;
+ };
+
+ @BeforeEach
+ void setUp() {
+ transportFactoryCount = new AtomicInteger(0);
+ testCertPath = "/fake/path";
+ testFingerprint = "fingerprint1";
+ shouldThrowOnFactory = false;
+ createdChannels = new ArrayList<>();
+ }
+
+ @AfterEach
+ void tearDown() {
+ for (RefreshingHttpJsonChannel channel : createdChannels) {
+ channel.shutdownNow();
+ }
+ }
+
+ private RefreshingHttpJsonChannel createTestChannel() {
+ RefreshingHttpJsonChannel ch =
+ new RefreshingHttpJsonChannel(
+ () -> transportFactory.get(),
+ transport -> channelFactory.apply(transport),
+ "fake/cert/path.json") {
+ @Override
+ String getWorkloadCertPath() {
+ return testCertPath;
+ }
+
+ @Override
+ String getCertificateFingerprint(String certPath) {
+ return testFingerprint;
+ }
+ };
+ createdChannels.add(ch);
+ return ch;
+ }
+
+ private void rotateCertificate(RefreshingHttpJsonChannel channel) {
+ channel.invalidateDiskFingerprintCache();
+ testFingerprint = "fingerprint2";
+ }
+
+ @Test
+ void testShouldRefreshFalseWhenCertPathNull() {
+ testCertPath = null;
+ RefreshingHttpJsonChannel channel = createTestChannel();
+ assertFalse(channel.shouldRefresh());
+ }
+
+ @Test
+ void testShouldRefreshFalseWhenUnchanged() {
+ RefreshingHttpJsonChannel channel = createTestChannel();
+
+ channel.invalidateDiskFingerprintCache(); // Invalidate 1-second cache
+ assertFalse(channel.shouldRefresh());
+ }
+
+ @Test
+ void testShouldRefreshTrueWhenChanged() {
+ RefreshingHttpJsonChannel channel = createTestChannel();
+
+ rotateCertificate(channel);
+
+ assertTrue(channel.shouldRefresh());
+ }
+
+ @Test
+ void shouldRefresh_doesNotCacheNegativeResultAndDetectsSubsequentRotationImmediately() {
+ RefreshingHttpJsonChannel channel = createTestChannel();
+
+ // First check returns false (unchanged fingerprint)
+ assertFalse(channel.shouldRefresh());
+
+ // Immediately change fingerprint WITHOUT invalidating cache
+ testFingerprint = "fingerprint2";
+
+ // Must immediately detect rotation because negative/unchanged checks are not cached for 1s
+ assertTrue(channel.shouldRefresh());
+
+ // Refresh updates activeCertFingerprint and clears cache
+ channel.refresh();
+ assertFalse(channel.shouldRefresh());
+ }
+
+ @Test
+ void rotationDuringInitialTransportCreation_isDetectedAndRefreshed() {
+ transportFactory =
+ () -> {
+ if (transportFactoryCount.incrementAndGet() == 1) {
+ // Simulate the certificate rotating on disk while the initial transport loads it.
+ testFingerprint = "fingerprint2";
+ }
+ lastCreatedTransport = new MockHttpTransport();
+ return lastCreatedTransport;
+ };
+
+ RefreshingHttpJsonChannel channel = createTestChannel();
+
+ // The baseline was recorded before the initial transport was created, so the rotation is seen.
+ assertTrue(channel.shouldRefresh());
+
+ channel.refresh();
+ assertEquals(2, transportFactoryCount.get());
+ assertEquals(1, channel.getGeneration());
+ assertFalse(channel.shouldRefresh());
+ }
+
+ @Test
+ void refresh_swapsTransportAndKeepsChannel() {
+ RefreshingHttpJsonChannel channel = createTestChannel();
+ FakeManagedHttpJsonChannel underlyingChannel = lastCreatedChannel;
+ HttpTransport initialTransport = channel.getHttpTransport();
+ assertSame(lastCreatedTransport, initialTransport);
+
+ rotateCertificate(channel);
+ channel.refresh();
+
+ assertEquals(2, transportFactoryCount.get());
+ assertNotSame(initialTransport, channel.getHttpTransport());
+ assertSame(lastCreatedTransport, channel.getHttpTransport());
+ // The underlying channel (and its executors) is reused rather than replaced or shut down.
+ assertSame(underlyingChannel, lastCreatedChannel);
+ assertFalse(underlyingChannel.isShutdown());
+ assertEquals(1, channel.getGeneration());
+ assertFalse(channel.shouldRefresh());
+ }
+
+ @Test
+ void refresh_whenCertificateFileEmpty_keepsTransportAndGeneration() {
+ RefreshingHttpJsonChannel channel = createTestChannel();
+ HttpTransport initialTransport = channel.getHttpTransport();
+
+ // A rotation is detected, but when refresh() re-reads the file the rotator has truncated it
+ // and not yet written the new certificate.
+ rotateCertificate(channel);
+ assertTrue(channel.shouldRefresh());
+ testFingerprint = "";
+ channel.refresh();
+
+ // Nothing is swapped and the generation is unchanged, so the failed call is not retried.
+ assertEquals(1, transportFactoryCount.get());
+ assertSame(initialTransport, channel.getHttpTransport());
+ assertEquals(0, channel.getGeneration());
+ // While the file stays empty, no refresh is requested.
+ channel.invalidateDiskFingerprintCache();
+ assertFalse(channel.shouldRefresh());
+
+ // Once the new certificate is written, the next check refreshes normally.
+ testFingerprint = "fingerprint2";
+ assertTrue(channel.shouldRefresh());
+ channel.refresh();
+ assertEquals(2, transportFactoryCount.get());
+ assertNotSame(initialTransport, channel.getHttpTransport());
+ assertEquals(1, channel.getGeneration());
+ }
+
+ @Test
+ void callCreatedBeforeRefresh_usesOriginalTransport() throws Exception {
+ MockHttpService originalService =
+ new MockHttpService(Collections.singletonList(FAKE_METHOD_DESCRIPTOR), "google.com:443");
+ MockHttpService rotatedService =
+ new MockHttpService(Collections.singletonList(FAKE_METHOD_DESCRIPTOR), "google.com:443");
+ Field message = Field.newBuilder().setName("bob").setNumber(1).build();
+ originalService.addResponse(message);
+ rotatedService.addResponse(message);
+ Queue transports =
+ new ArrayDeque<>(Arrays.asList(originalService, rotatedService));
+ transportFactory = transports::remove;
+ channelFactory =
+ transport ->
+ ManagedHttpJsonChannel.newBuilder()
+ .setEndpoint("google.com:443")
+ .setHttpTransport(transport)
+ .build();
+ RefreshingHttpJsonChannel channel = createTestChannel();
+ HttpJsonCallOptions callOptions = HttpJsonCallOptions.newBuilder().build();
+ HttpJsonCallContext callContext = HttpJsonCallContext.createDefault();
+
+ HttpJsonClientCall callBeforeRefresh =
+ channel.newCall(FAKE_METHOD_DESCRIPTOR, callOptions);
+
+ rotateCertificate(channel);
+ channel.refresh();
+ assertEquals(1, channel.getGeneration());
+
+ assertEquals(
+ message,
+ HttpJsonClientCalls.futureUnaryCall(callBeforeRefresh, message, callContext)
+ .get(10, TimeUnit.SECONDS));
+ assertEquals(1, originalService.getRequestPaths().size());
+ assertEquals(0, rotatedService.getRequestPaths().size());
+
+ HttpJsonClientCall callAfterRefresh =
+ channel.newCall(FAKE_METHOD_DESCRIPTOR, callOptions);
+ assertEquals(
+ message,
+ HttpJsonClientCalls.futureUnaryCall(callAfterRefresh, message, callContext)
+ .get(10, TimeUnit.SECONDS));
+ assertEquals(1, originalService.getRequestPaths().size());
+ assertEquals(1, rotatedService.getRequestPaths().size());
+ }
+
+ @Test
+ void testRefreshDoesNotCreateTransportWhenShutdown() {
+ RefreshingHttpJsonChannel channel = createTestChannel();
+ assertEquals(1, transportFactoryCount.get());
+
+ channel.shutdown();
+ rotateCertificate(channel);
+ channel.refresh();
+
+ assertEquals(1, transportFactoryCount.get());
+ assertEquals(0, channel.getGeneration());
+ }
+
+ @Test
+ void shutdown_waitsForInProgressRefresh() throws Exception {
+ CountDownLatch refreshStarted = new CountDownLatch(1);
+ CountDownLatch releaseRefresh = new CountDownLatch(1);
+ transportFactory =
+ () -> {
+ if (transportFactoryCount.incrementAndGet() > 1) {
+ refreshStarted.countDown();
+ try {
+ releaseRefresh.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ return new MockHttpTransport();
+ };
+ RefreshingHttpJsonChannel channel = createTestChannel();
+ FakeManagedHttpJsonChannel underlyingChannel = lastCreatedChannel;
+ rotateCertificate(channel);
+
+ Thread refreshThread = new Thread(channel::refresh);
+ Thread shutdownThread = new Thread(channel::shutdown);
+ try {
+ refreshThread.start();
+ assertTrue(refreshStarted.await(5, TimeUnit.SECONDS));
+ shutdownThread.start();
+
+ shutdownThread.join(200);
+ assertTrue(shutdownThread.isAlive());
+ assertFalse(underlyingChannel.isShutdown());
+ } finally {
+ releaseRefresh.countDown();
+ }
+ refreshThread.join(5000);
+ shutdownThread.join(5000);
+ assertFalse(refreshThread.isAlive());
+ assertFalse(shutdownThread.isAlive());
+ assertEquals(1, channel.getGeneration());
+ assertTrue(underlyingChannel.isShutdown());
+ }
+
+ @Test
+ void refresh_concurrentRefreshWhileWaitingForLock_skipsWithoutReadingDisk() throws Exception {
+ CountDownLatch refreshStarted = new CountDownLatch(1);
+ CountDownLatch releaseRefresh = new CountDownLatch(1);
+ transportFactory =
+ () -> {
+ if (transportFactoryCount.incrementAndGet() == 2) {
+ refreshStarted.countDown();
+ try {
+ releaseRefresh.await();
+ } catch (InterruptedException e) {
+ Thread.currentThread().interrupt();
+ }
+ }
+ return new MockHttpTransport();
+ };
+ RefreshingHttpJsonChannel channel = createTestChannel();
+ rotateCertificate(channel);
+
+ Thread refreshingThread = new Thread(channel::refresh);
+ Thread waitingThread = new Thread(channel::refresh);
+ try {
+ refreshingThread.start();
+ assertTrue(refreshStarted.await(5, TimeUnit.SECONDS));
+ waitingThread.start();
+ long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(5);
+ while (waitingThread.getState() != Thread.State.BLOCKED) {
+ assertTrue(System.nanoTime() < deadline);
+ Thread.sleep(1);
+ }
+ // Rotate again before the first refresh completes. A waiter that re-read the certificate
+ // would see this fingerprint and refresh a second time.
+ testFingerprint = "fingerprint3";
+ } finally {
+ releaseRefresh.countDown();
+ }
+ refreshingThread.join(5000);
+ waitingThread.join(5000);
+ assertFalse(refreshingThread.isAlive());
+ assertFalse(waitingThread.isAlive());
+
+ // Only the initial transport and the concurrent refresh created transports; the waiter skipped.
+ assertEquals(2, transportFactoryCount.get());
+ assertEquals(1, channel.getGeneration());
+ // The second rotation is still detected for the next UNAUTHENTICATED failure.
+ channel.invalidateDiskFingerprintCache();
+ assertTrue(channel.shouldRefresh());
+ }
+
+ @Test
+ void testRefreshFactoryExceptionDoesNotWedgeFingerprint() {
+ RefreshingHttpJsonChannel channel = createTestChannel();
+ HttpTransport initialTransport = channel.getHttpTransport();
+ assertEquals(1, transportFactoryCount.get());
+
+ shouldThrowOnFactory = true;
+ rotateCertificate(channel);
+
+ // Factory failure is logged and the existing transport is kept
+ channel.refresh();
+ assertEquals(1, transportFactoryCount.get());
+ assertEquals(0, channel.getGeneration());
+ assertSame(initialTransport, channel.getHttpTransport());
+
+ // Because the factory threw, the new fingerprint is not recorded as active, so the channel
+ // still reports that it should be refreshed.
+ assertTrue(channel.shouldRefresh());
+
+ shouldThrowOnFactory = false;
+ channel.refresh();
+ assertEquals(2, transportFactoryCount.get());
+ assertFalse(channel.shouldRefresh());
+ }
+
+ @Test
+ void testShutdownNowSetsIsShutdown() {
+ RefreshingHttpJsonChannel channel = createTestChannel();
+ assertFalse(channel.isShutdown());
+
+ channel.shutdownNow();
+
+ assertTrue(channel.isShutdown());
+ }
+
+ @Test
+ void testAwaitTerminationZeroTimeoutOnTerminatedChannelReturnsTrue() throws InterruptedException {
+ RefreshingHttpJsonChannel channel = createTestChannel();
+ lastCreatedChannel.isTerminated = true;
+
+ channel.shutdown();
+ assertTrue(channel.awaitTermination(0, TimeUnit.MILLISECONDS));
+ }
+
+ @Test
+ void testChannelDelegationMethods() {
+ RefreshingHttpJsonChannel channel = createTestChannel();
+ FakeManagedHttpJsonChannel underlyingChannel = lastCreatedChannel;
+ FakeHttpJsonClientCall