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..a279c66d0a25 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.createForTransport(); 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..4d48b2e4ec7a --- /dev/null +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsKeyStoreUtils.java @@ -0,0 +1,151 @@ +/* + * 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.CertificateException; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.security.spec.PKCS8EncodedKeySpec; +import java.util.ArrayList; +import java.util.List; +import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; + +/** + * 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 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. + * + *
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 or unreadable later 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 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 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 that point 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 before a certificate and a private key are
+ * found
+ */
+ static KeyStore createMtlsKeyStore(InputStream certAndKey)
+ throws GeneralSecurityException, IOException {
+ List When {@link #GOOGLE_API_CERTIFICATE_CONFIG} is unset and no well-known gcloud certificate
+ * configuration file exists, the presence of this bundle establishes mTLS intent 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";
+
// Retries for verifying certificate and private key matching during atomic key rotation.
private static final int CERT_KEY_MATCH_RETRIES = 3;
@@ -117,6 +132,8 @@ final class AgentIdentityUtils {
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
@@ -281,6 +298,49 @@ private static boolean isPathInWellKnownDir(final @Nullable String pathStr) {
}
}
+ /** Returns the path of the GKE credential bundle in the well-known directory. */
+ private static String getGkeCredentialBundlePath() {
+ return Paths.get(wellKnownDir, GKE_CREDENTIAL_BUNDLE_FILE).toString();
+ }
+
+ /** Returns {@code true} if {@code path} is the GKE credential bundle in the well-known dir. */
+ private static boolean isGkeCredentialBundle(final @Nullable String path) {
+ return path != null && getGkeCredentialBundlePath().equals(path);
+ }
+
+ /**
+ * Returns {@code true} if the GKE credential bundle is a readable, non-empty regular file.
+ * Mirrors the readiness check the transport uses (see {@link MtlsUtils#getWorkloadCertPath}).
+ */
+ private static boolean isGkeCredentialBundleReady(final String path) {
+ Path bundle = Paths.get(path);
+ try {
+ return Files.isRegularFile(bundle) && Files.isReadable(bundle) && Files.size(bundle) > 0;
+ } catch (IOException e) {
+ return false;
+ }
+ }
+
+ /**
+ * Returns {@code true} if the GKE credential bundle may be used: no well-known gcloud certificate
+ * configuration file exists (it would otherwise be the transport's mTLS certificate source, see
+ * {@link MtlsUtils#hasCertificateConfiguration}). Callers only reach this when {@link
+ * #GOOGLE_API_CERTIFICATE_CONFIG} is unset.
+ */
+ private static boolean isGkeCredentialBundleAllowed() {
+ return !MtlsUtils.hasCertificateConfiguration(environmentProvider, propertyProvider);
+ }
+
+ /**
+ * Returns whether mTLS intent is established for resolved certificate paths: either they came
+ * from a workload certificate config, or they point at the GKE credential bundle (whose presence
+ * establishes intent on its own).
+ */
+ private static boolean isMtlsIntentEstablished(
+ final boolean hasWorkloadConfig, final @Nullable String certPath) {
+ return hasWorkloadConfig || isGkeCredentialBundle(certPath);
+ }
+
/**
* Retrieves the certificate and raw PEM content for the Agent Identity.
*
@@ -319,14 +379,16 @@ private static boolean isPathInWellKnownDir(final @Nullable String pathStr) {
// isCachedInfoValid() verified that the cached certificate file still exists on disk with
// matching metadata, so certsPresent is guaranteed to be true here.
if (!shouldEnableMtls(
- /* certsPresent= */ true, /* configExists= */ initialCached.configMetadata != null)) {
+ /* certsPresent= */ true,
+ /* configExists= */ isMtlsIntentEstablished(
+ initialCached.configMetadata != null, initialCached.certMetadata.getPath()))) {
return null;
}
return initialCached.certInfo;
}
ResolvedCertAndKeyPaths paths = resolveCertAndKeyPaths(certConfigPath, initialCached);
- boolean configExists = paths.hasWorkloadConfig();
+ boolean configExists = isMtlsIntentEstablished(paths.hasWorkloadConfig(), paths.getCertPath());
boolean certsPresent = !Strings.isNullOrEmpty(paths.getCertPath());
if (!shouldEnableMtls(certsPresent, configExists)) {
@@ -654,9 +716,23 @@ private static ResolvedCertAndKeyPaths getWellKnownCertificatePathWithRetry(
final @Nullable CachedAgentIdentityInfo initialCached) throws IOException {
if (!isMtlsExplicitlyEnabled()) {
// Without a config file (configExists == false), mTLS is only enabled when
- // GOOGLE_API_USE_CLIENT_CERTIFICATE is explicitly "true".
+ // GOOGLE_API_USE_CLIENT_CERTIFICATE is explicitly "true", or, when it is unset, implicitly by
+ // the presence of the GKE credential bundle.
initialStartupCompleted = true;
- return new ResolvedCertAndKeyPaths(null, null, false);
+ if (isMtlsExplicitlyDisabled()) {
+ return new ResolvedCertAndKeyPaths(null, null, false);
+ }
+ return getGkeCredentialBundlePathIfPresent(initialCached);
+ }
+
+ // The GKE bundle is checked once, without polling, and before the other well-known files: when
+ // allowed, it is the certificate the transport presents for mTLS (see
+ // MtlsUtils#getWorkloadCertPath).
+ String gkeBundlePath = getGkeCredentialBundlePath();
+ if (isGkeCredentialBundleAllowed() && isGkeCredentialBundleReady(gkeBundlePath)) {
+ initialStartupCompleted = true;
+ // GKE combined bundle file contains both certificate chain and private key
+ return new ResolvedCertAndKeyPaths(gkeBundlePath, gkeBundlePath, false);
}
String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString();
@@ -717,7 +793,11 @@ private static ResolvedCertAndKeyPaths getWellKnownCertificatePathWithRetry(
if (fallbackCached != null
&& fallbackCached.configMetadata == null
&& fallbackCached.certMetadata != null
- && fallbackCached.keyMetadata != null) {
+ && fallbackCached.keyMetadata != null
+ // A cached GKE credential is only reused while the GKE bundle is still allowed (no
+ // certificate configuration exists), matching the certificate the transport presents.
+ && (!isGkeCredentialBundle(fallbackCached.certMetadata.getPath())
+ || isGkeCredentialBundleAllowed())) {
return new ResolvedCertAndKeyPaths(
fallbackCached.certMetadata.getPath(), fallbackCached.keyMetadata.getPath(), false);
}
@@ -730,6 +810,42 @@ private static ResolvedCertAndKeyPaths getWellKnownCertificatePathWithRetry(
wellKnownDir, GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN));
}
+ /**
+ * Implicit GKE discovery used when {@link #GOOGLE_API_CERTIFICATE_CONFIG} and {@link
+ * #GOOGLE_API_USE_CLIENT_CERTIFICATE} are both unset: returns the GKE credential bundle as both
+ * certificate and key if it is a readable, non-empty file and no well-known gcloud certificate
+ * configuration file exists. Checked once without polling (see {@link
+ * #GKE_CREDENTIAL_BUNDLE_FILE}).
+ *
+ * The readability and precedence rules mirror {@link MtlsUtils#getWorkloadCertPath}, so tokens
+ * are only bound to the GKE certificate when the transport also uses it for mTLS. If the bundle
+ * is transiently unavailable (e.g. mid-rotation) after a GKE credential was cached, the cached
+ * paths are returned so {@link #loadAndVerifyCredentials} can fall back to the cached credential.
+ */
+ private static ResolvedCertAndKeyPaths getGkeCredentialBundlePathIfPresent(
+ final @Nullable CachedAgentIdentityInfo initialCached) {
+ // Checked first so that neither a fresh nor a cached GKE credential is used once a certificate
+ // configuration exists (the transport would then present that configuration's certificate).
+ if (!isGkeCredentialBundleAllowed()) {
+ return new ResolvedCertAndKeyPaths(null, null, false);
+ }
+ String gkeBundlePath = getGkeCredentialBundlePath();
+ if (isGkeCredentialBundleReady(gkeBundlePath)) {
+ return new ResolvedCertAndKeyPaths(gkeBundlePath, gkeBundlePath, false);
+ }
+ CachedAgentIdentityInfo fallbackCached =
+ AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached);
+ if (fallbackCached != null
+ && fallbackCached.configMetadata == null
+ && fallbackCached.certMetadata != null
+ && fallbackCached.keyMetadata != null
+ && isGkeCredentialBundle(fallbackCached.certMetadata.getPath())) {
+ 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.
*
@@ -738,6 +854,9 @@ private static ResolvedCertAndKeyPaths getWellKnownCertificatePathWithRetry(
* 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 establishes mTLS intent on its own.
*/
static boolean shouldEnableMtls(final boolean certsPresent, final boolean configExists)
throws IOException {
@@ -837,6 +956,7 @@ private static ResolvedCertAndKeyPaths extractPathsFromConfig(final String certC
static void resetForTest() {
wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/";
environmentProvider = SystemEnvironmentProvider.getInstance();
+ propertyProvider = SystemPropertyProvider.getInstance();
timeService = Thread::sleep;
resetCachedState();
}
@@ -867,6 +987,16 @@ static void setEnvironmentProvider(final 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) {
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsKeyStoreUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsKeyStoreUtilsTest.java
new file mode 100644
index 000000000000..935d1ee8bcc8
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsKeyStoreUtilsTest.java
@@ -0,0 +1,259 @@
+/*
+ * 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 static java.nio.charset.StandardCharsets.UTF_8;
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.io.ByteArrayInputStream;
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.security.GeneralSecurityException;
+import java.security.KeyStore;
+import java.security.PrivateKey;
+import java.security.Signature;
+import java.security.cert.Certificate;
+import java.security.cert.CertificateException;
+import java.security.cert.X509Certificate;
+import java.util.Collections;
+import org.junit.jupiter.api.Test;
+
+class MtlsKeyStoreUtilsTest {
+
+ // Leaf (CN=Test Leaf) followed by the intermediate that signed it (CN=Test Intermediate CA).
+ static final String CHAIN_CERT_PATH = "testresources/mtls/test_chain_cert.pem";
+ // PKCS#8 private key for the leaf in CHAIN_CERT_PATH.
+ static final String CHAIN_KEY_PATH = "testresources/mtls/test_chain_key.pem";
+ private static final String SINGLE_CERT_PATH = "testresources/mtls/test_cert.pem";
+ private static final String SINGLE_KEY_PATH = "testresources/mtls/test_key.pem";
+
+ @Test
+ void createMtlsKeyStore_certificatesFirst_keepsFullChainInOrder() throws Exception {
+ KeyStore keyStore =
+ MtlsKeyStoreUtils.createMtlsKeyStore(stream(read(CHAIN_CERT_PATH), read(CHAIN_KEY_PATH)));
+
+ assertLeafThenIntermediate(keyStore);
+ }
+
+ @Test
+ void createMtlsKeyStore_keyFirst_keepsFullChainInOrder() throws Exception {
+ KeyStore keyStore =
+ MtlsKeyStoreUtils.createMtlsKeyStore(stream(read(CHAIN_KEY_PATH), read(CHAIN_CERT_PATH)));
+
+ assertLeafThenIntermediate(keyStore);
+ }
+
+ @Test
+ void upstreamCreateMtlsKeyStore_keepsOnlyLeaf() throws Exception {
+ // Documents why MtlsKeyStoreUtils exists. If google-http-client starts keeping the full chain,
+ // this fails and the helper can be replaced by the upstream method again.
+ KeyStore keyStore =
+ com.google.api.client.util.SecurityUtils.createMtlsKeyStore(
+ stream(read(CHAIN_CERT_PATH), read(CHAIN_KEY_PATH)));
+
+ assertEquals(1, keyStore.getCertificateChain(onlyAlias(keyStore)).length);
+ }
+
+ @Test
+ void createMtlsKeyStore_privateKeyMatchesLeaf() throws Exception {
+ KeyStore keyStore =
+ MtlsKeyStoreUtils.createMtlsKeyStore(stream(read(CHAIN_CERT_PATH), read(CHAIN_KEY_PATH)));
+ String alias = onlyAlias(keyStore);
+ PrivateKey key = (PrivateKey) keyStore.getKey(alias, new char[] {});
+ Certificate leaf = keyStore.getCertificateChain(alias)[0];
+
+ byte[] data = "chain".getBytes(UTF_8);
+ Signature signer = Signature.getInstance("SHA256withECDSA");
+ signer.initSign(key);
+ signer.update(data);
+ byte[] signature = signer.sign();
+ Signature verifier = Signature.getInstance("SHA256withECDSA");
+ verifier.initVerify(leaf.getPublicKey());
+ verifier.update(data);
+ assertTrue(verifier.verify(signature));
+ }
+
+ @Test
+ void createMtlsKeyStore_singleCertificate_sameAsBefore() throws Exception {
+ KeyStore keyStore =
+ MtlsKeyStoreUtils.createMtlsKeyStore(stream(read(SINGLE_CERT_PATH), read(SINGLE_KEY_PATH)));
+ KeyStore expected =
+ com.google.api.client.util.SecurityUtils.createMtlsKeyStore(
+ stream(read(SINGLE_CERT_PATH), read(SINGLE_KEY_PATH)));
+
+ String alias = onlyAlias(keyStore);
+ assertEquals(Collections.list(expected.aliases()), Collections.list(keyStore.aliases()));
+ assertEquals(expected.getType(), keyStore.getType());
+ Certificate[] chain = keyStore.getCertificateChain(alias);
+ assertEquals(1, chain.length);
+ assertArrayEquals(expected.getCertificate(alias).getEncoded(), chain[0].getEncoded());
+ assertArrayEquals(
+ expected.getKey(alias, new char[] {}).getEncoded(),
+ keyStore.getKey(alias, new char[] {}).getEncoded());
+ }
+
+ @Test
+ void createMtlsKeyStore_ignoresOtherSections() throws Exception {
+ String other = "-----BEGIN EC PARAMETERS-----\nBggqhkjOPQMBBw==\n-----END EC PARAMETERS-----\n";
+ KeyStore keyStore =
+ MtlsKeyStoreUtils.createMtlsKeyStore(
+ stream(other, read(CHAIN_CERT_PATH), other, read(CHAIN_KEY_PATH)));
+
+ assertEquals(2, keyStore.getCertificateChain(onlyAlias(keyStore)).length);
+ }
+
+ @Test
+ void createMtlsKeyStore_keysBeforeCertificate_usesLastKeyLikeUpstream() throws Exception {
+ // RSA key, then the leaf's EC key, then the certificates: the key just before the first
+ // certificate is used.
+ String input =
+ String.join("\n", read(SINGLE_KEY_PATH), read(CHAIN_KEY_PATH), read(CHAIN_CERT_PATH));
+
+ KeyStore keyStore = MtlsKeyStoreUtils.createMtlsKeyStore(stream(input));
+ KeyStore upstream = com.google.api.client.util.SecurityUtils.createMtlsKeyStore(stream(input));
+
+ assertEquals("EC", keyStore.getKey(onlyAlias(keyStore), new char[] {}).getAlgorithm());
+ assertEquals("EC", upstream.getKey(onlyAlias(upstream), new char[] {}).getAlgorithm());
+ assertLeafThenIntermediate(keyStore);
+ }
+
+ @Test
+ void createMtlsKeyStore_keysBeforeCertificate_lastKeyDoesNotMatch_throwsLikeUpstream()
+ throws Exception {
+ // The leaf's EC key, then an RSA key, then the certificates: the RSA key is chosen and cannot
+ // be read as an EC key, in both implementations.
+ String input =
+ String.join("\n", read(CHAIN_KEY_PATH), read(SINGLE_KEY_PATH), read(CHAIN_CERT_PATH));
+
+ assertThrows(
+ GeneralSecurityException.class, () -> MtlsKeyStoreUtils.createMtlsKeyStore(stream(input)));
+ assertThrows(
+ GeneralSecurityException.class,
+ () -> com.google.api.client.util.SecurityUtils.createMtlsKeyStore(stream(input)));
+ }
+
+ @Test
+ void createMtlsKeyStore_keysAfterCertificate_usesFirstKeyLikeUpstream() throws Exception {
+ String input =
+ String.join("\n", read(CHAIN_CERT_PATH), read(CHAIN_KEY_PATH), read(SINGLE_KEY_PATH));
+
+ KeyStore keyStore = MtlsKeyStoreUtils.createMtlsKeyStore(stream(input));
+ KeyStore upstream = com.google.api.client.util.SecurityUtils.createMtlsKeyStore(stream(input));
+
+ assertEquals("EC", keyStore.getKey(onlyAlias(keyStore), new char[] {}).getAlgorithm());
+ assertEquals("EC", upstream.getKey(onlyAlias(upstream), new char[] {}).getAlgorithm());
+ assertLeafThenIntermediate(keyStore);
+ }
+
+ @Test
+ void createMtlsKeyStore_malformedTrailingSection_ignored() throws Exception {
+ // A section with no end tag after a usable certificate and key. The upstream method stopped
+ // reading before reaching it, so it must not cause a failure now.
+ KeyStore keyStore =
+ MtlsKeyStoreUtils.createMtlsKeyStore(
+ stream(
+ read(CHAIN_CERT_PATH), read(CHAIN_KEY_PATH), "-----BEGIN CERTIFICATE-----\nAAAA"));
+
+ assertEquals(2, keyStore.getCertificateChain(onlyAlias(keyStore)).length);
+ }
+
+ @Test
+ void createMtlsKeyStore_unparsableExtraCertificate_leftOut() throws Exception {
+ String[] leafAndIntermediate =
+ read(CHAIN_CERT_PATH).split("(?<=-----END CERTIFICATE-----)\r?\n");
+ String bad = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----";
+ KeyStore keyStore =
+ MtlsKeyStoreUtils.createMtlsKeyStore(
+ stream(leafAndIntermediate[0], bad, leafAndIntermediate[1], read(CHAIN_KEY_PATH)));
+
+ assertLeafThenIntermediate(keyStore);
+ }
+
+ @Test
+ void createMtlsKeyStore_unparsableLeaf_throws() throws Exception {
+ String bad = "-----BEGIN CERTIFICATE-----\nAAAA\n-----END CERTIFICATE-----";
+ assertThrows(
+ CertificateException.class,
+ () ->
+ MtlsKeyStoreUtils.createMtlsKeyStore(
+ stream(bad, read(CHAIN_CERT_PATH), read(CHAIN_KEY_PATH))));
+ }
+
+ @Test
+ void createMtlsKeyStore_missingCertificate_throws() throws Exception {
+ IllegalArgumentException e =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> MtlsKeyStoreUtils.createMtlsKeyStore(stream(read(CHAIN_KEY_PATH))));
+ assertEquals("certificate is missing from certAndKey string", e.getMessage());
+ }
+
+ @Test
+ void createMtlsKeyStore_missingPrivateKey_throws() throws Exception {
+ IllegalArgumentException e =
+ assertThrows(
+ IllegalArgumentException.class,
+ () -> MtlsKeyStoreUtils.createMtlsKeyStore(stream(read(CHAIN_CERT_PATH))));
+ assertEquals("private key is missing from certAndKey string", e.getMessage());
+ }
+
+ static String read(String path) throws IOException {
+ return new String(Files.readAllBytes(new File(path).toPath()), UTF_8);
+ }
+
+ /** Asserts the key store's single entry holds the test leaf followed by its intermediate. */
+ static void assertLeafThenIntermediate(KeyStore keyStore) throws Exception {
+ Certificate[] chain = keyStore.getCertificateChain(onlyAlias(keyStore));
+ assertEquals(2, chain.length);
+ assertEquals("CN=Test Leaf", subject(chain[0]));
+ assertEquals("CN=Test Intermediate CA", subject(chain[1]));
+ }
+
+ private static ByteArrayInputStream stream(String... parts) {
+ return new ByteArrayInputStream(String.join("\n", parts).getBytes(UTF_8));
+ }
+
+ private static String onlyAlias(KeyStore keyStore) throws Exception {
+ assertEquals(1, keyStore.size());
+ String alias = keyStore.aliases().nextElement();
+ assertNotNull(keyStore.getCertificateChain(alias));
+ return alias;
+ }
+
+ private static String subject(Certificate certificate) {
+ return ((X509Certificate) certificate).getSubjectX500Principal().getName();
+ }
+}
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsUtilsTest.java
index d5fdc840d804..546dfbd2c220 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsUtilsTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/MtlsUtilsTest.java
@@ -39,6 +39,8 @@
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
@@ -46,6 +48,18 @@ class MtlsUtilsTest {
@TempDir Path tempDir;
+ @BeforeEach
+ void setUp() {
+ // Never consult the real GKE well-known path from tests.
+ MtlsUtils.setGkeCredentialBundlePathForTesting(
+ tempDir.resolve("no-gke-bundle").resolve("bundle.pem").toString());
+ }
+
+ @AfterEach
+ void tearDown() {
+ MtlsUtils.setGkeCredentialBundlePathForTesting(null);
+ }
+
@Test
void getCertificatePath_succeeds() throws IOException {
Path configFile = tempDir.resolve("config.json");
@@ -738,4 +752,124 @@ void getCertificateFingerprint_invalidOrNull_returnsNull() {
assertNull(MtlsUtils.getCertificateFingerprint("/nonexistent/file.crt"));
assertNull(MtlsUtils.getCertificateFingerprint(tempDir.toString())); // Directory
}
+
+ // --- GKE Credential Bundle Tests ---
+
+ private PropertyProvider homeInTempDir() {
+ return (name, def) -> {
+ if ("user.home".equals(name)) return tempDir.resolve("home").toString();
+ if ("os.name".equals(name)) return "Linux";
+ return def;
+ };
+ }
+
+ private Path writeGkeBundle() throws IOException {
+ Path bundle = tempDir.resolve("x509.credential-bundle.private-key.pem");
+ Files.write(bundle, "bundle".getBytes());
+ MtlsUtils.setGkeCredentialBundlePathForTesting(bundle.toString());
+ return bundle;
+ }
+
+ private void writeDefaultGcloudConfig(String content) throws IOException {
+ Path gcloudDir = tempDir.resolve("home").resolve(".config").resolve("gcloud");
+ Files.createDirectories(gcloudDir);
+ Files.write(gcloudDir.resolve("certificate_config.json"), content.getBytes());
+ }
+
+ @Test
+ void getWorkloadCertPath_noConfig_gkeBundlePresent_returnsBundlePath() throws IOException {
+ Path bundle = writeGkeBundle();
+ EnvironmentProvider envProvider = name -> null;
+
+ assertEquals(bundle.toString(), MtlsUtils.getWorkloadCertPath(envProvider, homeInTempDir()));
+ assertTrue(MtlsUtils.useMtlsClientCertificate(envProvider, homeInTempDir()));
+ }
+
+ @Test
+ void getWorkloadCertPath_noConfig_gkeBundleAbsent_returnsNull() {
+ MtlsUtils.setGkeCredentialBundlePathForTesting(tempDir.resolve("missing.pem").toString());
+ EnvironmentProvider envProvider = name -> null;
+
+ assertNull(MtlsUtils.getWorkloadCertPath(envProvider, homeInTempDir()));
+ assertFalse(MtlsUtils.useMtlsClientCertificate(envProvider, homeInTempDir()));
+ }
+
+ @Test
+ void getWorkloadCertPath_gkeBundleIsDirectory_returnsNull() throws IOException {
+ Path dir = Files.createDirectory(tempDir.resolve("x509.credential-bundle.private-key.pem"));
+ MtlsUtils.setGkeCredentialBundlePathForTesting(dir.toString());
+
+ assertNull(MtlsUtils.getWorkloadCertPath(name -> null, homeInTempDir()));
+ }
+
+ @Test
+ void getWorkloadCertPath_gkeBundleEmpty_returnsNull() throws IOException {
+ Path bundle = Files.createFile(tempDir.resolve("x509.credential-bundle.private-key.pem"));
+ MtlsUtils.setGkeCredentialBundlePathForTesting(bundle.toString());
+ EnvironmentProvider envProvider = name -> null;
+
+ assertNull(MtlsUtils.getWorkloadCertPath(envProvider, homeInTempDir()));
+ assertFalse(MtlsUtils.useMtlsClientCertificate(envProvider, homeInTempDir()));
+ assertNull(MtlsUtils.getGkeCredentialBundlePath(envProvider, homeInTempDir()));
+ }
+
+ @Test
+ void getWorkloadCertPath_useClientCertFalse_ignoresGkeBundle() throws IOException {
+ writeGkeBundle();
+ EnvironmentProvider envProvider =
+ name -> "GOOGLE_API_USE_CLIENT_CERTIFICATE".equals(name) ? "false" : null;
+
+ assertNull(MtlsUtils.getWorkloadCertPath(envProvider, homeInTempDir()));
+ assertFalse(MtlsUtils.useMtlsClientCertificate(envProvider, homeInTempDir()));
+ }
+
+ @Test
+ void getWorkloadCertPath_defaultConfigWithoutWorkload_ignoresGkeBundle() throws IOException {
+ writeGkeBundle();
+ writeDefaultGcloudConfig("{\"cert_configs\":{\"enterprise_certificates\":{\"libs\":[]}}}");
+
+ assertNull(MtlsUtils.getWorkloadCertPath(name -> null, homeInTempDir()));
+ }
+
+ @Test
+ void getWorkloadCertPath_explicitConfig_takesPrecedenceOverGkeBundle() throws IOException {
+ writeGkeBundle();
+ Path certFile = Files.write(tempDir.resolve("cert.pem"), "cert".getBytes());
+ Path keyFile = Files.write(tempDir.resolve("key.pem"), "key".getBytes());
+ Path configFile = tempDir.resolve("config.json");
+ Files.write(
+ configFile,
+ ("{\"cert_configs\":{\"workload\":{\"cert_path\":\""
+ + certFile
+ + "\",\"key_path\":\""
+ + keyFile
+ + "\"}}}")
+ .getBytes());
+ EnvironmentProvider envProvider =
+ name -> "GOOGLE_API_CERTIFICATE_CONFIG".equals(name) ? configFile.toString() : null;
+
+ assertEquals(certFile.toString(), MtlsUtils.getWorkloadCertPath(envProvider, homeInTempDir()));
+ }
+
+ @Test
+ void hasCertificateConfiguration_reflectsExplicitEnvAndDefaultFile() throws IOException {
+ assertFalse(MtlsUtils.hasCertificateConfiguration(name -> null, homeInTempDir()));
+ assertTrue(
+ MtlsUtils.hasCertificateConfiguration(
+ name -> "GOOGLE_API_CERTIFICATE_CONFIG".equals(name) ? "/any/path.json" : null,
+ homeInTempDir()));
+
+ writeDefaultGcloudConfig("{}");
+ assertTrue(MtlsUtils.hasCertificateConfiguration(name -> null, homeInTempDir()));
+ }
+
+ @Test
+ void getGkeCredentialBundlePath_onlyWhenNoConfiguration() throws IOException {
+ Path bundle = writeGkeBundle();
+ assertEquals(
+ bundle.toString(), MtlsUtils.getGkeCredentialBundlePath(name -> null, homeInTempDir()));
+
+ writeDefaultGcloudConfig("{}");
+ assertNull(MtlsUtils.getGkeCredentialBundlePath(name -> null, homeInTempDir()));
+ }
}
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/SecureConnectProviderTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/SecureConnectProviderTest.java
index fab805fa9520..0e56da9c7172 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/SecureConnectProviderTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/SecureConnectProviderTest.java
@@ -35,9 +35,12 @@
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
+import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
+import java.nio.charset.StandardCharsets;
+import java.security.KeyStore;
import java.util.List;
import org.junit.jupiter.api.Test;
@@ -47,10 +50,16 @@ private static class TestCertProviderCommandProcess extends Process {
private final boolean runForever;
private final int exitValue;
+ private final InputStream stdout;
public TestCertProviderCommandProcess(int exitValue, boolean runForever) {
+ this(exitValue, runForever, null);
+ }
+
+ TestCertProviderCommandProcess(int exitValue, boolean runForever, InputStream stdout) {
this.runForever = runForever;
this.exitValue = exitValue;
+ this.stdout = stdout;
}
@Override
@@ -60,7 +69,7 @@ public OutputStream getOutputStream() {
@Override
public InputStream getInputStream() {
- return null;
+ return stdout;
}
@Override
@@ -101,6 +110,23 @@ public Process createProcess(InputStream metadata) throws IOException {
}
}
+ @Test
+ void testGetKeyStore_certificateChain_keepsFullChain() throws Exception {
+ byte[] stdout =
+ (MtlsKeyStoreUtilsTest.read(MtlsKeyStoreUtilsTest.CHAIN_CERT_PATH)
+ + "\n"
+ + MtlsKeyStoreUtilsTest.read(MtlsKeyStoreUtilsTest.CHAIN_KEY_PATH))
+ .getBytes(StandardCharsets.UTF_8);
+ SecureConnectProvider.ProcessProvider processProvider =
+ metadata -> new TestCertProviderCommandProcess(0, false, new ByteArrayInputStream(stdout));
+
+ KeyStore store =
+ SecureConnectProvider.getKeyStore(
+ new ByteArrayInputStream("{}".getBytes(StandardCharsets.UTF_8)), processProvider);
+
+ MtlsKeyStoreUtilsTest.assertLeafThenIntermediate(store);
+ }
+
@Test
void testGetKeyStoreNonZeroExitCode() {
try (InputStream metadata =
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java
index 5ddd1a169d29..03674501cac4 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/mtls/X509ProviderTest.java
@@ -31,7 +31,9 @@
package com.google.auth.mtls;
+import static java.nio.charset.StandardCharsets.UTF_8;
import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
@@ -50,10 +52,12 @@
import java.security.cert.CertificateException;
import java.security.cert.CertificateFactory;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
class X509ProviderTest {
private static final String TEST_CERT_PATH = "testresources/mtls/test_cert.pem";
+ private static final String TEST_KEY_PATH = "testresources/mtls/test_key.pem";
private static final String TEST_CONFIG_PATH = "testresources/mtls/certificate_config.json";
@Test
@@ -208,4 +212,199 @@ void x509Provider_malformedCert_throws() throws IOException {
assertThrows(Exception.class, testProvider::getKeyStore);
}
+
+ @Test
+ void x509Provider_noConfig_usesGkeCredentialBundle(@TempDir Path tempDir) throws Exception {
+ Path bundle = writeGkeBundle(tempDir, /* keyFirst= */ false);
+ MtlsUtils.setGkeCredentialBundlePathForTesting(bundle.toString());
+ try {
+ X509Provider testProvider =
+ new X509Provider(new TestEnvironmentProvider(), noGcloudConfig(tempDir), null, true);
+
+ KeyStore store = testProvider.getKeyStore();
+ assertEquals(1, store.size());
+ assertNotNull(store.getCertificateAlias(loadTestCert()));
+ assertTrue(testProvider.isAvailable());
+ } finally {
+ MtlsUtils.setGkeCredentialBundlePathForTesting(null);
+ }
+ }
+
+ @Test
+ void x509Provider_noConfig_usesGkeCredentialBundleWithKeyFirst(@TempDir Path tempDir)
+ throws Exception {
+ Path bundle = writeGkeBundle(tempDir, /* keyFirst= */ true);
+ MtlsUtils.setGkeCredentialBundlePathForTesting(bundle.toString());
+ try {
+ X509Provider testProvider =
+ new X509Provider(new TestEnvironmentProvider(), noGcloudConfig(tempDir), null, true);
+
+ KeyStore store = testProvider.getKeyStore();
+ assertNotNull(store.getCertificateAlias(loadTestCert()));
+ } finally {
+ MtlsUtils.setGkeCredentialBundlePathForTesting(null);
+ }
+ }
+
+ @Test
+ void x509Provider_gkeCredentialBundleWithIntermediate_keepsFullChain(@TempDir Path tempDir)
+ throws Exception {
+ Path bundle = tempDir.resolve("x509.credential-bundle.private-key.pem");
+ Files.write(
+ bundle,
+ (MtlsKeyStoreUtilsTest.read(MtlsKeyStoreUtilsTest.CHAIN_CERT_PATH)
+ + "\n"
+ + MtlsKeyStoreUtilsTest.read(MtlsKeyStoreUtilsTest.CHAIN_KEY_PATH))
+ .getBytes(UTF_8));
+ MtlsUtils.setGkeCredentialBundlePathForTesting(bundle.toString());
+ try {
+ X509Provider testProvider =
+ new X509Provider(new TestEnvironmentProvider(), noGcloudConfig(tempDir), null, true);
+
+ KeyStore store = testProvider.getKeyStore();
+ // The intermediate is presented too, so peers that only trust the root can verify the leaf.
+ MtlsKeyStoreUtilsTest.assertLeafThenIntermediate(store);
+ } finally {
+ MtlsUtils.setGkeCredentialBundlePathForTesting(null);
+ }
+ }
+
+ @Test
+ void x509Provider_keyFirstGkeCredentialBundleWithIntermediate_keepsFullChain(
+ @TempDir Path tempDir) throws Exception {
+ // GKE pod certificate bundles put the private key before the certificate chain.
+ Path bundle = tempDir.resolve("x509.credential-bundle.private-key.pem");
+ Files.write(
+ bundle,
+ (MtlsKeyStoreUtilsTest.read(MtlsKeyStoreUtilsTest.CHAIN_KEY_PATH)
+ + "\n"
+ + MtlsKeyStoreUtilsTest.read(MtlsKeyStoreUtilsTest.CHAIN_CERT_PATH))
+ .getBytes(UTF_8));
+ MtlsUtils.setGkeCredentialBundlePathForTesting(bundle.toString());
+ try {
+ X509Provider testProvider =
+ new X509Provider(new TestEnvironmentProvider(), noGcloudConfig(tempDir), null, true);
+
+ MtlsKeyStoreUtilsTest.assertLeafThenIntermediate(testProvider.getKeyStore());
+ } finally {
+ MtlsUtils.setGkeCredentialBundlePathForTesting(null);
+ }
+ }
+
+ @Test
+ void x509Provider_certFileWithIntermediate_keepsFullChain(@TempDir Path tempDir)
+ throws Exception {
+ Path config = tempDir.resolve("certificate_config.json");
+ Files.write(
+ config,
+ ("{\"cert_configs\":{\"workload\":{\"cert_path\":\""
+ + new File(MtlsKeyStoreUtilsTest.CHAIN_CERT_PATH).getAbsolutePath()
+ + "\",\"key_path\":\""
+ + new File(MtlsKeyStoreUtilsTest.CHAIN_KEY_PATH).getAbsolutePath()
+ + "\"}}}")
+ .getBytes(UTF_8));
+
+ X509Provider testProvider = new X509Provider(config.toString());
+
+ KeyStore store = testProvider.getKeyStore();
+ MtlsKeyStoreUtilsTest.assertLeafThenIntermediate(store);
+ }
+
+ @Test
+ void x509Provider_explicitConfigEnv_takesPrecedenceOverGkeCredentialBundle(@TempDir Path tempDir)
+ throws Exception {
+ MtlsUtils.setGkeCredentialBundlePathForTesting(
+ tempDir.resolve("does_not_matter.pem").toString());
+ try {
+ TestEnvironmentProvider envProvider = new TestEnvironmentProvider();
+ envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", "badfile.txt");
+ // Even with a bundle present, an explicit config is used (and fails here).
+ writeGkeBundleAt(tempDir.resolve("does_not_matter.pem"), false);
+ X509Provider testProvider =
+ new X509Provider(envProvider, noGcloudConfig(tempDir), null, true);
+
+ CertificateSourceUnavailableException e =
+ assertThrows(CertificateSourceUnavailableException.class, testProvider::getKeyStore);
+ assertFalse(String.valueOf(e.getMessage()).contains("GKE"));
+ } finally {
+ MtlsUtils.setGkeCredentialBundlePathForTesting(null);
+ }
+ }
+
+ @Test
+ void x509Provider_overridePath_ignoresGkeCredentialBundle(@TempDir Path tempDir)
+ throws Exception {
+ Path bundle = writeGkeBundle(tempDir, false);
+ MtlsUtils.setGkeCredentialBundlePathForTesting(bundle.toString());
+ try {
+ X509Provider testProvider =
+ new X509Provider(
+ new TestEnvironmentProvider(), noGcloudConfig(tempDir), "badfile.txt", true);
+
+ CertificateSourceUnavailableException e =
+ assertThrows(CertificateSourceUnavailableException.class, testProvider::getKeyStore);
+ assertFalse(String.valueOf(e.getMessage()).contains("GKE"));
+ } finally {
+ MtlsUtils.setGkeCredentialBundlePathForTesting(null);
+ }
+ }
+
+ @Test
+ void x509Provider_noConfigAndNoGkeCredentialBundle_isUnavailable(@TempDir Path tempDir)
+ throws Exception {
+ MtlsUtils.setGkeCredentialBundlePathForTesting(tempDir.resolve("missing.pem").toString());
+ try {
+ X509Provider testProvider =
+ new X509Provider(new TestEnvironmentProvider(), noGcloudConfig(tempDir), null, true);
+
+ assertThrows(CertificateSourceUnavailableException.class, testProvider::getKeyStore);
+ assertFalse(testProvider.isAvailable());
+ } finally {
+ MtlsUtils.setGkeCredentialBundlePathForTesting(null);
+ }
+ }
+
+ @Test
+ void x509Provider_publicConstructor_ignoresGkeCredentialBundle(@TempDir Path tempDir)
+ throws Exception {
+ Path bundle = writeGkeBundle(tempDir, false);
+ MtlsUtils.setGkeCredentialBundlePathForTesting(bundle.toString());
+ try {
+ // Non-transport callers (e.g. workload identity federation X.509 credentials) keep the
+ // existing certificate configuration lookup and never fall back to the GKE bundle.
+ X509Provider testProvider =
+ new X509Provider(new TestEnvironmentProvider(), noGcloudConfig(tempDir), null);
+
+ assertThrows(CertificateSourceUnavailableException.class, testProvider::getKeyStore);
+ assertFalse(testProvider.isAvailable());
+ } finally {
+ MtlsUtils.setGkeCredentialBundlePathForTesting(null);
+ }
+ }
+
+ /** Property provider whose user.home has no gcloud certificate_config.json. */
+ private static TestPropertyProvider noGcloudConfig(Path tempDir) {
+ TestPropertyProvider propProvider = new TestPropertyProvider();
+ propProvider.setProperty("os.name", "Linux");
+ propProvider.setProperty("user.home", tempDir.resolve("home").toString());
+ return propProvider;
+ }
+
+ private static Certificate loadTestCert() throws IOException, CertificateException {
+ try (FileInputStream fis = new FileInputStream(new File(TEST_CERT_PATH))) {
+ return CertificateFactory.getInstance("X.509").generateCertificate(fis);
+ }
+ }
+
+ private static Path writeGkeBundle(Path dir, boolean keyFirst) throws IOException {
+ return writeGkeBundleAt(dir.resolve("x509.credential-bundle.private-key.pem"), keyFirst);
+ }
+
+ private static Path writeGkeBundleAt(Path bundle, boolean keyFirst) throws IOException {
+ String cert = new String(Files.readAllBytes(new File(TEST_CERT_PATH).toPath()), UTF_8);
+ String key = new String(Files.readAllBytes(new File(TEST_KEY_PATH).toPath()), UTF_8);
+ String content = keyFirst ? key + "\n" + cert : cert + "\n" + key;
+ Files.write(bundle, content.getBytes(UTF_8));
+ return bundle;
+ }
}
diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java
index e9302576435f..d6d25407a394 100644
--- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java
+++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/AgentIdentityUtilsTest.java
@@ -71,6 +71,7 @@
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
+import java.util.concurrent.atomic.AtomicBoolean;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.AtomicReference;
@@ -95,6 +96,7 @@ class AgentIdentityUtilsTest {
"spiffe://agents.global.org-INVALID.system.id.goog/path";
private TestEnvironmentProvider envProvider;
+ private TestPropertyProvider propertyProvider;
@TempDir private Path tempDir;
@BeforeEach
@@ -102,6 +104,12 @@ void setUp() throws IOException {
envProvider = new TestEnvironmentProvider();
AgentIdentityUtils.setEnvironmentProvider(envProvider);
AgentIdentityUtils.setTimeService(new FakeTimeService());
+ // Resolve the well-known gcloud certificate_config.json under a temp home, so a real config on
+ // the machine running the tests cannot affect GKE credential bundle discovery.
+ propertyProvider = new TestPropertyProvider();
+ propertyProvider.setProperty("os.name", "Linux");
+ propertyProvider.setProperty("user.home", tempDir.resolve("home").toString());
+ AgentIdentityUtils.setPropertyProvider(propertyProvider);
}
@AfterEach
@@ -1808,6 +1816,221 @@ public void verifyKeyPair_unsupportedKeyAlgorithm_throwsUnsupportedKeyAlgorithmE
assertTrue(e.getMessage().contains("Unsupported key algorithm: DSA"));
}
+ // --- GKE credential bundle (x509.credential-bundle.private-key.pem) ---
+
+ private Path writeGkeBundleInWellKnownDir(String certResource, String keyResource)
+ throws Exception {
+ AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/");
+ String certPem =
+ new String(
+ Files.readAllBytes(Paths.get(resourcePath(certResource))), StandardCharsets.UTF_8);
+ String keyPem =
+ new String(
+ Files.readAllBytes(Paths.get(resourcePath(keyResource))), StandardCharsets.UTF_8);
+ Path bundle = tempDir.resolve(AgentIdentityUtils.GKE_CREDENTIAL_BUNDLE_FILE);
+ Files.write(bundle, (certPem + "\n" + keyPem).getBytes(StandardCharsets.UTF_8));
+ return bundle;
+ }
+
+ private Path writeAgentGkeBundle() throws Exception {
+ return writeGkeBundleInWellKnownDir(
+ "agent/agent_spiffe_cert.pem", "agent/agent_spiffe_key.pem");
+ }
+
+ @Test
+ public void getAgentIdentityCertInfo_gkeBundle_implicit_returnsCertWithoutPolling()
+ throws Exception {
+ writeAgentGkeBundle();
+ FakeTimeService fakeTime = new FakeTimeService();
+ AgentIdentityUtils.setTimeService(fakeTime);
+
+ AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo();
+
+ assertNotNull(info);
+ assertTrue(
+ AgentIdentityCertificateValidationUtils.shouldRequestBoundToken(info.getCertificate()));
+ assertTrue(info.getCertContent().contains("BEGIN CERTIFICATE"));
+ assertFalse(info.getCertContent().contains("PRIVATE KEY"));
+ assertEquals(0, fakeTime.getSleepCount());
+ }
+
+ @Test
+ public void getAgentIdentityCertInfo_gkeBundle_secondCallUsesCacheAndStaysBound()
+ throws Exception {
+ writeAgentGkeBundle();
+
+ AgentIdentityUtils.CertInfo first = AgentIdentityUtils.getAgentIdentityCertInfo();
+ AgentIdentityUtils.CertInfo second = AgentIdentityUtils.getAgentIdentityCertInfo();
+
+ assertNotNull(first);
+ assertSame(first, second);
+ }
+
+ @Test
+ public void getAgentIdentityCertInfo_gkeBundle_useClientCertFalse_returnsNull() throws Exception {
+ writeAgentGkeBundle();
+ envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_USE_CLIENT_CERTIFICATE, "false");
+
+ assertNull(AgentIdentityUtils.getAgentIdentityCertInfo());
+ assertNull(AgentIdentityUtils.getAgentIdentityCertInfo());
+ }
+
+ @Test
+ public void getAgentIdentityCertInfo_gkeBundle_enableRuntimeBoundTokenFalse_returnsNull()
+ throws Exception {
+ writeAgentGkeBundle();
+ envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "false");
+
+ assertNull(AgentIdentityUtils.getAgentIdentityCertInfo());
+ }
+
+ @Test
+ public void getAgentIdentityCertInfo_gkeBundle_defaultGcloudConfigExists_returnsNull()
+ throws Exception {
+ writeAgentGkeBundle();
+ Path gcloudDir = tempDir.resolve("home").resolve(".config").resolve("gcloud");
+ Files.createDirectories(gcloudDir);
+ Files.write(gcloudDir.resolve("certificate_config.json"), "{}".getBytes());
+
+ // The gcloud config is the transport's mTLS certificate source, so tokens must not be bound to
+ // the GKE certificate.
+ assertNull(AgentIdentityUtils.getAgentIdentityCertInfo());
+ }
+
+ @Test
+ public void getAgentIdentityCertInfo_gkeBundle_nonAgentCertificate_returnsNull()
+ throws Exception {
+ writeGkeBundleInWellKnownDir("mtls/test_cert.pem", "mtls/test_key.pem");
+
+ assertNull(AgentIdentityUtils.getAgentIdentityCertInfo());
+ }
+
+ @Test
+ public void getAgentIdentityCertInfo_gkeBundleAbsent_returnsNullWithoutPolling()
+ throws Exception {
+ AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/");
+ FakeTimeService fakeTime = new FakeTimeService();
+ AgentIdentityUtils.setTimeService(fakeTime);
+
+ assertNull(AgentIdentityUtils.getAgentIdentityCertInfo());
+ assertEquals(0, fakeTime.getSleepCount());
+ }
+
+ @Test
+ public void getAgentIdentityCertInfo_gkeBundle_useClientCertTrue_isDiscovered() throws Exception {
+ writeAgentGkeBundle();
+ envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_USE_CLIENT_CERTIFICATE, "true");
+ FakeTimeService fakeTime = new FakeTimeService();
+ AgentIdentityUtils.setTimeService(fakeTime);
+
+ assertNotNull(AgentIdentityUtils.getAgentIdentityCertInfo());
+ assertEquals(0, fakeTime.getSleepCount());
+ }
+
+ @Test
+ public void getAgentIdentityCertInfo_gkeBundleEmpty_returnsNullWithoutPolling() throws Exception {
+ AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/");
+ Files.createFile(tempDir.resolve(AgentIdentityUtils.GKE_CREDENTIAL_BUNDLE_FILE));
+ FakeTimeService fakeTime = new FakeTimeService();
+ AgentIdentityUtils.setTimeService(fakeTime);
+
+ assertNull(AgentIdentityUtils.getAgentIdentityCertInfo());
+ assertEquals(0, fakeTime.getSleepCount());
+ }
+
+ @Test
+ public void getAgentIdentityCertInfo_gkeBundle_useClientCertTrue_isNotPolledFor()
+ throws Exception {
+ AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/");
+ envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_USE_CLIENT_CERTIFICATE, "true");
+ FakeTimeService fakeTime = new FakeTimeService();
+ AtomicBoolean written = new AtomicBoolean(false);
+ fakeTime.setOnSleepCallback(
+ () -> {
+ if (written.compareAndSet(false, true)) {
+ try {
+ writeAgentGkeBundle();
+ } catch (Exception e) {
+ throw new RuntimeException(e);
+ }
+ }
+ });
+ AgentIdentityUtils.setTimeService(fakeTime);
+
+ // The bundle appears during startup polling, but only the Cloud Run files are polled for.
+ assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo);
+ assertTrue(written.get());
+
+ // After startup, the bundle is found by the single up-front check.
+ assertNotNull(AgentIdentityUtils.getAgentIdentityCertInfo());
+ }
+
+ @Test
+ public void getAgentIdentityCertInfo_gkeBundle_takesPrecedenceOverCloudRunFilesWhenExplicit()
+ throws Exception {
+ // The transport presents the GKE bundle when no certificate config exists, so the token must be
+ // bound to it even if other well-known files are present.
+ writeAgentGkeBundle();
+ copyResource("mtls/test_cert.pem", tempDir.resolve("certificates.pem"));
+ copyResource("mtls/test_key.pem", tempDir.resolve("private_key.pem"));
+ envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_USE_CLIENT_CERTIFICATE, "true");
+
+ // The GKE bundle (agent cert) wins over certificates.pem/private_key.pem (non-agent cert).
+ AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo();
+ assertNotNull(info);
+ }
+
+ @Test
+ public void getAgentIdentityCertInfo_gkeBundleRemovedAfterCaching_fallsBackToCachedCredential()
+ throws Exception {
+ Path bundle = writeAgentGkeBundle();
+ AgentIdentityUtils.CertInfo first = AgentIdentityUtils.getAgentIdentityCertInfo();
+ assertNotNull(first);
+
+ // Simulate a transient gap during rotation.
+ Files.delete(bundle);
+
+ assertSame(first, AgentIdentityUtils.getAgentIdentityCertInfo());
+ }
+
+ private void writeDefaultGcloudCertificateConfig() throws Exception {
+ Path gcloudDir = tempDir.resolve("home").resolve(".config").resolve("gcloud");
+ Files.createDirectories(gcloudDir);
+ Files.write(gcloudDir.resolve("certificate_config.json"), "{}".getBytes());
+ }
+
+ @Test
+ public void getAgentIdentityCertInfo_gkeBundleRemovedAfterCaching_configAppears_doesNotUseCache()
+ throws Exception {
+ Path bundle = writeAgentGkeBundle();
+ assertNotNull(AgentIdentityUtils.getAgentIdentityCertInfo());
+
+ // The bundle is transiently missing and a certificate config now exists, so the transport
+ // presents the config's certificate; the cached GKE credential must not be reused.
+ Files.delete(bundle);
+ writeDefaultGcloudCertificateConfig();
+
+ assertNull(AgentIdentityUtils.getAgentIdentityCertInfo());
+ }
+
+ @Test
+ public void
+ getAgentIdentityCertInfo_gkeBundleRemovedAfterCaching_configAppears_explicit_doesNotUseCache()
+ throws Exception {
+ Path bundle = writeAgentGkeBundle();
+ envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_USE_CLIENT_CERTIFICATE, "true");
+ FakeTimeService fakeTime = new FakeTimeService();
+ AgentIdentityUtils.setTimeService(fakeTime);
+ assertNotNull(AgentIdentityUtils.getAgentIdentityCertInfo());
+
+ Files.delete(bundle);
+ writeDefaultGcloudCertificateConfig();
+
+ // No other well-known files exist and the cached GKE credential is not allowed anymore.
+ assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo);
+ assertEquals(0, fakeTime.getSleepCount());
+ }
+
private String resourcePath(String resource) throws Exception {
URL url = getClass().getClassLoader().getResource(resource);
assertNotNull(url, "Test resource " + resource + " not found");
diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/test_chain_cert.pem b/google-auth-library-java/oauth2_http/testresources/mtls/test_chain_cert.pem
new file mode 100644
index 000000000000..5465eb9a51b1
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/testresources/mtls/test_chain_cert.pem
@@ -0,0 +1,23 @@
+-----BEGIN CERTIFICATE-----
+MIIB2TCCAX+gAwIBAgIUNavUXQ/rulG+3syxU0MrxecWQGQwCgYIKoZIzj0EAwIw
+HzEdMBsGA1UEAwwUVGVzdCBJbnRlcm1lZGlhdGUgQ0EwIBcNMjYxMDA5MTUxODM3
+WhgPMjEyNjA5MTUxNTE4MzdaMBQxEjAQBgNVBAMMCVRlc3QgTGVhZjBZMBMGByqG
+SM49AgEGCCqGSM49AwEHA0IABFFirHZTwx/ld55xeocgpp0rQME9O9ATEonLHecx
+RYlG8N0eMNTC4PyIVCQPfHnrI167YpwCk2nyh+HS8wl3UrOjgaEwgZ4wDAYDVR0T
+AQH/BAIwADAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwIwKQYD
+VR0RBCIwIIYec3BpZmZlOi8vZXhhbXBsZS50ZXN0L3dvcmtsb2FkMB0GA1UdDgQW
+BBSXEYG9sUpIGjEVcNnv5z6R/Y0UGjAfBgNVHSMEGDAWgBQtGnDllv2kiVva8/vP
+KnPkeOYblTAKBggqhkjOPQQDAgNIADBFAiBFg0vUmv7/I2KMcnIlDMpxwQhl7KUH
+fTvnJUu3TlCq3wIhAMpR49B2EVADdJTP630dUMbxQl7i0KoSinyZe+eXbGo7
+-----END CERTIFICATE-----
+-----BEGIN CERTIFICATE-----
+MIIBoTCCAUagAwIBAgIULt67iL18WCU7vF52uolgyC26bL8wCgYIKoZIzj0EAwIw
+FzEVMBMGA1UEAwwMVGVzdCBSb290IENBMCAXDTI2MTAwOTE1MTgzN1oYDzIxMjYw
+OTE1MTUxODM3WjAfMR0wGwYDVQQDDBRUZXN0IEludGVybWVkaWF0ZSBDQTBZMBMG
+ByqGSM49AgEGCCqGSM49AwEHA0IABPOMvOWtJYOV6I9Sp6C7gzA3iOi3VCz9kZQ2
+iW86iUDpk+S5jso0pP3gJy8hdJBLGkcmm9L+x4CaRcCSePsQ/vyjZjBkMBIGA1Ud
+EwEB/wQIMAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMB0GA1UdDgQWBBQtGnDllv2k
+iVva8/vPKnPkeOYblTAfBgNVHSMEGDAWgBT8Ay0RXQdWDWCi9PzNTwvXx+W+KzAK
+BggqhkjOPQQDAgNJADBGAiEA/K0vk+MiOpF0G0Rrpw2tpi+DtIXUlkPLSTzXVGMn
+cGgCIQChgI06W0im1y5pz3ymwTewRSF7MDlCgKt+pALGcZv1Gw==
+-----END CERTIFICATE-----
diff --git a/google-auth-library-java/oauth2_http/testresources/mtls/test_chain_key.pem b/google-auth-library-java/oauth2_http/testresources/mtls/test_chain_key.pem
new file mode 100644
index 000000000000..37ef3f333f18
--- /dev/null
+++ b/google-auth-library-java/oauth2_http/testresources/mtls/test_chain_key.pem
@@ -0,0 +1,5 @@
+-----BEGIN PRIVATE KEY-----
+MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgnkUdqWqfKVO0qTH6
+WByFOh5OjqL9NOuQebS1JhEL7SChRANCAARRYqx2U8Mf5XeecXqHIKadK0DBPTvQ
+ExKJyx3nMUWJRvDdHjDUwuD8iFQkD3x56yNeu2KcApNp8ofh0vMJd1Kz
+-----END PRIVATE KEY-----
*
*/
public static @Nullable String getWorkloadCertPath(
@@ -175,9 +238,14 @@ public static boolean useMtlsClientCertificate(
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;
}
- 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(
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..0930bfebaf3d 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,7 +32,6 @@
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;
@@ -148,7 +147,7 @@ static KeyStore getKeyStore(InputStream metadata, ProcessProvider processProvide
}
// Create mTLS key store with the input certificates from shell command.
- return SecurityUtils.createMtlsKeyStore(process.getInputStream());
+ return MtlsKeyStoreUtils.createMtlsKeyStore(process.getInputStream());
}
@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..f88cbe1b996c 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 for the client transport layer. In addition to the locations checked
+ * by {@link #X509Provider()}, it 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 createForTransport() {
+ return new X509Provider(
+ SystemEnvironmentProvider.getInstance(), SystemPropertyProvider.getInstance(), null, true);
}
/**
@@ -105,15 +136,37 @@ public X509Provider() {
*