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 4f0d4c034b6d..3a180d445304 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 @@ -40,8 +40,12 @@ import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; +import java.io.InterruptedIOException; +import java.nio.file.AccessDeniedException; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; +import java.nio.file.attribute.BasicFileAttributes; import java.security.MessageDigest; import java.util.Locale; import org.jspecify.annotations.NullMarked; @@ -59,21 +63,308 @@ public class MtlsUtils { static final String WELL_KNOWN_CERTIFICATE_CONFIG_FILE = "certificate_config.json"; static final String CLOUDSDK_CONFIG_DIRECTORY = "gcloud"; + /** + * Well-known directory where workload SPIFFE credentials and certificate configurations are + * delivered at container startup. + */ + @InternalApi + public static final String WELL_KNOWN_SPIFFE_DIR = + "/var/run/secrets/workload-spiffe-credentials/"; + /** * Well-known path of the combined certificate chain and private key bundle that GKE delivers to * pods with Agent Identity (Pod Certificates). Used as a fallback client certificate source when * no certificate configuration file is configured or present. */ static final String GKE_CREDENTIAL_BUNDLE_PATH = - "/var/run/secrets/workload-spiffe-credentials/x509.credential-bundle.private-key.pem"; + WELL_KNOWN_SPIFFE_DIR + "x509.credential-bundle.private-key.pem"; + + // Polling configuration for initial container startup credential file readiness when + // GOOGLE_API_CERTIFICATE_CONFIG (or its referenced cert/key files) resides in the well-known + // SPIFFE directory (50 * 100ms + 50 * 500ms = 30 seconds total). + private static final int FAST_POLL_CYCLES = 50; + private static final long FAST_POLL_INTERVAL_MS = 100; // 0.1 seconds + private static final long SLOW_POLL_INTERVAL_MS = 500; // 0.5 seconds + private static final long TOTAL_TIMEOUT_MS = 30000; // 30 seconds + + /** Total startup polling timeout in seconds (30s). */ + @InternalApi public static final long STARTUP_POLL_TIMEOUT_SECONDS = TOTAL_TIMEOUT_MS / 1000; + + private static final int TOTAL_POLL_CYCLES = + FAST_POLL_CYCLES + + (int) + ((TOTAL_TIMEOUT_MS - (FAST_POLL_CYCLES * FAST_POLL_INTERVAL_MS)) + / SLOW_POLL_INTERVAL_MS); // Overridable via setGkeCredentialBundlePathForTesting so unit tests can point to a temp file. private static volatile String gkeCredentialBundlePath = GKE_CREDENTIAL_BUNDLE_PATH; + // Overridable via setWellKnownSpiffeDirForTesting so unit tests can point to a temp directory. + private static volatile String wellKnownSpiffeDir = WELL_KNOWN_SPIFFE_DIR; + + private static volatile TimeService timeService = Thread::sleep; + + // Tracks whether initial container startup discovery has completed across both mTLS transport + // initialization and Agent Identity token binding so the 30-second startup poll runs at most once + // per process. + private static volatile boolean initialStartupCompleted = false; + + /** + * Functional interface for sleeping during startup credential polling. Overridable for testing. + */ + @InternalApi + @VisibleForTesting + @FunctionalInterface + public interface TimeService { + void sleep(long millis) throws InterruptedException; + } + private MtlsUtils() { // Prevent instantiation for Utility class } + private static long getSleepIntervalMs(int cycle) { + return (cycle < FAST_POLL_CYCLES) ? FAST_POLL_INTERVAL_MS : SLOW_POLL_INTERVAL_MS; + } + + /** + * Returns {@code true} if initial container startup certificate discovery has already completed. + */ + @InternalApi + public static boolean isInitialStartupCompleted() { + return initialStartupCompleted; + } + + /** Marks initial container startup certificate discovery as completed. */ + @InternalApi + public static void markInitialStartupCompleted() { + initialStartupCompleted = true; + } + + /** Resets the initial startup completion flag for testing. */ + @InternalApi + @VisibleForTesting + public static void resetStartupStateForTesting() { + initialStartupCompleted = false; + } + + /** + * Overrides the well-known SPIFFE directory path for testing; {@code null} restores the default. + */ + @VisibleForTesting + static void setWellKnownSpiffeDirForTesting(@Nullable String dir) { + wellKnownSpiffeDir = dir != null ? dir : WELL_KNOWN_SPIFFE_DIR; + initialStartupCompleted = false; + } + + /** Overrides the sleep service for testing; {@code null} restores {@code Thread::sleep}. */ + @VisibleForTesting + static void setTimeServiceForTesting(@Nullable TimeService service) { + timeService = service != null ? service : Thread::sleep; + initialStartupCompleted = false; + } + + /** + * 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. + */ + @InternalApi + public static Path resolveRealPathAllowingMissing(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 {@code pathStr} resides within {@code targetWellKnownDir}. */ + @InternalApi + public static boolean isPathInWellKnownDir(@Nullable String pathStr, String targetWellKnownDir) { + if (Strings.isNullOrEmpty(pathStr) || Strings.isNullOrEmpty(targetWellKnownDir)) { + return false; + } + try { + Path path = resolveRealPathAllowingMissing(Paths.get(pathStr)); + Path wellKnown = resolveRealPathAllowingMissing(Paths.get(targetWellKnownDir)); + return path.startsWith(wellKnown); + } catch (Exception e) { + return false; + } + } + + private static boolean isNonEmptyReadableFile(File file) { + return file.isFile() && file.canRead() && file.length() > 0; + } + + private static boolean isExistingNonFileOrUnreadable(File file) { + Path path = file.toPath(); + try { + BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class); + if (!attrs.isRegularFile()) { + return true; + } + return !Files.isReadable(path) && Files.exists(path); + } catch (AccessDeniedException e) { + return true; + } catch (IOException e) { + return false; + } + } + + /** + * On initial container startup ({@code !isInitialStartupCompleted()}), polls up to 30 seconds + * when {@code configFile} or its referenced {@code cert_path}/{@code key_path} resides within + * {@code targetWellKnownDir} until the configuration and referenced certificate/key files are + * non-empty readable files, or a non-retryable state is encountered. + * + * @return {@code true} if startup polling was active for {@code configFile}, {@code false} + * otherwise + * @throws IOException if the thread is interrupted while sleeping during startup polling + */ + @InternalApi + public static boolean waitForWorkloadConfigStartupIfNeeded( + File configFile, + String targetWellKnownDir, + TimeService sleepService, + @Nullable Runnable onFirstPollSleep) + throws IOException { + if (initialStartupCompleted) { + return false; + } + boolean shouldPoll = isPathInWellKnownDir(configFile.getPath(), targetWellKnownDir); + try { + WorkloadCertificateConfiguration config = + waitForExplicitConfigStartupIfNeeded( + configFile, targetWellKnownDir, sleepService, onFirstPollSleep); + if (config != null && !shouldPoll) { + shouldPoll = + isPathInWellKnownDir(config.getCertPath(), targetWellKnownDir) + || isPathInWellKnownDir(config.getPrivateKeyPath(), targetWellKnownDir); + } + } catch (InterruptedIOException e) { + throw new IOException( + "Interrupted while waiting for Agent Identity certificate files for bound" + + " token request.", + e.getCause() != null ? e.getCause() : e); + } catch (Exception ignored) { + // Non-retryable parse or config errors are handled by the caller's single-pass evaluation. + } + return shouldPoll; + } + + /** + * On initial container startup ({@code !initialStartupCompleted}), polls up to 30 seconds when + * {@code configFile} or its referenced {@code cert_path}/{@code key_path} resides within the + * well-known SPIFFE directory until the configuration and referenced certificate/key files are + * non-empty readable files. + */ + private static @Nullable WorkloadCertificateConfiguration waitForExplicitConfigStartupIfNeeded( + File configFile) throws IOException { + return waitForExplicitConfigStartupIfNeeded(configFile, wellKnownSpiffeDir, timeService, null); + } + + private static @Nullable WorkloadCertificateConfiguration waitForExplicitConfigStartupIfNeeded( + File configFile, + String targetWellKnownDir, + TimeService sleepService, + @Nullable Runnable onFirstPollSleep) + throws IOException { + if (initialStartupCompleted) { + return null; + } + boolean shouldPoll = isPathInWellKnownDir(configFile.getPath(), targetWellKnownDir); + int maxCycles = shouldPoll ? TOTAL_POLL_CYCLES : 1; + boolean warned = false; + + for (int cycle = 0; cycle < maxCycles; cycle++) { + if (initialStartupCompleted) { + return null; + } + if (isExistingNonFileOrUnreadable(configFile)) { + // Directory or permission denied on an existing file will not resolve via startup polling. + break; + } + if (isNonEmptyReadableFile(configFile)) { + try (InputStream certConfigStream = new FileInputStream(configFile)) { + WorkloadCertificateConfiguration config = + WorkloadCertificateConfiguration.fromCertificateConfigurationStream(certConfigStream); + if (!shouldPoll + && (isPathInWellKnownDir(config.getCertPath(), targetWellKnownDir) + || isPathInWellKnownDir(config.getPrivateKeyPath(), targetWellKnownDir))) { + shouldPoll = true; + maxCycles = TOTAL_POLL_CYCLES; + } + if (!shouldPoll) { + initialStartupCompleted = true; + return config; + } + File certFile = new File(config.getCertPath()); + File keyFile = new File(config.getPrivateKeyPath()); + if (isExistingNonFileOrUnreadable(certFile) || isExistingNonFileOrUnreadable(keyFile)) { + // Referenced certificate or key is a directory or unreadable; fail without polling. + break; + } + if (isNonEmptyReadableFile(certFile) && isNonEmptyReadableFile(keyFile)) { + initialStartupCompleted = true; + return config; + } + } catch (CertificateSourceUnavailableException e) { + // Valid JSON without a workload section (e.g. ECP configuration); do not poll. + initialStartupCompleted = true; + throw e; + } catch (IllegalArgumentException | ClassCastException e) { + // Valid JSON missing or mistyped cert_configs, cert_path, or key_path; do not poll. + initialStartupCompleted = true; + throw e; + } catch (Exception e) { + if (!shouldPoll) { + initialStartupCompleted = true; + if (e instanceof IOException) { + throw (IOException) e; + } + if (e instanceof RuntimeException) { + throw (RuntimeException) e; + } + throw new IOException(e); + } + // When polling in the well-known SPIFFE directory, retry on transient partial JSON + // writes (which may throw IOException or NullPointerException at EOF in JsonParser). + } + } + if (cycle + 1 < maxCycles) { + if (shouldPoll && !warned && onFirstPollSleep != null) { + onFirstPollSleep.run(); + warned = true; + } + try { + sleepService.sleep(getSleepIntervalMs(cycle)); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + InterruptedIOException interrupted = + new InterruptedIOException( + "Interrupted while waiting for workload certificate configuration at " + + configFile.getPath()); + interrupted.initCause(e); + throw interrupted; + } + } + } + if (shouldPoll) { + initialStartupCompleted = true; + } + return null; + } + /** * Returns {@code true} if a certificate configuration is configured or present: either {@code * GOOGLE_API_CERTIFICATE_CONFIG} is set, or the well-known gcloud {@code certificate_config.json} @@ -194,6 +485,20 @@ public static boolean useMtlsClientCertificate( // 1. Explicit Configuration Path (Fail Closed) if (!Strings.isNullOrEmpty(explicitConfigPath)) { File configFile = new File(explicitConfigPath); + WorkloadCertificateConfiguration config = null; + try { + config = waitForExplicitConfigStartupIfNeeded(configFile); + } catch (CertificateSourceUnavailableException e) { + // ECP / PKCS11 configuration without workload section; safe fallback + return null; + } catch (Exception e) { + throw new IllegalStateException( + "Certificate configuration file specified via GOOGLE_API_CERTIFICATE_CONFIG at '" + + explicitConfigPath + + "' is malformed: " + + e.getMessage(), + e); + } if (!configFile.exists()) { throw new IllegalStateException( "Certificate configuration file specified via GOOGLE_API_CERTIFICATE_CONFIG at '" @@ -207,19 +512,21 @@ public static boolean useMtlsClientCertificate( + explicitConfigPath + "'."); } - WorkloadCertificateConfiguration config; - try { - config = getWorkloadCertificateConfiguration(envProvider, propProvider, explicitConfigPath); - } catch (CertificateSourceUnavailableException e) { - // ECP / PKCS11 configuration without workload section; safe fallback - return null; - } catch (Exception e) { - throw new IllegalStateException( - "Certificate configuration file specified via GOOGLE_API_CERTIFICATE_CONFIG at '" - + explicitConfigPath - + "' is malformed: " - + e.getMessage(), - e); + if (config == null) { + try { + config = + getWorkloadCertificateConfiguration(envProvider, propProvider, explicitConfigPath); + } catch (CertificateSourceUnavailableException e) { + // ECP / PKCS11 configuration without workload section; safe fallback + return null; + } catch (Exception e) { + throw new IllegalStateException( + "Certificate configuration file specified via GOOGLE_API_CERTIFICATE_CONFIG at '" + + explicitConfigPath + + "' is malformed: " + + e.getMessage(), + e); + } } checkCertAndKeyFilesReadable(config, explicitConfigPath, false); return config.getCertPath(); @@ -270,7 +577,7 @@ private static void checkCertAndKeyFilesReadable( WorkloadCertificateConfiguration config, String configPath, boolean isDefaultConfig) { File certFile = new File(config.getCertPath()); File keyFile = new File(config.getPrivateKeyPath()); - if (!certFile.isFile() || !certFile.canRead() || !keyFile.isFile() || !keyFile.canRead()) { + if (!isNonEmptyReadableFile(certFile) || !isNonEmptyReadableFile(keyFile)) { String sourcePrefix = isDefaultConfig ? "referenced by default configuration '" @@ -353,17 +660,30 @@ static WorkloadCertificateConfiguration getWorkloadCertificateConfiguration( @Nullable String certConfigPathOverride) throws IOException { File certConfig; + boolean isExplicitConfig = false; if (certConfigPathOverride != null) { certConfig = new File(certConfigPathOverride); + isExplicitConfig = true; } else { String envCredentialsPath = envProvider.getEnv(CERTIFICATE_CONFIGURATION_ENV_VARIABLE); if (!Strings.isNullOrEmpty(envCredentialsPath)) { certConfig = new File(envCredentialsPath); + isExplicitConfig = true; } else { certConfig = getWellKnownCertificateConfigFile(envProvider, propProvider); } } + String rawUseClientCert = envProvider.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE"); + String useClientCert = rawUseClientCert != null ? rawUseClientCert.trim() : null; + if (isExplicitConfig && !isUseClientCertificateExplicitlyDisabled(useClientCert)) { + WorkloadCertificateConfiguration polledConfig = + waitForExplicitConfigStartupIfNeeded(certConfig); + if (polledConfig != null) { + return polledConfig; + } + } + if (!certConfig.isFile() || !certConfig.canRead()) { throw new CertificateSourceUnavailableException( "Certificate configuration file does not exist or is not a file: " 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 0dfa9d63c8a4..16f30a9f6d89 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 @@ -242,12 +242,6 @@ static boolean isCachedInfoValid( if (cached.configMetadata != null) { return false; } - if (cached.certMetadata.path.endsWith("certificates.pem") - && Files.exists( - Paths.get(AgentIdentityUtils.getWellKnownDir(), "credentialbundle.pem"))) { - // credentialbundle.pem takes precedence if added after certificates.pem was cached - return false; - } } if (!cached.certMetadata.isUnchangedOnDisk()) { return false; 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 2bbe599df301..8bd6415bd5b6 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 @@ -39,12 +39,12 @@ import com.google.auth.oauth2.AgentIdentityCertificateValidationUtils.UnsupportedKeyAlgorithmException; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.Strings; +import java.io.File; import java.io.IOException; import java.io.InputStream; import java.nio.charset.StandardCharsets; import java.nio.file.AccessDeniedException; import java.nio.file.Files; -import java.nio.file.Path; import java.nio.file.Paths; import java.security.PrivateKey; import java.security.cert.X509Certificate; @@ -91,7 +91,7 @@ final class AgentIdentityUtils { */ static final String GOOGLE_API_USE_CLIENT_CERTIFICATE = "GOOGLE_API_USE_CLIENT_CERTIFICATE"; - private static volatile String wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/"; + private static volatile String wellKnownDir = MtlsUtils.WELL_KNOWN_SPIFFE_DIR; /** * File name of the combined certificate chain and private key bundle that GKE delivers to the @@ -101,8 +101,7 @@ final class AgentIdentityUtils { * configuration file exists, the presence of this bundle auto-enables mTLS on its own (no {@link * #GOOGLE_API_USE_CLIENT_CERTIFICATE} required). It is never polled for, because GKE delivers it * before the container starts and polling on its absence would delay every workload without Agent - * Identity. It must be a readable, non-empty file. When present and allowed, it takes precedence - * over the other well-known files, matching the certificate the transport presents. + * Identity. It must be a readable, non-empty file. */ static final String GKE_CREDENTIAL_BUNDLE_FILE = "x509.credential-bundle.private-key.pem"; @@ -114,23 +113,6 @@ final class AgentIdentityUtils { private static final long CERT_KEY_MATCH_RETRY_INTERVAL_MS = 100; - // Polling configuration for initial container startup credential file readiness. - // Matches Python google-auth implementation (_agent_identity_utils.py) for initial - // asynchronous credential delivery (50 * 100ms + 50 * 500ms = 30 seconds total). - private static final int FAST_POLL_CYCLES = 50; - - private static final long FAST_POLL_INTERVAL_MS = 100; // 0.1 seconds - - private static final long SLOW_POLL_INTERVAL_MS = 500; // 0.5 seconds - - private static final long TOTAL_TIMEOUT_MS = 30000; // 30 seconds - - private static final int TOTAL_POLL_CYCLES = - FAST_POLL_CYCLES - + (int) - ((TOTAL_TIMEOUT_MS - (FAST_POLL_CYCLES * FAST_POLL_INTERVAL_MS)) - / SLOW_POLL_INTERVAL_MS); - private static volatile EnvironmentProvider environmentProvider = SystemEnvironmentProvider.getInstance(); @@ -138,11 +120,6 @@ final class AgentIdentityUtils { private static volatile TimeService timeService = Thread::sleep; - // Tracks whether initial container startup discovery has completed. The 30-second - // polling loop (TOTAL_POLL_CYCLES) is strictly limited to initial startup so subsequent - // token refreshes never block for 30 seconds. - private static volatile boolean initialStartupCompleted = false; - // Tracks whether the FINE-level message for explicitly disabled mTLS when certificates are // present has already been logged in this process to prevent log spam on repeated token // refreshes. @@ -251,10 +228,6 @@ private static final class IncompleteWorkloadConfigException extends IOException } } - private static long getSleepIntervalMs(final int cycle) { - return (cycle < FAST_POLL_CYCLES) ? FAST_POLL_INTERVAL_MS : SLOW_POLL_INTERVAL_MS; - } - private static @Nullable String getTrimmedEnv(final String name) { String val = environmentProvider.getEnv(name); return val != null ? val.trim() : null; @@ -269,14 +242,6 @@ private static long getSleepIntervalMs(final int cycle) { return getTrimmedEnv(GOOGLE_API_USE_CLIENT_CERTIFICATE); } - /** - * Returns {@code true} if {@link #GOOGLE_API_USE_CLIENT_CERTIFICATE} is set to {@code "true"} - * (case-insensitive). - */ - private static boolean isMtlsExplicitlyEnabled() { - return "true".equalsIgnoreCase(getUseClientCertificateEnv()); - } - /** * Returns {@code true} if {@link #GOOGLE_API_USE_CLIENT_CERTIFICATE} is set to a non-empty value * other than {@code "true"} (case-insensitive). @@ -286,42 +251,6 @@ 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 = resolveRealPathAllowingMissing(Paths.get(pathStr)); - Path wellKnown = resolveRealPathAllowingMissing(Paths.get(wellKnownDir)); - return path.startsWith(wellKnown); - } catch (Exception e) { - return false; - } - } - /** Returns {@code true} if {@code path} is the GKE credential bundle in the well-known dir. */ static boolean isGkeCredentialBundle(final @Nullable String path) { return path != null && gkeCredentialBundlePath.equals(path); @@ -411,9 +340,7 @@ private static ResolvedCertAndKeyPaths resolveCertAndKeyPaths( // startup delivery (when in well-known directory) and transient rotation race conditions. return getPathsFromConfigWithRetry(certConfigPath, cached); } - // Fallback to well-known locations. We use retry with backoff here as well to handle - // startup delivery (when explicit mTLS is enabled) and race conditions during file - // replacement by a rotation process. + // Fallback to the well-known GKE Pod Certificate bundle when no certificate config is set. return getWellKnownCertificatePathWithRetry(cached); } @@ -600,83 +527,57 @@ private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry( return new ResolvedCertAndKeyPaths(null, null, false); } - boolean inWellKnownDir = isPathInWellKnownDir(certConfigPath); - boolean shouldPoll = !initialStartupCompleted && inWellKnownDir; - int maxCycles = shouldPoll ? TOTAL_POLL_CYCLES : 1; - boolean warned = false; + boolean shouldPoll = + MtlsUtils.waitForWorkloadConfigStartupIfNeeded( + new File(certConfigPath), + wellKnownDir, + timeService::sleep, + () -> + LoggingUtils.log( + LOGGER_PROVIDER, + Level.FINE, + Collections.emptyMap(), + String.format( + "Agent Identity certificate config or referenced credential files not yet" + + " ready at %s (from %s environment variable). Retrying for up to %d" + + " seconds.", + certConfigPath, + GOOGLE_API_CERTIFICATE_CONFIG, + MtlsUtils.STARTUP_POLL_TIMEOUT_SECONDS))); IOException lastParseException = null; - for (int cycle = 0; cycle < maxCycles; cycle++) { - try { - if (AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(certConfigPath))) { - ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath); - if (!paths.hasWorkloadConfig()) { - // Valid non-workload config (e.g. enterprise certs) - exit early without polling! - initialStartupCompleted = true; - return paths; - } - // The config file itself may reside outside wellKnownDir while referencing cert_path or - // key_path inside wellKnownDir that are still being delivered at startup. - if (!initialStartupCompleted - && !shouldPoll - && (isPathInWellKnownDir(paths.getCertPath()) - || isPathInWellKnownDir(paths.getKeyPath()))) { - shouldPoll = true; - maxCycles = TOTAL_POLL_CYCLES; - } - boolean certReady = - AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(paths.getCertPath())); - boolean keyReady = - AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(paths.getKeyPath())); - if (certReady && keyReady) { - initialStartupCompleted = true; - return paths; - } + try { + if (AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(certConfigPath))) { + ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath); + if (!paths.hasWorkloadConfig()) { + // Valid non-workload config (e.g. enterprise certs) - exit early. + MtlsUtils.markInitialStartupCompleted(); + return paths; } - } catch (AccessDeniedException e) { - String failedFile = e.getFile() != null ? e.getFile() : certConfigPath; - throw new IOException( - "Permission denied reading certificate config file: " + failedFile, e); - } catch (IncompleteWorkloadConfigException e) { - // Incomplete workload config (missing cert_path or key_path) will not become ready via - // startup polling; break out of the polling loop immediately so initial startup throws - // without waiting 30s (or falls back to a previously cached config in steady state). - lastParseException = e; - break; - } catch (IOException e) { - CachedAgentIdentityInfo latestCached = - AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached); - if (!shouldPoll && cycle + 1 >= maxCycles && latestCached == null) { - initialStartupCompleted = true; - throw e; // Fail fast on malformed JSON syntax errors when not polling + boolean certReady = + AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(paths.getCertPath())); + boolean keyReady = + AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(paths.getKeyPath())); + if (certReady && keyReady) { + MtlsUtils.markInitialStartupCompleted(); + return paths; } - lastParseException = e; - // When polling is active, fall through to retry (handles partial/empty JSON during startup) } - if (cycle + 1 < maxCycles) { - if (shouldPoll && !warned) { - LoggingUtils.log( - LOGGER_PROVIDER, - Level.FINE, - Collections.emptyMap(), - String.format( - "Agent Identity certificate config or referenced credential files not yet ready" - + " at %s (from %s environment variable). Retrying for up to %d seconds.", - certConfigPath, GOOGLE_API_CERTIFICATE_CONFIG, TOTAL_TIMEOUT_MS / 1000)); - warned = true; - } - try { - timeService.sleep(getSleepIntervalMs(cycle)); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - throw new IOException( - "Interrupted while waiting for Agent Identity certificate files for bound" - + " token request.", - e); - } + } catch (AccessDeniedException e) { + String failedFile = e.getFile() != null ? e.getFile() : certConfigPath; + throw new IOException("Permission denied reading certificate config file: " + failedFile, e); + } catch (IncompleteWorkloadConfigException e) { + lastParseException = e; + } catch (IOException e) { + CachedAgentIdentityInfo latestCached = + AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached); + if (!shouldPoll && latestCached == null) { + MtlsUtils.markInitialStartupCompleted(); + throw e; // Fail fast on malformed JSON syntax errors when not polling } + lastParseException = e; } - initialStartupCompleted = true; + MtlsUtils.markInitialStartupCompleted(); CachedAgentIdentityInfo fallbackCached = AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached); @@ -712,87 +613,40 @@ private static ResolvedCertAndKeyPaths getPathsFromConfigWithRetry( private static ResolvedCertAndKeyPaths getWellKnownCertificatePathWithRetry( final @Nullable CachedAgentIdentityInfo initialCached) throws IOException { if (isMtlsExplicitlyDisabled()) { - initialStartupCompleted = true; + MtlsUtils.markInitialStartupCompleted(); return new ResolvedCertAndKeyPaths(null, null, false); } // Startup polling is only performed when GOOGLE_API_CERTIFICATE_CONFIG is set and points to the // well-known directory (see getPathsFromConfigWithRetry). When GOOGLE_API_CERTIFICATE_CONFIG is - // unset, GKE mounts the Pod Certificate volume before the container starts (and on GCE an empty - // well-known directory may exist without any certificates ever being delivered), so we perform - // a single immediate check without startup polling. + // unset, only the GKE Pod Certificate bundle is checked in the well-known directory (matching + // MtlsUtils.getWorkloadCertPath and X509Provider so we never mint a bound token for a + // certificate that the mTLS transport ignores). GKE mounts the Pod Certificate volume before + // the container starts, so we perform a single immediate check without startup polling. // The GKE credential bundle is only used when no well-known gcloud certificate configuration // file exists (it would otherwise be the transport's mTLS certificate source). boolean gkeBundleAllowed = !MtlsUtils.hasCertificateConfiguration(environmentProvider, propertyProvider); String gkeBundlePath = gkeCredentialBundlePath; if (gkeBundleAllowed && MtlsUtils.isGkeCredentialBundleReady(gkeBundlePath)) { - initialStartupCompleted = true; + MtlsUtils.markInitialStartupCompleted(); // GKE combined bundle file contains both certificate chain and private key return new ResolvedCertAndKeyPaths(gkeBundlePath, gkeBundlePath, false); } - if (!isMtlsExplicitlyEnabled()) { - // Without a config file or GOOGLE_API_USE_CLIENT_CERTIFICATE="true", only the GKE credential - // bundle can auto-enable mTLS. If a GKE credential was previously cached and the bundle is - // transiently unavailable (e.g. mid-rotation), return the cached paths so - // loadAndVerifyCredentials can fall back to the cached credential. - initialStartupCompleted = true; - CachedAgentIdentityInfo fallbackCached = - AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached); - if (gkeBundleAllowed - && fallbackCached != null - && fallbackCached.canUseWellKnownFallback() - && fallbackCached.isGkeCredentialBundle) { - return new ResolvedCertAndKeyPaths( - fallbackCached.certMetadata.getPath(), fallbackCached.keyMetadata.getPath(), false); - } - return new ResolvedCertAndKeyPaths(null, null, false); - } - - String bundlePath = Paths.get(wellKnownDir, "credentialbundle.pem").toString(); - String certOnlyPath = Paths.get(wellKnownDir, "certificates.pem").toString(); - String keyOnlyPath = Paths.get(wellKnownDir, "private_key.pem").toString(); - - try { - if (AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(bundlePath))) { - initialStartupCompleted = true; - // Combined bundle file contains both certificate chain and private key - return new ResolvedCertAndKeyPaths(bundlePath, bundlePath, false); - } - if (AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(certOnlyPath)) - && AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(keyOnlyPath))) { - initialStartupCompleted = true; - return new ResolvedCertAndKeyPaths(certOnlyPath, keyOnlyPath, false); - } - } catch (AccessDeniedException e) { - initialStartupCompleted = true; - LoggingUtils.log( - LOGGER_PROVIDER, - Level.FINE, - Collections.emptyMap(), - "Permission denied reading well-known certificates. Falling back to unbound token."); - return new ResolvedCertAndKeyPaths(null, null, false); - } catch (Exception e) { - LoggingUtils.log( - LOGGER_PROVIDER, - Level.FINE, - Collections.emptyMap(), - "Unexpected error checking well-known certificates: " + e.getMessage()); - } - initialStartupCompleted = true; - + // If a GKE credential was previously cached and the bundle is transiently unavailable (e.g. + // mid-rotation), return the cached paths so loadAndVerifyCredentials can fall back to the + // cached credential. + MtlsUtils.markInitialStartupCompleted(); CachedAgentIdentityInfo fallbackCached = AgentIdentityCacheUtils.getLatestOrInitialCache(initialCached); - if (fallbackCached != null + if (gkeBundleAllowed + && fallbackCached != null && fallbackCached.canUseWellKnownFallback() - // A cached GKE credential is only reused while the GKE bundle is still allowed (no - // certificate configuration exists), matching the certificate the transport presents. - && (!fallbackCached.isGkeCredentialBundle || gkeBundleAllowed)) { + && fallbackCached.isGkeCredentialBundle) { return new ResolvedCertAndKeyPaths( fallbackCached.certMetadata.getPath(), fallbackCached.keyMetadata.getPath(), false); } - return new ResolvedCertAndKeyPaths(null, null, false); } @@ -904,7 +758,7 @@ private static ResolvedCertAndKeyPaths extractPathsFromConfig(final String certC /** Resets all static state and overrides for testing. */ @VisibleForTesting static void resetForTest() { - wellKnownDir = "/var/run/secrets/workload-spiffe-credentials/"; + wellKnownDir = MtlsUtils.WELL_KNOWN_SPIFFE_DIR; gkeCredentialBundlePath = Paths.get(wellKnownDir, GKE_CREDENTIAL_BUNDLE_FILE).toString(); environmentProvider = SystemEnvironmentProvider.getInstance(); propertyProvider = SystemPropertyProvider.getInstance(); @@ -914,7 +768,7 @@ static void resetForTest() { private static void resetCachedState() { AgentIdentityCacheUtils.clearCachedAgentIdentityInfo(); - initialStartupCompleted = false; + MtlsUtils.resetStartupStateForTesting(); mtlsDisabledLogged = false; mtlsDisabledEvaluated = 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 34395805b5e9..5f0147028a8e 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 @@ -59,6 +59,9 @@ void setUp() { @AfterEach void tearDown() { MtlsUtils.setGkeCredentialBundlePathForTesting(null); + MtlsUtils.setWellKnownSpiffeDirForTesting(null); + MtlsUtils.setTimeServiceForTesting(null); + MtlsUtils.resetStartupStateForTesting(); } @Test @@ -885,4 +888,269 @@ void getGkeCredentialBundlePath_onlyWhenNoConfiguration() throws IOException { writeDefaultGcloudConfig("{}"); assertNull(MtlsUtils.getGkeCredentialBundlePath(name -> null, homeInTempDir())); } + + // --- Initial Startup Polling Tests (Well-Known SPIFFE Directory) --- + + @Test + void getWorkloadCertPath_explicitConfigInWellKnownDir_pollsUntilConfigAndCertsReady() { + MtlsUtils.setWellKnownSpiffeDirForTesting(tempDir.toAbsolutePath().toString() + "/"); + Path configFile = tempDir.resolve("certificate_config.json"); + Path certFile = tempDir.resolve("certificates.pem"); + Path keyFile = tempDir.resolve("private_key.pem"); + + int[] sleepCount = {0}; + MtlsUtils.setTimeServiceForTesting( + millis -> { + sleepCount[0]++; + if (sleepCount[0] == 2) { + try { + String configJson = + String.format( + "{\"cert_configs\":{\"workload\":{\"cert_path\":\"%s\",\"key_path\":\"%s\"}}}", + certFile.toString().replace("\\", "\\\\"), + keyFile.toString().replace("\\", "\\\\")); + Files.write(configFile, configJson.getBytes()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } else if (sleepCount[0] == 3) { + try { + Files.write(certFile, "dummy cert".getBytes()); + Files.write(keyFile, "dummy key".getBytes()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + }); + + EnvironmentProvider envProvider = + name -> "GOOGLE_API_CERTIFICATE_CONFIG".equals(name) ? configFile.toString() : null; + + assertEquals(certFile.toString(), MtlsUtils.getWorkloadCertPath(envProvider, homeInTempDir())); + assertEquals(3, sleepCount[0]); + assertTrue(MtlsUtils.isInitialStartupCompleted()); + } + + @Test + void getWorkloadCertPath_explicitConfigOutsideWellKnownDir_certInWellKnownDir_pollsUntilReady( + @TempDir Path outsideDir) throws IOException { + MtlsUtils.setWellKnownSpiffeDirForTesting(tempDir.toAbsolutePath().toString() + "/"); + Path certFile = tempDir.resolve("certificates.pem"); + Path keyFile = tempDir.resolve("private_key.pem"); + Path configFile = outsideDir.resolve("certificate_config.json"); + String configJson = + String.format( + "{\"cert_configs\":{\"workload\":{\"cert_path\":\"%s\",\"key_path\":\"%s\"}}}", + certFile.toString().replace("\\", "\\\\"), keyFile.toString().replace("\\", "\\\\")); + Files.write(configFile, configJson.getBytes()); + + int[] sleepCount = {0}; + MtlsUtils.setTimeServiceForTesting( + millis -> { + sleepCount[0]++; + if (sleepCount[0] == 2) { + try { + Files.write(certFile, "dummy cert".getBytes()); + Files.write(keyFile, "dummy key".getBytes()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + }); + + EnvironmentProvider envProvider = + name -> "GOOGLE_API_CERTIFICATE_CONFIG".equals(name) ? configFile.toString() : null; + + assertTrue(MtlsUtils.useMtlsClientCertificate(envProvider, homeInTempDir())); + assertEquals(2, sleepCount[0]); + } + + @Test + void getWorkloadCertPath_explicitConfigInWellKnownDir_partialJsonWriteDuringStartup_retries() + throws IOException { + MtlsUtils.setWellKnownSpiffeDirForTesting(tempDir.toAbsolutePath().toString() + "/"); + Path certFile = Files.write(tempDir.resolve("certificates.pem"), "dummy cert".getBytes()); + Path keyFile = Files.write(tempDir.resolve("private_key.pem"), "dummy key".getBytes()); + Path configFile = tempDir.resolve("certificate_config.json"); + Files.write(configFile, "{\"cert_configs\":".getBytes()); + + int[] sleepCount = {0}; + MtlsUtils.setTimeServiceForTesting( + millis -> { + sleepCount[0]++; + if (sleepCount[0] == 1) { + try { + String validJson = + String.format( + "{\"cert_configs\":{\"workload\":{\"cert_path\":\"%s\",\"key_path\":\"%s\"}}}", + certFile.toString().replace("\\", "\\\\"), + keyFile.toString().replace("\\", "\\\\")); + Files.write(configFile, validJson.getBytes()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + }); + + EnvironmentProvider envProvider = + name -> "GOOGLE_API_CERTIFICATE_CONFIG".equals(name) ? configFile.toString() : null; + + assertEquals(certFile.toString(), MtlsUtils.getWorkloadCertPath(envProvider, homeInTempDir())); + assertEquals(1, sleepCount[0]); + } + + @Test + void getWorkloadCertPath_explicitConfigInWellKnownDir_zeroByteFiles_pollsUntilNonEmpty() + throws IOException { + MtlsUtils.setWellKnownSpiffeDirForTesting(tempDir.toAbsolutePath().toString() + "/"); + Path certFile = Files.createFile(tempDir.resolve("certificates.pem")); + Path keyFile = Files.createFile(tempDir.resolve("private_key.pem")); + Path configFile = tempDir.resolve("certificate_config.json"); + String configJson = + String.format( + "{\"cert_configs\":{\"workload\":{\"cert_path\":\"%s\",\"key_path\":\"%s\"}}}", + certFile.toString().replace("\\", "\\\\"), keyFile.toString().replace("\\", "\\\\")); + Files.write(configFile, configJson.getBytes()); + + int[] sleepCount = {0}; + MtlsUtils.setTimeServiceForTesting( + millis -> { + sleepCount[0]++; + if (sleepCount[0] == 1) { + try { + Files.write(certFile, "dummy cert".getBytes()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } else if (sleepCount[0] == 2) { + try { + Files.write(keyFile, "dummy key".getBytes()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + }); + + EnvironmentProvider envProvider = + name -> "GOOGLE_API_CERTIFICATE_CONFIG".equals(name) ? configFile.toString() : null; + + assertEquals(certFile.toString(), MtlsUtils.getWorkloadCertPath(envProvider, homeInTempDir())); + assertEquals(2, sleepCount[0]); + } + + @Test + void getWorkloadCertPath_explicitConfigInWellKnownDir_symlinkedWellKnownDir_pollsUntilReady() + throws IOException { + 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()); + } + + MtlsUtils.setWellKnownSpiffeDirForTesting(symlinkWellKnown.toAbsolutePath().toString() + "/"); + Path configViaRealPath = realWellKnown.resolve("certificate_config.json"); + Path certViaRealPath = realWellKnown.resolve("certificates.pem"); + Path keyViaRealPath = realWellKnown.resolve("private_key.pem"); + + int[] sleepCount = {0}; + MtlsUtils.setTimeServiceForTesting( + millis -> { + sleepCount[0]++; + if (sleepCount[0] == 1) { + try { + Files.write(certViaRealPath, "dummy cert".getBytes()); + Files.write(keyViaRealPath, "dummy key".getBytes()); + String configJson = + String.format( + "{\"cert_configs\":{\"workload\":{\"cert_path\":\"%s\",\"key_path\":\"%s\"}}}", + certViaRealPath.toString().replace("\\", "\\\\"), + keyViaRealPath.toString().replace("\\", "\\\\")); + Files.write(configViaRealPath, configJson.getBytes()); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + }); + + EnvironmentProvider envProvider = + name -> "GOOGLE_API_CERTIFICATE_CONFIG".equals(name) ? configViaRealPath.toString() : null; + + assertEquals( + certViaRealPath.toString(), MtlsUtils.getWorkloadCertPath(envProvider, homeInTempDir())); + assertEquals(1, sleepCount[0]); + } + + @Test + void + getWorkloadCertPath_explicitConfigInWellKnownDir_timeout_throwsAndSubsequentCallDoesNotPoll() { + MtlsUtils.setWellKnownSpiffeDirForTesting(tempDir.toAbsolutePath().toString() + "/"); + Path missingConfig = tempDir.resolve("missing_config.json"); + + int[] sleepCount = {0}; + MtlsUtils.setTimeServiceForTesting(millis -> sleepCount[0]++); + + EnvironmentProvider envProvider = + name -> "GOOGLE_API_CERTIFICATE_CONFIG".equals(name) ? missingConfig.toString() : null; + + assertThrows( + IllegalStateException.class, + () -> MtlsUtils.getWorkloadCertPath(envProvider, homeInTempDir())); + assertEquals(99, sleepCount[0]); + assertTrue(MtlsUtils.isInitialStartupCompleted()); + + // Subsequent call after initial startup completed fails immediately with 0 additional sleeps. + assertThrows( + IllegalStateException.class, + () -> MtlsUtils.getWorkloadCertPath(envProvider, homeInTempDir())); + assertEquals(99, sleepCount[0]); + } + + @Test + void getWorkloadCertPath_explicitConfigInWellKnownDir_nonRetryableStates_failWithoutPolling() + throws IOException { + MtlsUtils.setWellKnownSpiffeDirForTesting(tempDir.toAbsolutePath().toString() + "/"); + int[] sleepCount = {0}; + MtlsUtils.setTimeServiceForTesting(millis -> sleepCount[0]++); + + // 1. ECP-only config in wellKnownSpiffeDir returns null with 0 sleeps. + Path ecpConfig = tempDir.resolve("ecp_config.json"); + Files.write(ecpConfig, "{\"cert_configs\":{\"enterprise_certificates\":{}}}".getBytes()); + assertNull( + MtlsUtils.getWorkloadCertPath( + name -> "GOOGLE_API_CERTIFICATE_CONFIG".equals(name) ? ecpConfig.toString() : null, + homeInTempDir())); + assertEquals(0, sleepCount[0]); + + // 2. Incomplete workload config (missing key_path) throws immediately with 0 sleeps. + MtlsUtils.resetStartupStateForTesting(); + Path incompleteConfig = tempDir.resolve("incomplete_config.json"); + Files.write( + incompleteConfig, + "{\"cert_configs\":{\"workload\":{\"cert_path\":\"cert.pem\"}}}".getBytes()); + assertThrows( + IllegalStateException.class, + () -> + MtlsUtils.getWorkloadCertPath( + name -> + "GOOGLE_API_CERTIFICATE_CONFIG".equals(name) + ? incompleteConfig.toString() + : null, + homeInTempDir())); + assertEquals(0, sleepCount[0]); + + // 3. Missing config outside wellKnownSpiffeDir throws immediately with 0 sleeps. + MtlsUtils.resetStartupStateForTesting(); + assertThrows( + IllegalStateException.class, + () -> + MtlsUtils.getWorkloadCertPath( + name -> + "GOOGLE_API_CERTIFICATE_CONFIG".equals(name) + ? "/nonexistent/outside.json" + : null, + homeInTempDir())); + assertEquals(0, sleepCount[0]); + } } 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 e9d7182cf310..ed19b73165f3 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 @@ -403,6 +403,51 @@ void x509Provider_publicConstructor_ignoresGkeCredentialBundle(@TempDir Path tem } } + @Test + void x509Provider_explicitConfigInWellKnownSpiffeDir_pollsDuringStartupUntilFilesReady( + @TempDir Path tempDir) throws Exception { + MtlsUtils.setWellKnownSpiffeDirForTesting(tempDir.toAbsolutePath().toString() + "/"); + try { + Path configFile = tempDir.resolve("certificate_config.json"); + Path certFile = tempDir.resolve("certificates.pem"); + Path keyFile = tempDir.resolve("private_key.pem"); + + int[] sleepCount = {0}; + MtlsUtils.setTimeServiceForTesting( + millis -> { + sleepCount[0]++; + if (sleepCount[0] == 2) { + try { + Files.copy(new File(TEST_CERT_PATH).toPath(), certFile); + Files.copy(new File(TEST_KEY_PATH).toPath(), keyFile); + String configJson = + String.format( + "{\"cert_configs\":{\"workload\":{\"cert_path\":\"%s\",\"key_path\":\"%s\"}}}", + certFile.toString().replace("\\", "\\\\"), + keyFile.toString().replace("\\", "\\\\")); + Files.write(configFile, configJson.getBytes(UTF_8)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + }); + + TestEnvironmentProvider envProvider = new TestEnvironmentProvider(); + envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", configFile.toString()); + X509Provider testProvider = + new X509Provider(envProvider, noGcloudConfig(tempDir), null, true); + + KeyStore store = testProvider.getKeyStore(); + assertEquals(1, store.size()); + assertNotNull(store.getCertificateAlias(loadTestCert())); + assertEquals(2, sleepCount[0]); + } finally { + MtlsUtils.setWellKnownSpiffeDirForTesting(null); + MtlsUtils.setTimeServiceForTesting(null); + MtlsUtils.resetStartupStateForTesting(); + } + } + /** Property provider whose user.home has no gcloud certificate_config.json. */ private static TestPropertyProvider noGcloudConfig(Path tempDir) { TestPropertyProvider propProvider = new TestPropertyProvider(); 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 26680489dd25..3edcc212eaff 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 @@ -445,29 +445,29 @@ public void shouldEnableMtls_unset_certsMissing_configExists_throwsIOException() } @Test - public void getAgentIdentityCertInfo_fallbackPath_loadsCertificate() throws Exception { + public void + getAgentIdentityCertInfo_unconfiguredLegacyWellKnownFiles_returnsNullEvenWhenExplicitMtlsEnabled() + throws Exception { AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); - URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); - assertNotNull(certUrl, "Test resource agent/agent_spiffe_cert.pem not found"); - String certPath = Paths.get(certUrl.toURI()).toAbsolutePath().toString(); - Files.copy(Paths.get(certPath), tempDir.resolve("certificates.pem")); - - URL keyUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_key.pem"); - assertNotNull(keyUrl, "Test resource agent/agent_spiffe_key.pem not found"); - String keyPath = Paths.get(keyUrl.toURI()).toAbsolutePath().toString(); - Files.copy(Paths.get(keyPath), tempDir.resolve("private_key.pem")); - + Path certPath = + copyResource("agent/agent_spiffe_cert.pem", tempDir.resolve("certificates.pem")); + Path keyPath = copyResource("agent/agent_spiffe_key.pem", tempDir.resolve("private_key.pem")); + String certPem = new String(Files.readAllBytes(certPath), StandardCharsets.UTF_8); + String keyPem = new String(Files.readAllBytes(keyPath), StandardCharsets.UTF_8); + Files.write( + tempDir.resolve("credentialbundle.pem"), + (certPem + "\n" + keyPem).getBytes(StandardCharsets.UTF_8)); + + // Without GOOGLE_API_CERTIFICATE_CONFIG, legacy well-known files (certificates.pem + + // private_key.pem and credentialbundle.pem) are not used by the mTLS transport and must not + // produce a bound token, even if GOOGLE_API_USE_CLIENT_CERTIFICATE is "true". envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", null); assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); - AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); - assertNotNull(info); - assertEquals( - new String(Files.readAllBytes(tempDir.resolve("certificates.pem")), StandardCharsets.UTF_8), - info.getCertContent()); + assertNull(AgentIdentityUtils.getAgentIdentityCertInfo()); } @Test @@ -754,7 +754,7 @@ public void getAgentIdentityCertInfo_implicitDiscovery_bundleWithMismatchedKey_t + "\n-----END PRIVATE KEY-----"; String bundleContent = certPem + "\n" + mismatchedKeyPem; - Path bundleFile = tempDir.resolve("credentialbundle.pem"); + Path bundleFile = tempDir.resolve(AgentIdentityUtils.GKE_CREDENTIAL_BUNDLE_FILE); Files.write(bundleFile, bundleContent.getBytes(StandardCharsets.UTF_8)); envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); @@ -770,7 +770,7 @@ public void getAgentIdentityCertInfo_implicitDiscovery_bundleWithMismatchedKey_t } @Test - public void getAgentIdentityCertInfo_wellKnownBundleFile_verifiesAndStripsPrivateKey() + public void getAgentIdentityCertInfo_configuredBundleFile_verifiesAndStripsPrivateKey() throws Exception { AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); @@ -787,8 +787,10 @@ public void getAgentIdentityCertInfo_wellKnownBundleFile_verifiesAndStripsPrivat String bundleContent = certPem + "\n" + keyPem; Path bundleFile = tempDir.resolve("credentialbundle.pem"); Files.write(bundleFile, bundleContent.getBytes(StandardCharsets.UTF_8)); + Path configFile = tempDir.resolve("config.json"); + writeWorkloadConfig(configFile, bundleFile, bundleFile); - envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, null); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, configFile.toString()); envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_USE_CLIENT_CERTIFICATE, "true"); AgentIdentityUtils.CertInfo info = AgentIdentityUtils.getAgentIdentityCertInfo(); @@ -844,7 +846,7 @@ public void getAgentIdentityCertInfo_implicitWellKnownAccessDenied_returnsNull() throws Exception { Path restrictedDir = tempDir.resolve("restricted_well_known"); Files.createDirectory(restrictedDir); - Path bundleFile = restrictedDir.resolve("credentialbundle.pem"); + Path bundleFile = restrictedDir.resolve(AgentIdentityUtils.GKE_CREDENTIAL_BUNDLE_FILE); Files.write(bundleFile, "test".getBytes(StandardCharsets.UTF_8)); restrictedDir.toFile().setReadable(false, false); @@ -1127,7 +1129,6 @@ public void getAgentIdentityCertInfo_symlinkedWellKnownDir_pollsForStartupFiles( setupValidAgentCredentialsInTempDir(); FakeTimeService fakeTime = new FakeTimeService(); AgentIdentityUtils.setTimeService(fakeTime); - envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); // Initial startup discovery succeeds immediately without polling @@ -1150,7 +1151,6 @@ public void getAgentIdentityCertInfo_startupSucceeded_rotationMomentaryUnlinkRec setupValidAgentCredentialsInTempDir(); FakeTimeService fakeTime = new FakeTimeService(); AgentIdentityUtils.setTimeService(fakeTime); - envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); AgentIdentityUtils.CertInfo info1 = AgentIdentityUtils.getAgentIdentityCertInfo(); @@ -1206,8 +1206,9 @@ public void getAgentIdentityCertInfo_implicitDiscovery_bundleWithoutPrivateKey_t AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); URL certUrl = getClass().getClassLoader().getResource("agent/agent_spiffe_cert.pem"); assertNotNull(certUrl); - // credentialbundle.pem exists without a valid private key, so discovery fails closed - Files.copy(Paths.get(certUrl.toURI()), tempDir.resolve("credentialbundle.pem")); + // GKE bundle exists without a valid private key, so discovery fails closed + Files.copy( + Paths.get(certUrl.toURI()), tempDir.resolve(AgentIdentityUtils.GKE_CREDENTIAL_BUNDLE_FILE)); envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); FakeTimeService fakeTime = new FakeTimeService(); @@ -1364,7 +1365,6 @@ public void getAgentIdentityCertInfo_cachesVerifiedCertInfoAndInvalidatesOnRotat setupValidAgentCredentialsInTempDir(); FakeTimeService fakeTime = new FakeTimeService(); AgentIdentityUtils.setTimeService(fakeTime); - envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); AgentIdentityUtils.CertInfo info1 = AgentIdentityUtils.getAgentIdentityCertInfo(); @@ -1388,7 +1388,6 @@ public void getAgentIdentityCertInfo_cachesVerifiedCertInfoAndInvalidatesOnRotat setupValidAgentCredentialsInTempDir(); FakeTimeService fakeTime = new FakeTimeService(); AgentIdentityUtils.setTimeService(fakeTime); - envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); Path certFile = tempDir.resolve("certificates.pem"); @@ -1433,7 +1432,6 @@ public void getAgentIdentityCertInfo_cachesVerifiedCertInfoAndInvalidatesOnRotat public void getAgentIdentityCertInfo_concurrentThreadsReadingAndRotating_noNpeOrRaceConditions() throws Exception { setupValidAgentCredentialsInTempDir(); - envProvider.setEnv("GOOGLE_API_CERTIFICATE_CONFIG", null); envProvider.setEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE", "true"); Path certFile = tempDir.resolve("certificates.pem"); @@ -1564,7 +1562,7 @@ public void getAgentIdentityCertInfo_configRemovedAfterCaching_fallsBackToCached @Test public void getAgentIdentityCertInfo_wellKnownCacheThenMissingConfig_doesNotUseWellKnownCache( @TempDir Path outsideDir) throws Exception { - setupValidAgentCredentialsInTempDir(); + writeAgentGkeBundle(); envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, null); envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_USE_CLIENT_CERTIFICATE, "true"); assertNotNull(AgentIdentityUtils.getAgentIdentityCertInfo()); @@ -2099,6 +2097,25 @@ public void getAgentIdentityCertInfo_gkeBundleRemovedAfterCaching_configAppears_ assertEquals(0, fakeTime.getSleepCount()); } + @Test + public void + getAgentIdentityCertInfo_afterMtlsUtilsCompletesStartupPoll_doesNotPerformSecondStartupPoll() + throws Exception { + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + FakeTimeService fakeTime = new FakeTimeService(); + AgentIdentityUtils.setTimeService(fakeTime); + envProvider.setEnv( + AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, + tempDir.resolve("missing_config.json").toAbsolutePath().toString()); + + // Simulate MtlsUtils completing initial startup polling during transport creation. + com.google.auth.mtls.MtlsUtils.markInitialStartupCompleted(); + + // Subsequent AgentIdentityUtils call must fail fast without a second 30-second poll. + 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");