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 479e644b1d47..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 @@ -131,16 +131,23 @@ 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=false. + * GOOGLE_API_USE_CLIENT_CERTIFICATE (set to a non-empty value other than "true"). */ public static boolean useMtlsClientCertificate( EnvironmentProvider envProvider, PropertyProvider propProvider) { - String useClientCertificate = envProvider.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); - if ("false".equalsIgnoreCase(useClientCertificate)) { + 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) { @@ -167,17 +174,18 @@ public static boolean useMtlsClientCertificate( * 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=false}, 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. + * 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 useClientCertificate = envProvider.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); - if ("false".equalsIgnoreCase(useClientCertificate)) { + String rawVal = envProvider.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); + String useClientCertificate = rawVal != null ? rawVal.trim() : null; + if (isUseClientCertificateExplicitlyDisabled(useClientCertificate)) { return null; } 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 index eacad1065247..0dfa9d63c8a4 100644 --- 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 @@ -352,19 +352,22 @@ static boolean tryUpdateCache( } /** - * Checks if a file exists and is readable, throwing {@link AccessDeniedException} if permission - * is denied. + * 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 { - Files.readAttributes(path, BasicFileAttributes.class); + 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 true; + return attrs.size() > 0; } catch (AccessDeniedException e) { throw e; } catch (IOException e) { diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java index 49aa3033424e..2bbe599df301 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityUtils.java @@ -286,14 +286,36 @@ private static boolean isMtlsExplicitlyDisabled() { 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 = Paths.get(pathStr).toAbsolutePath().normalize(); - Path wellKnown = Paths.get(wellKnownDir).toAbsolutePath().normalize(); + Path path = resolveRealPathAllowingMissing(Paths.get(pathStr)); + Path wellKnown = resolveRealPathAllowingMissing(Paths.get(wellKnownDir)); return path.startsWith(wellKnown); } catch (Exception e) { return false; @@ -686,7 +708,7 @@ private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry( lastParseException); } - /** Searches for certificates at well-known locations with retry logic. */ + /** Searches for certificates at well-known locations. */ private static ResolvedCertAndKeyPaths getWellKnownCertificatePathWithRetry( final @Nullable CachedAgentIdentityInfo initialCached) throws IOException { if (isMtlsExplicitlyDisabled()) { @@ -694,11 +716,11 @@ private static ResolvedCertAndKeyPaths getWellKnownCertificatePathWithRetry( return new ResolvedCertAndKeyPaths(null, null, false); } - // Unlike the Cloud Run well-known files below (which may be delivered asynchronously and are - // polled for up to 30s on initial startup), GKE mounts the Pod Certificate volume before the - // container starts, so we perform a single immediate check per call without startup polling. - // When found, we mark initialStartupCompleted = true so subsequent refreshes during rotation do - // not enter the 30s startup polling loop. + // 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 = @@ -732,52 +754,31 @@ private static ResolvedCertAndKeyPaths getWellKnownCertificatePathWithRetry( String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString(); String keyOnlyPath = Paths.get(wellKnownDir, "private_key.pem").toString(); - boolean shouldPoll = !initialStartupCompleted; - int maxCycles = shouldPoll ? TOTAL_POLL_CYCLES : 1; - - boolean warned = false; - for (int cycle = 0; cycle < maxCycles; cycle++) { - 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) { + try { + if (AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(bundlePath))) { 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) { - // Fall through to retry + // Combined bundle file contains both certificate chain and private key + return new ResolvedCertAndKeyPaths(bundlePath, bundlePath, false); } - - if (cycle + 1 < maxCycles) { - if (shouldPoll && !warned) { - LoggingUtils.log( - LOGGER_PROVIDER, - Level.FINE, - Collections.emptyMap(), - String.format( - "Well-known certificate file not found at %s. Retrying for up to %d seconds.", - wellKnownDir, TOTAL_TIMEOUT_MS / 1000)); - warned = true; - } - try { - timeService.sleep(getSleepIntervalMs(cycle)); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException("Interrupted while waiting for well-known certificate files.", e); - } + 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; @@ -792,12 +793,7 @@ private static ResolvedCertAndKeyPaths getWellKnownCertificatePathWithRetry( fallbackCached.certMetadata.getPath(), fallbackCached.keyMetadata.getPath(), false); } - throw new IOException( - String.format( - "Unable to find well-known Agent Identity certificate file at %s for bound token" - + " request. Token binding protection is failing. You can turn off this" - + " protection by setting %s to false to fall back to unbound tokens.", - wellKnownDir, GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN)); + return new ResolvedCertAndKeyPaths(null, null, false); } /** 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 546dfbd2c220..1e85e6b11b08 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 @@ -823,6 +823,17 @@ void getWorkloadCertPath_useClientCertFalse_ignoresGkeBundle() throws IOExceptio assertFalse(MtlsUtils.useMtlsClientCertificate(envProvider, homeInTempDir())); } + @Test + void getWorkloadCertPath_useClientCertNonTrueValues_disablesMtls() throws IOException { + writeGkeBundle(); + for (String disabledVal : new String[] {"0", "no", "FALSE", "off", "random"}) { + EnvironmentProvider envProvider = + name -> "GOOGLE_API_USE_CLIENT_CERTIFICATE".equals(name) ? disabledVal : null; + assertNull(MtlsUtils.getWorkloadCertPath(envProvider, homeInTempDir())); + assertFalse(MtlsUtils.useMtlsClientCertificate(envProvider, homeInTempDir())); + } + } + @Test void getWorkloadCertPath_defaultConfigWithoutWorkload_ignoresGkeBundle() throws IOException { writeGkeBundle(); 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 d6d25407a394..26680489dd25 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,7 +71,6 @@ 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; @@ -590,7 +589,7 @@ public void getAgentIdentityCertInfo_mismatch_throwsIOExceptionAfterRetries() th @Test public void - getAgentIdentityCertInfo_wellKnownDirExistsNoFiles_explicitlyEnabled_retriesAndThrowsIOException() + getAgentIdentityCertInfo_wellKnownDirExistsNoFiles_explicitlyEnabled_returnsNullWithoutPolling() throws IOException { AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); FakeTimeService fakeTime = new FakeTimeService(); @@ -598,8 +597,23 @@ public void getAgentIdentityCertInfo_mismatch_throwsIOExceptionAfterRetries() th envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); - assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); - assertTrue(fakeTime.getSleepCount() > 0); + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + assertEquals(0, fakeTime.getSleepCount()); + } + + @Test + public void + getAgentIdentityCertInfo_wellKnownDirMissing_explicitlyEnabled_returnsNullWithoutPolling() + throws IOException { + AgentIdentityUtils.setWellKnownDir( + tempDir.resolve("non_existent_well_known").toAbsolutePath().toString() + "/"); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); + + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + assertEquals(0, fakeTime.getSleepCount()); } @Test @@ -1004,13 +1018,15 @@ public void getAgentIdentityCertInfo_partialJsonDuringStartupPoll_retriesAndSucc @Test public void - getAgentIdentityCertInfo_startupPollTimeout_subsequentRefreshDoesNotPollAndThrowsIOException() + getAgentIdentityCertInfo_configInWellKnownDirTimeout_subsequentRefreshDoesNotPollAndThrowsIOException() throws Exception { AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); FakeTimeService fakeTime = new FakeTimeService(); AgentIdentityUtils.setTimeService(fakeTime); - envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); - envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); + envProvider.setEnv( + "GOOGLE_API_CERTIFICATE_CONFIG", + tempDir.resolve("missing_config.json").toAbsolutePath().toString()); + envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", null); // Initial call polls for all 100 cycles (99 sleeps) and fails closed with IOException assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); @@ -1022,24 +1038,86 @@ public void getAgentIdentityCertInfo_partialJsonDuringStartupPoll_retriesAndSucc } @Test - public void - getAgentIdentityCertInfo_configInWellKnownDirTimeout_subsequentRefreshDoesNotPollAndThrowsIOException() - throws Exception { + public void getAgentIdentityCertInfo_zeroByteFilesDuringStartupPoll_waitsUntilNonEmpty() + throws Exception { AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + Path targetCertPath = tempDir.resolve("certificates.pem"); + Path targetKeyPath = tempDir.resolve("private_key.pem"); + // Create 0-byte files simulating CSI driver creating file entries before writing content + Files.createFile(targetCertPath); + Files.createFile(targetKeyPath); + + Path configFile = tempDir.resolve("credentials.json"); + writeWorkloadConfig(configFile, targetCertPath, targetKeyPath); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, configFile.toString()); + + // Populate after sleep 4 and 5 so loadAndVerifyCredentials's 3 attempts (2 sleeps) cannot + // mask a broken 0-byte startup poll check. FakeTimeService fakeTime = new FakeTimeService(); + fakeTime.setOnSleepCallback( + () -> { + if (fakeTime.getSleepCount() == 4) { + try { + // First populate only cert; key is still 0 bytes + copyResource("agent/agent_spiffe_cert.pem", targetCertPath); + } catch (Exception e) { + throw new RuntimeException(e); + } + } else if (fakeTime.getSleepCount() == 5) { + try { + // Then populate key + copyResource("agent/agent_spiffe_key.pem", targetKeyPath); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }); AgentIdentityUtils.setTimeService(fakeTime); + + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(info); + assertEquals(5, fakeTime.getSleepCount()); + } + + @Test + public void getAgentIdentityCertInfo_symlinkedWellKnownDir_pollsForStartupFiles() + throws Exception { + Path realWellKnown = Files.createDirectory(tempDir.resolve("real_run_secrets")); + Path symlinkWellKnown = tempDir.resolve("var_run_secrets"); + try { + Files.createSymbolicLink(symlinkWellKnown, realWellKnown); + } catch (UnsupportedOperationException | IOException e) { + Assumptions.assumeTrue(false, "Symbolic links not supported: " + e.getMessage()); + } + + // Configure wellKnownDir via symlink path (/var/run/...) while GOOGLE_API_CERTIFICATE_CONFIG + // uses the real path (/run/...) for a file that does not exist yet at startup. + AgentIdentityUtils.setWellKnownDir(symlinkWellKnown.toAbsolutePath().toString() + "/"); + Path configViaRealPath = realWellKnown.resolve("credentials.json"); + Path certViaRealPath = realWellKnown.resolve("certificates.pem"); + Path keyViaRealPath = realWellKnown.resolve("private_key.pem"); envProvider.setEnv( - "GOOGLE_API_CERTIFICATE_CONFIG", - tempDir.resolve("missing_config.json").toAbsolutePath().toString()); - envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", null); + AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, + configViaRealPath.toAbsolutePath().toString()); - // Initial call polls for all 100 cycles (99 sleeps) and fails closed with IOException - assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); - assertEquals(99, fakeTime.getSleepCount()); + FakeTimeService fakeTime = new FakeTimeService(); + fakeTime.setOnSleepCallback( + () -> { + if (fakeTime.getSleepCount() == 1) { + try { + copyResource("agent/agent_spiffe_cert.pem", certViaRealPath); + copyResource("agent/agent_spiffe_key.pem", keyViaRealPath); + writeWorkloadConfig(configViaRealPath, certViaRealPath, keyViaRealPath); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + }); + AgentIdentityUtils.setTimeService(fakeTime); - // Subsequent token refresh fails closed immediately without sleeping - assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); - assertEquals(99, fakeTime.getSleepCount()); + AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); + assertNotNull(info); + assertEquals(1, fakeTime.getSleepCount()); } @Test @@ -1140,8 +1218,9 @@ public void getAgentIdentityCertInfo_implicitDiscovery_bundleWithoutPrivateKey_t } @Test - public void getAgentIdentityCertInfo_explicitMtlsEnabled_missingPrivateKey_throwsIOException() - throws Exception { + public void + getAgentIdentityCertInfo_explicitMtlsEnabled_missingPrivateKey_returnsNullWithoutPolling() + throws Exception { AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); assertNotNull(certUrl); @@ -1153,10 +1232,8 @@ public void getAgentIdentityCertInfo_explicitMtlsEnabled_missingPrivateKey_throw FakeTimeService fakeTime = new FakeTimeService(); AgentIdentityUtils.setTimeService(fakeTime); - IOException e = assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); - assertTrue( - e.getMessage().contains("Unable to find well-known Agent Identity certificate file")); - assertEquals(99, fakeTime.getSleepCount()); + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + assertEquals(0, fakeTime.getSleepCount()); } @Test @@ -1944,25 +2021,16 @@ public void getAgentIdentityCertInfo_gkeBundle_useClientCertTrue_isNotPolledFor( 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()); + // Without GOOGLE_API_CERTIFICATE_CONFIG, no startup polling occurs when the bundle is absent. + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + assertEquals(0, fakeTime.getSleepCount()); - // After startup, the bundle is found by the single up-front check. + // Once the bundle is written, it is found by the single up-front check. + writeAgentGkeBundle(); assertNotNull(AgentIdentityUtils.getAgentIdentityCertInfo()); + assertEquals(0, fakeTime.getSleepCount()); } @Test @@ -2027,7 +2095,7 @@ public void getAgentIdentityCertInfo_gkeBundleRemovedAfterCaching_configAppears_ writeDefaultGcloudCertificateConfig(); // No other well-known files exist and the cached GKE credential is not allowed anymore. - assertThrows(IOException.class, AgentIdentityUtils::getAgentIdentityCertInfo); + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); assertEquals(0, fakeTime.getSleepCount()); } 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 f31ac845fdcb..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; @@ -1408,18 +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/InstantiatingGrpcChannelProviderTest.java b/sdk-platform-java/gax-java/gax-grpc/src/test/java/com/google/api/gax/grpc/InstantiatingGrpcChannelProviderTest.java index a7b28c7d709e..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 @@ -52,6 +52,7 @@ 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; @@ -1443,6 +1444,50 @@ void createChannelBuilder_whenMtlsActiveAndKeyStoreNull_throwsIOException() { 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/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 d1c2ea0f1c97..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; @@ -51,7 +50,6 @@ import java.util.concurrent.Executor; import java.util.concurrent.ScheduledExecutorService; import java.util.function.Supplier; -import java.util.logging.Level; import java.util.logging.Logger; import javax.net.ssl.KeyManagerFactory; import javax.net.ssl.SSLContext; @@ -453,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/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 749876c4a005..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 @@ -41,6 +41,7 @@ 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; @@ -479,6 +480,50 @@ void getTransportChannel_whenMtlsKeyStoreThrowsRuntimeException_propagatesUnwrap 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 { diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/EndpointContext.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/EndpointContext.java index 0c0ef9a69b9c..2fdb3974087f 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/EndpointContext.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/EndpointContext.java @@ -34,7 +34,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.auth.oauth2.ComputeEngineCredentials; import com.google.auto.value.AutoValue; @@ -42,7 +41,6 @@ import com.google.common.base.Strings; import com.google.common.net.HostAndPort; import java.io.IOException; -import java.util.logging.Level; import java.util.logging.Logger; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -65,7 +63,11 @@ public abstract class EndpointContext { // static block initialization for exception handling static { try { - INSTANCE = EndpointContext.newBuilder().setServiceName("").build(); + INSTANCE = + EndpointContext.newBuilder() + .setServiceName("") + .setCertificateBasedAccess(new CertificateBasedAccess(name -> "false")) + .build(); } catch (IOException e) { throw new RuntimeException("Unable to create a default empty EndpointContext", e); } @@ -309,16 +311,9 @@ private String determineEndpoint() throws IOException { if (certificateBasedAccess.useMtlsClientCertificate()) { if (mtlsProvider == null) { try { - mtlsProvider = DefaultMtlsProviderFactory.create(); + mtlsProvider = certificateBasedAccess.createDefaultMtlsProvider(); } catch (CertificateSourceUnavailableException e) { // This is okay. Leave mtlsProvider as null; - } 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."); } } } diff --git a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/mtls/CertificateBasedAccess.java b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/mtls/CertificateBasedAccess.java index d77378ad9612..59a2b674404c 100644 --- a/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/mtls/CertificateBasedAccess.java +++ b/sdk-platform-java/gax-java/gax/src/main/java/com/google/api/gax/rpc/mtls/CertificateBasedAccess.java @@ -32,8 +32,11 @@ import com.google.api.core.InternalApi; import com.google.api.gax.rpc.internal.EnvironmentProvider; +import com.google.auth.mtls.DefaultMtlsProviderFactory; +import com.google.auth.mtls.MtlsProvider; import com.google.auth.mtls.MtlsUtils; import com.google.auth.oauth2.PropertyProvider; +import java.io.IOException; /** * Utility class for handling certificate-based access configurations. @@ -96,4 +99,9 @@ public MtlsEndpointUsagePolicy getMtlsEndpointUsagePolicy() { public String getWorkloadCertPath() { return MtlsUtils.getWorkloadCertPath(getAuthEnvProvider(), getAuthPropertyProvider()); } + + /** Creates the default {@link MtlsProvider} for the environment. */ + public MtlsProvider createDefaultMtlsProvider() throws IOException { + return DefaultMtlsProviderFactory.create(); + } } diff --git a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/EndpointContextTest.java b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/EndpointContextTest.java index 950b9f9dd7a7..e388676a8c89 100644 --- a/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/EndpointContextTest.java +++ b/sdk-platform-java/gax-java/gax/src/test/java/com/google/api/gax/rpc/EndpointContextTest.java @@ -38,6 +38,7 @@ import com.google.api.gax.rpc.mtls.CertificateBasedAccess; import com.google.api.gax.rpc.testing.FakeMtlsProvider; import com.google.auth.Credentials; +import com.google.auth.mtls.CertificateSourceUnavailableException; import com.google.auth.mtls.MtlsProvider; import com.google.auth.oauth2.ComputeEngineCredentials; import com.google.common.truth.Truth; @@ -743,4 +744,39 @@ void getUrlDomain_nonGDUUniverseDomain() throws IOException { defaultEndpointContextBuilder.setUniverseDomain("random.com").build(); Truth.assertThat(endpointContext.getUrlDomain()).isEqualTo("test.random.com"); } + + @Test + void endpointContextBuild_whenDefaultMtlsProviderThrowsIOException_propagatesIOException() + throws Exception { + CertificateBasedAccess certificateBasedAccess = Mockito.mock(CertificateBasedAccess.class); + Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true); + IOException expected = new IOException("Corrupt PEM file"); + Mockito.when(certificateBasedAccess.createDefaultMtlsProvider()).thenThrow(expected); + + EndpointContext.Builder builder = + defaultEndpointContextBuilder + .setMtlsProvider(null) + .setCertificateBasedAccess(certificateBasedAccess); + IOException actual = assertThrows(IOException.class, builder::build); + Truth.assertThat(actual).isSameInstanceAs(expected); + } + + @Test + void + endpointContextBuild_whenDefaultMtlsProviderThrowsCertificateSourceUnavailable_fallsBackToRegularEndpoint() + throws Exception { + CertificateBasedAccess certificateBasedAccess = Mockito.mock(CertificateBasedAccess.class); + Mockito.when(certificateBasedAccess.useMtlsClientCertificate()).thenReturn(true); + Mockito.when(certificateBasedAccess.getMtlsEndpointUsagePolicy()) + .thenReturn(CertificateBasedAccess.MtlsEndpointUsagePolicy.AUTO); + Mockito.when(certificateBasedAccess.createDefaultMtlsProvider()) + .thenThrow(new CertificateSourceUnavailableException("No certificates on disk")); + + EndpointContext endpointContext = + defaultEndpointContextBuilder + .setMtlsProvider(null) + .setCertificateBasedAccess(certificateBasedAccess) + .build(); + Truth.assertThat(endpointContext.resolvedEndpoint()).isEqualTo(DEFAULT_ENDPOINT); + } }