Skip to content

Commit 0bae268

Browse files
authored
fix(auth): align mTLS transport and bound token certificate startup polling and well-known discovery (#14748)
### Summary Fixes two certificate discovery asymmetries between mTLS transport initialization (`MtlsUtils` / `X509Provider`) and Agent Identity bound-token acquisition (`AgentIdentityUtils`): 1. **Add 30-second initial startup polling to `MtlsUtils` for the well-known SPIFFE directory:** - When `GOOGLE_API_CERTIFICATE_CONFIG` (or `certConfigPathOverride`) and its referenced `cert_path` / `key_path` reside in `/var/run/secrets/workload-spiffe-credentials/` (or `/run/secrets/workload-spiffe-credentials/`), `MtlsUtils` polls for up to 30 seconds (`50 × 100ms` + `50 × 500ms`) until the configuration file, `cert_path`, and `key_path` are non-empty readable regular files and the JSON configuration parses cleanly. - `MtlsUtils.waitForWorkloadConfigStartupIfNeeded(...)` consolidates startup config polling across both `MtlsUtils` (`X509Provider`) and `AgentIdentityUtils`. - Non-retryable states (missing files outside the well-known SPIFFE directory, directories, unreadable files, valid ECP-only configs, or incomplete workload configs missing `cert_path`/`key_path`) fail immediately with 0 sleeps. 2. **Remove unconfigured legacy `credentialbundle.pem` and `certificates.pem` + `private_key.pem` fallbacks in `AgentIdentityUtils`:** - When `GOOGLE_API_CERTIFICATE_CONFIG` is unset, `AgentIdentityUtils.getWellKnownCertificatePathWithRetry()` now checks only the GKE Pod Certificate bundle (`/var/run/secrets/workload-spiffe-credentials/x509.credential-bundle.private-key.pem`), matching `MtlsUtils.getWorkloadCertPath()` and `X509Provider` 1:1 so `AgentIdentityUtils` never mints a certificate-bound token for a certificate that the transport ignores. ### Testing - **Unit Tests:** Added unit tests in `MtlsUtilsTest`, `X509ProviderTest`, and `AgentIdentityUtilsTest` covering: - Delayed config and cert/key creation in `/var/run/secrets/workload-spiffe-credentials/` - Partial/truncated JSON writes during startup polling - 0-byte certificate/key files during startup polling - Startup poll timeout (99 sleeps) and fail-fast (0 sleeps) for paths outside the well-known SPIFFE directory - Ignoring unconfigured `credentialbundle.pem` and `certificates.pem` + `private_key.pem` when `GOOGLE_API_CERTIFICATE_CONFIG` is unset - **Manual & Live Cloud Run Verification:** - Full test setup, 6-scenario local end-to-end simulation output, and live Cloud Run Agent Identity (`--functional-type=agent --identity-type=agent-identity`) execution logs (`agent-bound-token-java-test-4mrbz`): https://paste.googleplex.com/6755399436524544
1 parent 919a4db commit 0bae268

6 files changed

Lines changed: 705 additions & 321 deletions

File tree

‎google-auth-library-java/oauth2_http/java/com/google/auth/mtls/MtlsUtils.java‎

Lines changed: 286 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,18 @@
3535
import com.google.auth.oauth2.PropertyProvider;
3636
import com.google.common.annotations.VisibleForTesting;
3737
import com.google.common.base.Strings;
38+
import com.google.common.collect.ImmutableList;
3839
import com.google.common.io.BaseEncoding;
3940
import java.io.File;
4041
import java.io.FileInputStream;
4142
import java.io.IOException;
4243
import java.io.InputStream;
44+
import java.io.InterruptedIOException;
45+
import java.nio.file.AccessDeniedException;
4346
import java.nio.file.Files;
47+
import java.nio.file.Path;
4448
import java.nio.file.Paths;
49+
import java.nio.file.attribute.BasicFileAttributes;
4550
import java.security.MessageDigest;
4651
import java.util.Locale;
4752
import org.jspecify.annotations.NullMarked;
@@ -59,21 +64,258 @@ public class MtlsUtils {
5964
static final String WELL_KNOWN_CERTIFICATE_CONFIG_FILE = "certificate_config.json";
6065
static final String CLOUDSDK_CONFIG_DIRECTORY = "gcloud";
6166

67+
/**
68+
* Well-known directory where workload SPIFFE credentials and certificate configurations are
69+
* delivered at container startup.
70+
*/
71+
@InternalApi
72+
public static final String WELL_KNOWN_SPIFFE_DIR =
73+
"/var/run/secrets/workload-spiffe-credentials/";
74+
75+
/**
76+
* Alternate well-known directory path where workload SPIFFE credentials and certificate
77+
* configurations may be mounted (for example, when {@code /run/secrets} is mounted directly or
78+
* referenced via resolved {@code /run} paths).
79+
*/
80+
@InternalApi
81+
public static final String WELL_KNOWN_RUN_SPIFFE_DIR =
82+
"/run/secrets/workload-spiffe-credentials/";
83+
84+
private static final ImmutableList<Path> DEFAULT_WELL_KNOWN_SPIFFE_DIRS =
85+
ImmutableList.of(
86+
Paths.get(WELL_KNOWN_SPIFFE_DIR).toAbsolutePath().normalize(),
87+
Paths.get(WELL_KNOWN_RUN_SPIFFE_DIR).toAbsolutePath().normalize());
88+
6289
/**
6390
* Well-known path of the combined certificate chain and private key bundle that GKE delivers to
6491
* pods with Agent Identity (Pod Certificates). Used as a fallback client certificate source when
6592
* no certificate configuration file is configured or present.
6693
*/
6794
static final String GKE_CREDENTIAL_BUNDLE_PATH =
68-
"/var/run/secrets/workload-spiffe-credentials/x509.credential-bundle.private-key.pem";
95+
WELL_KNOWN_SPIFFE_DIR + "x509.credential-bundle.private-key.pem";
96+
97+
// Polling configuration for initial container startup credential file readiness when
98+
// GOOGLE_API_CERTIFICATE_CONFIG and its referenced cert/key files reside in the well-known
99+
// SPIFFE directory (50 * 100ms + 50 * 500ms = 30 seconds total).
100+
private static final int FAST_POLL_CYCLES = 50;
101+
private static final long FAST_POLL_INTERVAL_MS = 100; // 0.1 seconds
102+
private static final long SLOW_POLL_INTERVAL_MS = 500; // 0.5 seconds
103+
private static final long TOTAL_TIMEOUT_MS = 30000; // 30 seconds
104+
105+
/** Total startup polling timeout in seconds (30s). */
106+
@InternalApi public static final long STARTUP_POLL_TIMEOUT_SECONDS = TOTAL_TIMEOUT_MS / 1000;
107+
108+
private static final int TOTAL_POLL_CYCLES =
109+
FAST_POLL_CYCLES
110+
+ (int)
111+
((TOTAL_TIMEOUT_MS - (FAST_POLL_CYCLES * FAST_POLL_INTERVAL_MS))
112+
/ SLOW_POLL_INTERVAL_MS);
69113

70114
// Overridable via setGkeCredentialBundlePathForTesting so unit tests can point to a temp file.
71115
private static volatile String gkeCredentialBundlePath = GKE_CREDENTIAL_BUNDLE_PATH;
72116

117+
// Overridable via setWellKnownSpiffeDirForTesting so unit tests can point to a temp directory.
118+
private static volatile String wellKnownSpiffeDir = WELL_KNOWN_SPIFFE_DIR;
119+
120+
private static volatile TimeService timeService = Thread::sleep;
121+
122+
/**
123+
* Functional interface for sleeping during startup credential polling. Overridable for testing.
124+
*/
125+
@InternalApi
126+
@VisibleForTesting
127+
@FunctionalInterface
128+
public interface TimeService {
129+
void sleep(long millis) throws InterruptedException;
130+
}
131+
73132
private MtlsUtils() {
74133
// Prevent instantiation for Utility class
75134
}
76135

136+
private static long getSleepIntervalMs(int cycle) {
137+
return (cycle < FAST_POLL_CYCLES) ? FAST_POLL_INTERVAL_MS : SLOW_POLL_INTERVAL_MS;
138+
}
139+
140+
/**
141+
* Overrides the well-known SPIFFE directory path for testing; {@code null} restores the default.
142+
*/
143+
@VisibleForTesting
144+
static void setWellKnownSpiffeDirForTesting(@Nullable String dir) {
145+
wellKnownSpiffeDir = dir != null ? dir : WELL_KNOWN_SPIFFE_DIR;
146+
}
147+
148+
/** Overrides the sleep service for testing; {@code null} restores {@code Thread::sleep}. */
149+
@VisibleForTesting
150+
static void setTimeServiceForTesting(@Nullable TimeService service) {
151+
timeService = service != null ? service : Thread::sleep;
152+
}
153+
154+
/**
155+
* Checks whether {@code pathStr} resides within {@code targetWellKnownDir} (or either of the
156+
* default well-known SPIFFE directories {@link #WELL_KNOWN_SPIFFE_DIR} and {@link
157+
* #WELL_KNOWN_RUN_SPIFFE_DIR} when {@code targetWellKnownDir} is {@link #WELL_KNOWN_SPIFFE_DIR}).
158+
*/
159+
@InternalApi
160+
public static boolean isPathInWellKnownDir(@Nullable String pathStr, String targetWellKnownDir) {
161+
if (Strings.isNullOrEmpty(pathStr) || Strings.isNullOrEmpty(targetWellKnownDir)) {
162+
return false;
163+
}
164+
try {
165+
Path path = Paths.get(pathStr).toAbsolutePath().normalize();
166+
if (WELL_KNOWN_SPIFFE_DIR.equals(targetWellKnownDir)) {
167+
for (Path defaultDir : DEFAULT_WELL_KNOWN_SPIFFE_DIRS) {
168+
if (path.startsWith(defaultDir)) {
169+
return true;
170+
}
171+
}
172+
return false;
173+
}
174+
Path wellKnown = Paths.get(targetWellKnownDir).toAbsolutePath().normalize();
175+
return path.startsWith(wellKnown);
176+
} catch (Exception e) {
177+
return false;
178+
}
179+
}
180+
181+
private static boolean isNonEmptyReadableFile(File file) {
182+
return file.isFile() && file.canRead() && file.length() > 0;
183+
}
184+
185+
private static boolean isExistingNonFileOrUnreadable(File file) {
186+
Path path = file.toPath();
187+
try {
188+
BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
189+
if (!attrs.isRegularFile()) {
190+
return true;
191+
}
192+
return !Files.isReadable(path) && Files.exists(path);
193+
} catch (AccessDeniedException e) {
194+
return true;
195+
} catch (IOException e) {
196+
return false;
197+
}
198+
}
199+
200+
/**
201+
* Polls up to 30 seconds when {@code configFile} and its referenced {@code cert_path} and {@code
202+
* key_path} reside within {@code targetWellKnownDir} until the configuration and referenced
203+
* certificate/key files are non-empty readable files, or a non-retryable state is encountered.
204+
*
205+
* @return {@code true} if startup polling was active for {@code configFile}, {@code false}
206+
* otherwise
207+
* @throws IOException if the thread is interrupted while sleeping during startup polling
208+
*/
209+
@InternalApi
210+
public static boolean waitForWorkloadConfigStartupIfNeeded(
211+
File configFile,
212+
String targetWellKnownDir,
213+
TimeService sleepService,
214+
@Nullable Runnable onFirstPollSleep)
215+
throws IOException {
216+
boolean shouldPoll = isPathInWellKnownDir(configFile.getPath(), targetWellKnownDir);
217+
try {
218+
waitForExplicitConfigStartupIfNeeded(
219+
configFile, targetWellKnownDir, sleepService, onFirstPollSleep);
220+
} catch (InterruptedIOException e) {
221+
throw new IOException(
222+
"Interrupted while waiting for Agent Identity certificate files for bound"
223+
+ " token request.",
224+
e.getCause() != null ? e.getCause() : e);
225+
} catch (Exception ignored) {
226+
// Non-retryable parse or config errors are handled by the caller's single-pass evaluation.
227+
}
228+
return shouldPoll;
229+
}
230+
231+
/**
232+
* Polls up to 30 seconds when {@code configFile} and its referenced {@code cert_path} and {@code
233+
* key_path} reside within the well-known SPIFFE directory until the configuration and referenced
234+
* certificate/key files are non-empty readable files.
235+
*/
236+
private static @Nullable WorkloadCertificateConfiguration waitForExplicitConfigStartupIfNeeded(
237+
File configFile) throws IOException {
238+
return waitForExplicitConfigStartupIfNeeded(configFile, wellKnownSpiffeDir, timeService, null);
239+
}
240+
241+
private static @Nullable WorkloadCertificateConfiguration waitForExplicitConfigStartupIfNeeded(
242+
File configFile,
243+
String targetWellKnownDir,
244+
TimeService sleepService,
245+
@Nullable Runnable onFirstPollSleep)
246+
throws IOException {
247+
boolean shouldPoll = isPathInWellKnownDir(configFile.getPath(), targetWellKnownDir);
248+
int maxCycles = shouldPoll ? TOTAL_POLL_CYCLES : 1;
249+
boolean warned = false;
250+
251+
for (int cycle = 0; cycle < maxCycles; cycle++) {
252+
if (isExistingNonFileOrUnreadable(configFile)) {
253+
// Directory or permission denied on an existing file will not resolve via startup polling.
254+
break;
255+
}
256+
if (isNonEmptyReadableFile(configFile)) {
257+
try (InputStream certConfigStream = new FileInputStream(configFile)) {
258+
WorkloadCertificateConfiguration config =
259+
WorkloadCertificateConfiguration.fromCertificateConfigurationStream(certConfigStream);
260+
if (!shouldPoll) {
261+
return config;
262+
}
263+
File certFile = new File(config.getCertPath());
264+
File keyFile = new File(config.getPrivateKeyPath());
265+
if (isExistingNonFileOrUnreadable(certFile) || isExistingNonFileOrUnreadable(keyFile)) {
266+
// Referenced certificate or key is a directory or unreadable; fail without polling.
267+
break;
268+
}
269+
if (isNonEmptyReadableFile(certFile) && isNonEmptyReadableFile(keyFile)) {
270+
return config;
271+
}
272+
if (!isPathInWellKnownDir(config.getCertPath(), targetWellKnownDir)
273+
|| !isPathInWellKnownDir(config.getPrivateKeyPath(), targetWellKnownDir)) {
274+
// Startup polling only applies when the config file AND its referenced cert_path and
275+
// key_path all reside within the well-known directory.
276+
break;
277+
}
278+
} catch (CertificateSourceUnavailableException e) {
279+
// Valid JSON without a workload section (e.g. ECP configuration); do not poll.
280+
throw e;
281+
} catch (IllegalArgumentException | ClassCastException e) {
282+
// Valid JSON missing or mistyped cert_configs, cert_path, or key_path; do not poll.
283+
throw e;
284+
} catch (Exception e) {
285+
if (!shouldPoll) {
286+
if (e instanceof IOException) {
287+
throw (IOException) e;
288+
}
289+
if (e instanceof RuntimeException) {
290+
throw (RuntimeException) e;
291+
}
292+
throw new IOException(e);
293+
}
294+
// When polling in the well-known SPIFFE directory, retry on transient partial JSON
295+
// writes (which may throw IOException or NullPointerException at EOF in JsonParser).
296+
}
297+
}
298+
if (cycle + 1 < maxCycles) {
299+
if (shouldPoll && !warned && onFirstPollSleep != null) {
300+
onFirstPollSleep.run();
301+
warned = true;
302+
}
303+
try {
304+
sleepService.sleep(getSleepIntervalMs(cycle));
305+
} catch (InterruptedException e) {
306+
Thread.currentThread().interrupt();
307+
InterruptedIOException interrupted =
308+
new InterruptedIOException(
309+
"Interrupted while waiting for workload certificate configuration at "
310+
+ configFile.getPath());
311+
interrupted.initCause(e);
312+
throw interrupted;
313+
}
314+
}
315+
}
316+
return null;
317+
}
318+
77319
/**
78320
* Returns {@code true} if a certificate configuration is configured or present: either {@code
79321
* GOOGLE_API_CERTIFICATE_CONFIG} is set, or the well-known gcloud {@code certificate_config.json}
@@ -194,6 +436,20 @@ public static boolean useMtlsClientCertificate(
194436
// 1. Explicit Configuration Path (Fail Closed)
195437
if (!Strings.isNullOrEmpty(explicitConfigPath)) {
196438
File configFile = new File(explicitConfigPath);
439+
WorkloadCertificateConfiguration config = null;
440+
try {
441+
config = waitForExplicitConfigStartupIfNeeded(configFile);
442+
} catch (CertificateSourceUnavailableException e) {
443+
// ECP / PKCS11 configuration without workload section; safe fallback
444+
return null;
445+
} catch (Exception e) {
446+
throw new IllegalStateException(
447+
"Certificate configuration file specified via GOOGLE_API_CERTIFICATE_CONFIG at '"
448+
+ explicitConfigPath
449+
+ "' is malformed: "
450+
+ e.getMessage(),
451+
e);
452+
}
197453
if (!configFile.exists()) {
198454
throw new IllegalStateException(
199455
"Certificate configuration file specified via GOOGLE_API_CERTIFICATE_CONFIG at '"
@@ -207,19 +463,21 @@ public static boolean useMtlsClientCertificate(
207463
+ explicitConfigPath
208464
+ "'.");
209465
}
210-
WorkloadCertificateConfiguration config;
211-
try {
212-
config = getWorkloadCertificateConfiguration(envProvider, propProvider, explicitConfigPath);
213-
} catch (CertificateSourceUnavailableException e) {
214-
// ECP / PKCS11 configuration without workload section; safe fallback
215-
return null;
216-
} catch (Exception e) {
217-
throw new IllegalStateException(
218-
"Certificate configuration file specified via GOOGLE_API_CERTIFICATE_CONFIG at '"
219-
+ explicitConfigPath
220-
+ "' is malformed: "
221-
+ e.getMessage(),
222-
e);
466+
if (config == null) {
467+
try (InputStream certConfigStream = new FileInputStream(configFile)) {
468+
config =
469+
WorkloadCertificateConfiguration.fromCertificateConfigurationStream(certConfigStream);
470+
} catch (CertificateSourceUnavailableException e) {
471+
// ECP / PKCS11 configuration without workload section; safe fallback
472+
return null;
473+
} catch (Exception e) {
474+
throw new IllegalStateException(
475+
"Certificate configuration file specified via GOOGLE_API_CERTIFICATE_CONFIG at '"
476+
+ explicitConfigPath
477+
+ "' is malformed: "
478+
+ e.getMessage(),
479+
e);
480+
}
223481
}
224482
checkCertAndKeyFilesReadable(config, explicitConfigPath, false);
225483
return config.getCertPath();
@@ -270,7 +528,7 @@ private static void checkCertAndKeyFilesReadable(
270528
WorkloadCertificateConfiguration config, String configPath, boolean isDefaultConfig) {
271529
File certFile = new File(config.getCertPath());
272530
File keyFile = new File(config.getPrivateKeyPath());
273-
if (!certFile.isFile() || !certFile.canRead() || !keyFile.isFile() || !keyFile.canRead()) {
531+
if (!isNonEmptyReadableFile(certFile) || !isNonEmptyReadableFile(keyFile)) {
274532
String sourcePrefix =
275533
isDefaultConfig
276534
? "referenced by default configuration '"
@@ -353,17 +611,30 @@ static WorkloadCertificateConfiguration getWorkloadCertificateConfiguration(
353611
@Nullable String certConfigPathOverride)
354612
throws IOException {
355613
File certConfig;
614+
boolean isExplicitConfig = false;
356615
if (certConfigPathOverride != null) {
357616
certConfig = new File(certConfigPathOverride);
617+
isExplicitConfig = true;
358618
} else {
359619
String envCredentialsPath = envProvider.getEnv(CERTIFICATE_CONFIGURATION_ENV_VARIABLE);
360620
if (!Strings.isNullOrEmpty(envCredentialsPath)) {
361621
certConfig = new File(envCredentialsPath);
622+
isExplicitConfig = true;
362623
} else {
363624
certConfig = getWellKnownCertificateConfigFile(envProvider, propProvider);
364625
}
365626
}
366627

628+
String rawUseClientCert = envProvider.getEnv("GOOGLE_API_USE_CLIENT_CERTIFICATE");
629+
String useClientCert = rawUseClientCert != null ? rawUseClientCert.trim() : null;
630+
if (isExplicitConfig && !isUseClientCertificateExplicitlyDisabled(useClientCert)) {
631+
WorkloadCertificateConfiguration polledConfig =
632+
waitForExplicitConfigStartupIfNeeded(certConfig);
633+
if (polledConfig != null) {
634+
return polledConfig;
635+
}
636+
}
637+
367638
if (!certConfig.isFile() || !certConfig.canRead()) {
368639
throw new CertificateSourceUnavailableException(
369640
"Certificate configuration file does not exist or is not a file: "

‎google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/AgentIdentityCacheUtils.java‎

Lines changed: 0 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -242,12 +242,6 @@ static boolean isCachedInfoValid(
242242
if (cached.configMetadata != null) {
243243
return false;
244244
}
245-
if (cached.certMetadata.path.endsWith("certificates.pem")
246-
&& Files.exists(
247-
Paths.get(AgentIdentityUtils.getWellKnownDir(), "credentialbundle.pem"))) {
248-
// credentialbundle.pem takes precedence if added after certificates.pem was cached
249-
return false;
250-
}
251245
}
252246
if (!cached.certMetadata.isUnchangedOnDisk()) {
253247
return false;

0 commit comments

Comments
 (0)