Skip to content

Commit acc7c9d

Browse files
macastelazvverman
andauthored
feat: Enable Bound Token for Agentic Identities (#13873)
This PR introduces a feature which enables the auth library to acquire bound access-tokens and bound id-tokens in Agentic Environments. 1) We detect certs in default paths and check if they match the SPIFFE format for agents. 2) If 1. is a yes then we call the MDS endpoint in a POST request with the certificate in the body. Note this PR was based on #13169 --- ## Manual Testing & End-to-End Verification We verified this feature end-to-end across both a **Live Cloud Run Agent Identity environment** (testing against the live Google Metadata Server, Security Token Service, and Vertex AI with the **Java Agent Development Kit (`com.google.adk:google-adk:1.9.0`)**) and a **10-Scenario Local Mock MDS Simulation Harness** (testing exact HTTP request payloads, true cryptographic certificate/key rotation, combined bundle private-key stripping, well-known directory discovery, non-agent SPIFFE fallback, environment variable precedence, non-atomic rotation retries, and asynchronous container startup polling). ### 1. Live Cloud Run Agent Identity Verification (`<PROJECT_ID>`, `us-central1`) We deployed a containerized Java test application built against this branch (`google-auth-library-oauth2-http:1.50.0-SNAPSHOT` @ `27776f6d62a`) + Java ADK (`com.google.adk:google-adk:1.9.0`) to Cloud Run with Agent Identity enabled (`--functional-type=agent --identity-type=agent-identity`). #### Execution A: Default Bound Token Acquisition (`GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN` unset / default `true`) - **Cloud Run Job Executions**: `agent-bound-token-java-test-cjrgp` (latest run with Step 4D) & `agent-bound-token-java-test-dd9gm` - **Workload Certificate Discovery**: - Detected `/var/run/secrets/workload-spiffe-credentials/credentials.json` - Extracted Leaf Certificate SAN URI: `spiffe://agents.global.org-<ORG_NUMBER>.system.id.goog/resources/run/projects/<PROJECT_NUMBER>/locations/us-central1/jobs/agent-bound-token-java-test` - Leaf Certificate SHA-256 (`x5t#S256`): `5Ggf2ofOIkHkvBAwfku6eKQVI5pYzW50whzv-VwSAM8` - **Bound Access Token (`ComputeEngineCredentials`)**: - Successfully acquired bound OAuth2 Access Token via `POST` to Metadata Server (`ya29.d.c0AZ4bNp...`). - **Bound ID Token (`IdTokenCredentials`) & Cryptographic Binding Verification (RFC 8705 § 3.1)**: - Acquired bound ID Token for target audience `https://example-target-service.run.app`. - Decoded JWT payload inline (`textPayload`) and verified live Google STS embedded the `cnf` (Confirmation) claim containing the SHA-256 thumbprint (`x5t#S256`) of the workload's leaf X.509 certificate: ```json "cnf": { "x5t#S256": "5Ggf2ofOIkHkvBAwfku6eKQVI5pYzW50whzv-VwSAM8" } ``` - Verified an **exact byte-for-byte match** (`[PASS] JWT x5t#S256 thumbprint EXACTLY matches local leaf certificate SHA-256!`). - **Java ADK (`com.google.adk:google-adk:1.9.0`) + `google-genai` & mTLS Proof-of-Possession Verification (Steps 4A, 4B, 4C, 4D)**: - **Step 4A (ADK `HttpClientFactory` Non-mTLS Transport)**: Inspected ADK's shared `OkHttpClient` (`sun.security.ssl.SSLSocketFactoryImpl`, no client cert). Calling Google APIs over this non-mTLS channel with the bound token is rejected at the auth layer with **`HTTP 401 UNAUTHENTICATED`**. - **Step 4B (Live ADK `LlmAgent` + `Gemini` Turn on Vertex AI)**: Invoking `InMemoryRunner.runAsync(...)` with `Gemini` (`gemini-2.5-flash`) fails on turn 1 with `com.google.genai.errors.ClientException: 401 . Request had invalid authentication credentials`, confirming the known limitation where `google-genai` sends bound tokens over a non-mTLS channel. - **Step 4C (True mTLS Contrast Test — Matching Workload Certificate)**: Presenting the **exact same bound access token** over a true mTLS `OkHttpClient` (configured with `/var/run/secrets/workload-spiffe-credentials/certificates.pem` + `private_key.pem`, `x5t#S256 = 5Ggf2ofOIkHkvBAwfku6eKQVI5pYzW50whzv-VwSAM8`) to `https://cloudresourcemanager.mtls.googleapis.com/v1/projects/<PROJECT_ID>` **passes authentication** (`HTTP 403 PERMISSION_DENIED` IAM check instead of `401 UNAUTHENTICATED`), proving Google API Frontend verified the token binding against the TLS client certificate handshake. - **Step 4D (Sender-Constraining / Proof-of-Possession Negative Test — Mismatched Client Certificate)**: Presenting the **exact same bound access token** to `https://cloudresourcemanager.mtls.googleapis.com/v1/projects/<PROJECT_ID>` over an mTLS `OkHttpClient` configured with a **different** X.509 client certificate (`x5t#S256 = -57ZjYVm89oczfdO02Hf3Sz-FaYQIVH1DD3c9zaBIKA`) is **rejected at the auth layer with `HTTP 401 UNAUTHENTICATED`**, confirming that Google API Frontend enforces cryptographic thumbprint matching (`cnf.x5t#S256 == SHA256(TLS client cert)`). #### Execution B: Opt-Out Unbound Token Acquisition (`GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false`) - **Cloud Run Job Execution**: `agent-bound-token-java-test-t7b98` - Updated job environment variable `--set-env-vars="GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false,GOOGLE_CLOUD_LOCATION=global,GOOGLE_GENAI_USE_VERTEXAI=true"`. - Verified `ComputeEngineCredentials` and `IdTokenCredentials` fell back to standard `HTTP GET` requests against MDS and issued standard unbound tokens (decoded JWT payload confirmed absence of the `cnf` claim). - **Java ADK Live Agent Turn (`Step 4B`)**: With `GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false`, the unbound token works over `google-genai`'s non-mTLS channel and the live ADK `LlmAgent` + `Gemini` turn **SUCCEEDS**: `[ADK Event] author=bound-token-verify-agent, content=Hello! Yes, as an agent designed to test bound token behavior with the Java ADK, I am expected to receive and process bound tokens.` --- ### 2. Local End-to-End Simulation & Wire Verification (10 Scenarios) To verify internal wire-level, discovery, rotation, environment-variable, and error-handling behavior between the client and MDS, we executed our local simulation suite (`LocalSimulationRunner.java`) spinning up a local Mock MDS (`HttpServer`) across **10 end-to-end scenarios**: 1. **Bound Access Token (`ComputeEngineCredentials` + `GOOGLE_API_CERTIFICATE_CONFIG`)**: Verified `HTTP POST` to `/computeMetadata/v1/instance/service-accounts/default/token?scopes=https://www.googleapis.com/auth/cloud-platform`, verified JSON body `{"certificate_chain": "-----BEGIN CERTIFICATE-----\n..."}` (serialized as a single PEM string), and verified no extra fields are included in the JSON payload. 2. **Bound ID Token (`IdTokenCredentials` + `GOOGLE_API_CERTIFICATE_CONFIG`)**: Verified `HTTP POST` to `/computeMetadata/v1/instance/service-accounts/default/identity?audience=https://target.run.app` (with `audience` passed as a URL query parameter) and JSON body `{"certificate_chain": "-----BEGIN CERTIFICATE-----\n..."}`. 3. **True Live Cryptographic Certificate & Key Rotation on Disk (`Gen-1` $\rightarrow$ `Gen-2`)**: Generated a new X.509 SPIFFE certificate and matching 2048-bit RSA key pair on disk, updated file `mtime`, and verified `AgentIdentityUtils.getAgentIdentityCertInfo()` invalidated its cache, verified the new key pair, and transmitted the rotated `Gen-2` certificate chain (`!req3.body.equals(req1.body)`). 4. **Opt-Out Override (`GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false`)**: Verified setting `GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false` switches requests back to `HTTP GET` with an empty request body. 5. **Well-Known Directory Combined Bundle (`credentialbundle.pem`) + Private Key Stripping**: Unset `GOOGLE_API_CERTIFICATE_CONFIG`, wrote a combined `credentialbundle.pem` containing **both** `-----BEGIN CERTIFICATE-----` and `-----BEGIN PRIVATE KEY-----` in the well-known directory, and verified that `AgentIdentityUtils` resolved `certPath == keyPath`, verified the key pair, and **stripped the `PRIVATE KEY` block** from the transmitted `POST` payload. 6. **Well-Known Directory Separate Files (`certificates.pem` + `private_key.pem`)**: Unset `GOOGLE_API_CERTIFICATE_CONFIG`, removed `credentialbundle.pem`, placed separate `certificates.pem` and `private_key.pem` in the well-known directory, and verified bound `HTTP POST` acquisition. 7. **Non-Agent SPIFFE Trust Domain Fallback (`shouldRequestBoundToken == false`)**: Configured a valid X.509 certificate with a standard GKE Workload Identity SAN (`spiffe://my-standard-gke-project.svc.id.goog/ns/default/sa/my-ksa`); verified `AgentIdentityUtils` did not throw, cached `shouldRequestBoundToken = false`, and fell back to standard `HTTP GET`. 8. **Environment Variable Precedence & `GOOGLE_API_USE_CLIENT_CERTIFICATE` Matrix**: - **8a**: Legacy `GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES=false` (with `GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN` unset) $\rightarrow$ falls back to `HTTP GET`. - **8b**: Modern `GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=true` overrides legacy `GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES=false` $\rightarrow$ sends bound `HTTP POST`. - **8c**: `GOOGLE_API_USE_CLIENT_CERTIFICATE=false` with valid agent certs on disk $\rightarrow$ disables bound token (`HTTP GET`) without startup polling. - **8d**: `GOOGLE_API_USE_CLIENT_CERTIFICATE=true` with missing cert files $\rightarrow$ fails closed with `IOException` after retries instead of silently downgrading to an unbound token. 9. **Non-Atomic Rotation / Transient Key-Pair Mismatch Recovery (`CERT_KEY_MATCH_RETRIES`)**: Wrote a new `Gen-3` non-prod SPIFFE certificate (`spiffe://agents-nonprod.global.org-54321.system.id.goog/...`) first while delaying the matching `private_key.pem` update on a background thread; verified `loadAndVerifyCredentials()` retried cleanly via `CERT_KEY_MATCH_RETRIES` and transmitted `Gen-3`. 10. **Asynchronous Container Startup Credential Delivery Polling (`TOTAL_POLL_CYCLES`)**: Started with an empty well-known directory on initial startup (`GOOGLE_API_USE_CLIENT_CERTIFICATE=true`), delivered `certificates.pem` + `private_key.pem` asynchronously from a background thread after ~180ms, and verified the initial `refreshAccessToken()` polled until the files arrived and succeeded with a bound `HTTP POST`. --- ### 3. Reproducible Test Artifacts & Execution Logs (`gpaste` - Internal Corp Access Only) | Artifact / Log | Description | Link | | :--- | :--- | :--- | | **Combined Verification Summary & Logs** | Complete report & console outputs from Live Cloud Run (Bound `cjrgp` & Opt-Out `t7b98`) + Java ADK + Local 10-Scenario Simulation | https://paste.googleplex.com/4671448266964992 | | **`CloudRunAgentVerifyApp.java`** | Live Cloud Run + Java ADK verification app (validates SPIFFE cert, ADC access token, ID token `cnf.x5t#S256` match, and ADK Steps 4A/4B/4C/4D) | https://paste.googleplex.com/5431546950057984 | | **`LocalSimulationRunner.java`** | Self-contained local E2E test harness with Mock MDS (`HttpServer`) testing all 10 scenarios | https://paste.googleplex.com/4541159570014208 | | **`Dockerfile`** | Multi-stage Docker build resolving Java ADK 1.9.0 + `google-genai` and overlaying our local PR JAR | https://paste.googleplex.com/6569302979903488 | | **`deploy_cloud_run_job.sh`** | Turnkey script to build via Cloud Build, deploy Cloud Run Job with `--identity-type=agent-identity`, and execute | https://paste.googleplex.com/4908078735163392 | | **`build_and_run_local.sh`** | Turnkey script to compile and run the local simulation | https://paste.googleplex.com/6449866457350144 | | **Raw Cloud Logging — Bound Run (`cjrgp`)** | Plain-text Cloud Run logs from `gcloud logging read` for default Bound Token execution (with inline decoded JWT & ADK Steps 4A/4B/4C/4D) | https://paste.googleplex.com/5404054864396288 | | **Raw Cloud Logging — Opt-Out Run (`t7b98`)** | Plain-text Cloud Run logs from `gcloud logging read` for `GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false` execution (showing live ADK Gemini response) | https://paste.googleplex.com/5990735250325504 | #### Quick Reproduction Steps To replicate the live Cloud Run test in any GCP project with Agent Identity enabled: ```bash # 1. Place CloudRunAgentVerifyApp.java, LocalSimulationRunner.java, Dockerfile, and deploy_cloud_run_job.sh # in a directory alongside your local google-cloud-java checkout. chmod +x deploy_cloud_run_job.sh build_and_run_local.sh # 2. Run the local 10-scenario simulation suite: ./build_and_run_local.sh # 3. Deploy and execute the Cloud Run Job with Agent Identity enabled (Default Bound Token mode): ./deploy_cloud_run_job.sh <PROJECT_ID> us-central1 # 4. To test opt-out behavior (unblocks ADK non-mTLS Gemini calls): gcloud alpha run jobs update agent-bound-token-java-test \ --project=<PROJECT_ID> --region=us-central1 \ --set-env-vars="GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false,GOOGLE_CLOUD_LOCATION=global,GOOGLE_GENAI_USE_VERTEXAI=true" gcloud alpha run jobs execute agent-bound-token-java-test \ --project=<PROJECT_ID> --region=us-central1 --wait ``` --------- Co-authored-by: Pranav Iyer <pjiyer@google.com>
1 parent a86cc11 commit acc7c9d

16 files changed

Lines changed: 3860 additions & 26 deletions
Lines changed: 358 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,358 @@
1+
/*
2+
* Copyright 2026, Google Inc. All rights reserved.
3+
*
4+
* Redistribution and use in source and binary forms, with or without
5+
* modification, are permitted provided that the following conditions are
6+
* met:
7+
*
8+
* * Redistributions of source code must retain the above copyright
9+
* notice, this list of conditions and the following disclaimer.
10+
* * Redistributions in binary form must reproduce the above
11+
* copyright notice, this list of conditions and the following disclaimer
12+
* in the documentation and/or other materials provided with the
13+
* distribution.
14+
*
15+
* * Neither the name of Google Inc. nor the names of its
16+
* contributors may be used to endorse or promote products derived from
17+
* this software without specific prior written permission.
18+
*
19+
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
20+
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
21+
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
22+
* A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
23+
* OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
24+
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
25+
* LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
26+
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
27+
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
28+
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
29+
* OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
30+
*/
31+
package com.google.auth.oauth2;
32+
33+
import com.google.auth.oauth2.AgentIdentityUtils.CertInfo;
34+
import com.google.common.base.Strings;
35+
import java.io.IOException;
36+
import java.nio.file.AccessDeniedException;
37+
import java.nio.file.Files;
38+
import java.nio.file.Path;
39+
import java.nio.file.Paths;
40+
import java.nio.file.attribute.BasicFileAttributes;
41+
import java.nio.file.attribute.FileTime;
42+
import java.util.Objects;
43+
import org.jspecify.annotations.NullMarked;
44+
import org.jspecify.annotations.Nullable;
45+
46+
/**
47+
* Utility class for in-memory caching and filesystem metadata validation of Agent Identity
48+
* certificates and configuration files.
49+
*/
50+
@NullMarked
51+
final class AgentIdentityCacheUtils {
52+
53+
// In-memory cache of verified Agent Identity info to avoid redundant disk reads, X.509/PKCS#8
54+
// parsing, and cryptographic signature verification on every token refresh when files are
55+
// unchanged.
56+
private static volatile @Nullable CachedAgentIdentityInfo cachedAgentIdentityInfo;
57+
58+
private AgentIdentityCacheUtils() {}
59+
60+
/**
61+
* Captures filesystem metadata (path, modification time, size, and OS file key/inode) for a
62+
* certificate, key, or configuration file to detect rotation on disk without re-parsing unchanged
63+
* files.
64+
*/
65+
static final class FileMetadata {
66+
private final String path;
67+
private final FileTime lastModifiedTime;
68+
private final long size;
69+
private final @Nullable Object fileKey;
70+
71+
private FileMetadata(
72+
final String path,
73+
final FileTime lastModifiedTime,
74+
final long size,
75+
final @Nullable Object fileKey) {
76+
this.path = path;
77+
this.lastModifiedTime = lastModifiedTime;
78+
this.size = size;
79+
this.fileKey = fileKey;
80+
}
81+
82+
/** Returns the filesystem path captured by this metadata snapshot. */
83+
String getPath() {
84+
return path;
85+
}
86+
87+
/** Reads the current filesystem attributes for the given file path. */
88+
static @Nullable FileMetadata of(final @Nullable String pathStr) throws IOException {
89+
if (Strings.isNullOrEmpty(pathStr)) {
90+
return null;
91+
}
92+
Path path = Paths.get(pathStr);
93+
BasicFileAttributes attrs = Files.readAttributes(path, BasicFileAttributes.class);
94+
return new FileMetadata(pathStr, attrs.lastModifiedTime(), attrs.size(), attrs.fileKey());
95+
}
96+
97+
/** Returns true if the given metadata matches this file's path, mtime, size, and file key. */
98+
boolean matches(final @Nullable FileMetadata other) {
99+
if (other == null) {
100+
return false;
101+
}
102+
return Objects.equals(this.path, other.path)
103+
&& Objects.equals(this.lastModifiedTime, other.lastModifiedTime)
104+
&& this.size == other.size
105+
&& Objects.equals(this.fileKey, other.fileKey);
106+
}
107+
108+
/**
109+
* Re-reads the file's current attributes from disk and returns {@code true} if the file still
110+
* exists on disk with the exact same modification time, size, and OS file key/inode as when
111+
* this snapshot was captured.
112+
*/
113+
boolean isUnchangedOnDisk() {
114+
try {
115+
return matches(FileMetadata.of(this.path));
116+
} catch (IOException e) {
117+
return false;
118+
}
119+
}
120+
121+
/**
122+
* Returns {@code true} if {@code expectedPath} equals this snapshot's path and the file on disk
123+
* is unchanged since this snapshot was captured.
124+
*/
125+
boolean isUnchangedAtPath(final @Nullable String expectedPath) {
126+
return Objects.equals(this.path, expectedPath) && isUnchangedOnDisk();
127+
}
128+
}
129+
130+
/**
131+
* Stores an in-memory snapshot of verified Agent Identity certificate information and its
132+
* associated filesystem metadata to avoid redundant disk I/O and cryptographic checks on
133+
* subsequent token refreshes.
134+
*/
135+
static final class CachedAgentIdentityInfo {
136+
/**
137+
* Filesystem metadata of the JSON certificate configuration file ({@code
138+
* GOOGLE_API_CERTIFICATE_CONFIG}) when it was parsed, or {@code null} if the certificate was
139+
* discovered from the well-known directory.
140+
*/
141+
final @Nullable FileMetadata configMetadata;
142+
143+
/**
144+
* Filesystem metadata of the X.509 certificate or combined credential bundle file when it was
145+
* read.
146+
*/
147+
final @Nullable FileMetadata certMetadata;
148+
149+
/**
150+
* Filesystem metadata of the private key or combined credential bundle file when it was read,
151+
* or {@code null} when {@link #shouldRequestBoundToken} is {@code false} (for non-agent
152+
* certificates where the private key is not read).
153+
*/
154+
final @Nullable FileMetadata keyMetadata;
155+
156+
/**
157+
* Whether the parsed certificate's Subject Alternative Names matched an allowed Agent Identity
158+
* SPIFFE trust domain pattern and therefore requires requesting a bound token.
159+
*/
160+
final boolean shouldRequestBoundToken;
161+
162+
/**
163+
* The verified {@link AgentIdentityUtils.CertInfo} (parsed {@code X509Certificate} and PEM
164+
* certificate chain) when {@link #shouldRequestBoundToken} is {@code true}, or {@code null}
165+
* when {@link #shouldRequestBoundToken} is {@code false}.
166+
*/
167+
final @Nullable CertInfo certInfo;
168+
169+
/**
170+
* Constructs a cached snapshot of Agent Identity evaluation state and file metadata.
171+
*
172+
* @param configMetadata filesystem metadata of the certificate config file, or {@code null}
173+
* when discovered from the well-known directory
174+
* @param certMetadata filesystem metadata of the certificate or bundle file
175+
* @param keyMetadata filesystem metadata of the private key or bundle file, or {@code null}
176+
* when {@code shouldRequestBoundToken} is {@code false}
177+
* @param shouldRequestBoundToken {@code true} if the certificate matched an Agent Identity
178+
* SPIFFE pattern and requires a bound token; {@code false} otherwise
179+
* @param certInfo the verified {@link AgentIdentityUtils.CertInfo} when {@code
180+
* shouldRequestBoundToken} is {@code true}, or {@code null} otherwise
181+
*/
182+
CachedAgentIdentityInfo(
183+
final @Nullable FileMetadata configMetadata,
184+
final @Nullable FileMetadata certMetadata,
185+
final @Nullable FileMetadata keyMetadata,
186+
final boolean shouldRequestBoundToken,
187+
final @Nullable CertInfo certInfo) {
188+
this.configMetadata = configMetadata;
189+
this.certMetadata = certMetadata;
190+
this.keyMetadata = keyMetadata;
191+
this.shouldRequestBoundToken = shouldRequestBoundToken;
192+
this.certInfo = certInfo;
193+
}
194+
}
195+
196+
/** Returns the current in-memory cached Agent Identity info, or {@code null} if none. */
197+
static @Nullable CachedAgentIdentityInfo getCachedAgentIdentityInfo() {
198+
return cachedAgentIdentityInfo;
199+
}
200+
201+
/** Clears the in-memory cached Agent Identity info. */
202+
static void clearCachedAgentIdentityInfo() {
203+
cachedAgentIdentityInfo = null;
204+
}
205+
206+
/**
207+
* Returns {@code true} if the cached snapshot is non-null and its config, certificate, and key
208+
* file metadata still match the current files on disk.
209+
*/
210+
static boolean isCachedInfoValid(
211+
final @Nullable CachedAgentIdentityInfo cached, final @Nullable String certConfigPath) {
212+
if (cached == null || cached.certMetadata == null) {
213+
return false;
214+
}
215+
boolean hasConfigEnv = !Strings.isNullOrEmpty(certConfigPath);
216+
if (hasConfigEnv) {
217+
// When GOOGLE_API_CERTIFICATE_CONFIG is set, the cache must have been populated from that
218+
// config file (configMetadata != null) and the config file on disk must be unchanged.
219+
if (cached.configMetadata == null
220+
|| !cached.configMetadata.isUnchangedAtPath(certConfigPath)) {
221+
return false;
222+
}
223+
} else {
224+
// When GOOGLE_API_CERTIFICATE_CONFIG is unset, the cache must have been populated from the
225+
// well-known directory (where configMetadata is null).
226+
if (cached.configMetadata != null) {
227+
return false;
228+
}
229+
if (cached.certMetadata.path.endsWith("certificates.pem")
230+
&& Files.exists(
231+
Paths.get(AgentIdentityUtils.getWellKnownDir(), "credentialbundle.pem"))) {
232+
// credentialbundle.pem takes precedence if added after certificates.pem was cached
233+
return false;
234+
}
235+
}
236+
if (!cached.certMetadata.isUnchangedOnDisk()) {
237+
return false;
238+
}
239+
if (!cached.shouldRequestBoundToken) {
240+
return true;
241+
}
242+
return cached.keyMetadata != null && cached.keyMetadata.isUnchangedOnDisk();
243+
}
244+
245+
/**
246+
* Checks whether the resolved {@code certPath}, {@code keyPath}, and {@code configMetaBefore}
247+
* match the given cached snapshot.
248+
*
249+
* <p>Even after {@link #isCachedInfoValid} returns {@code false} at the start of {@code
250+
* AgentIdentityUtils.getAgentIdentityCertInfo()}, this check can still hit in three cases:
251+
*
252+
* <ol>
253+
* <li>The config file was deleted after caching while the cert and key files on disk remain
254+
* unchanged ({@code getPathsFromConfigWithRetry} fell back to the cached paths and {@code
255+
* configMetadata}, where {@code tryUpdateCache} will not overwrite the cache while the
256+
* config file is missing).
257+
* <li>Another thread completed rotation and updated {@code cachedAgentIdentityInfo} while the
258+
* current thread was resolving paths.
259+
* <li>Package-private {@code AgentIdentityUtils.loadAndVerifyCredentials(certPath, keyPath)}
260+
* was invoked directly without going through {@code getAgentIdentityCertInfo()}.
261+
* </ol>
262+
*/
263+
static boolean isPostResolutionCacheHit(
264+
final @Nullable CachedAgentIdentityInfo cached,
265+
final @Nullable String certPath,
266+
final @Nullable String keyPath,
267+
final @Nullable FileMetadata configMetaBefore) {
268+
if (cached == null || cached.certMetadata == null) {
269+
return false;
270+
}
271+
boolean configMatches =
272+
configMetaBefore == null
273+
? cached.configMetadata == null
274+
: configMetaBefore.matches(cached.configMetadata);
275+
if (!configMatches || !cached.certMetadata.isUnchangedAtPath(certPath)) {
276+
return false;
277+
}
278+
if (!cached.shouldRequestBoundToken) {
279+
return true;
280+
}
281+
return !Strings.isNullOrEmpty(keyPath)
282+
&& cached.keyMetadata != null
283+
&& cached.keyMetadata.isUnchangedAtPath(keyPath);
284+
}
285+
286+
/**
287+
* Returns the latest {@link CachedAgentIdentityInfo} if it contains a verified bound-token {@link
288+
* AgentIdentityUtils.CertInfo}, or falls back to {@code initialCached}.
289+
*
290+
* <p>{@code initialCached} is captured at the start of {@code getAgentIdentityCertInfo()} so that
291+
* if the cache is cleared or overwritten with a non-agent certificate during retry backoff, the
292+
* caller still retains the last-known-good bound certificate snapshot, while preferring a fresher
293+
* bound certificate if another thread completed rotation in the meantime.
294+
*/
295+
static @Nullable CachedAgentIdentityInfo getLatestOrInitialCache(
296+
final @Nullable CachedAgentIdentityInfo initialCached) {
297+
CachedAgentIdentityInfo latest = cachedAgentIdentityInfo;
298+
return (latest != null && latest.shouldRequestBoundToken && latest.certInfo != null)
299+
? latest
300+
: initialCached;
301+
}
302+
303+
/**
304+
* Safely updates the in-memory cache only if file metadata remained stable across the read/verify
305+
* operation. Returns {@code true} if the post-read cert/key metadata matched the pre-read
306+
* metadata, or {@code false} if the cert or key changed or disappeared mid-read. If only the
307+
* config file changed, returns {@code true} without updating the cache, so the next refresh
308+
* re-resolves paths from the new config.
309+
*/
310+
static boolean tryUpdateCache(
311+
final @Nullable FileMetadata configMetaBefore,
312+
final @Nullable FileMetadata certMetaBefore,
313+
final @Nullable FileMetadata keyMetaBefore,
314+
final boolean shouldRequestBoundToken,
315+
final @Nullable CertInfo certInfo) {
316+
if (certMetaBefore == null || !certMetaBefore.isUnchangedOnDisk()) {
317+
return false;
318+
}
319+
boolean configStable = configMetaBefore == null || configMetaBefore.isUnchangedOnDisk();
320+
if (!shouldRequestBoundToken) {
321+
if (configStable) {
322+
cachedAgentIdentityInfo =
323+
new CachedAgentIdentityInfo(configMetaBefore, certMetaBefore, null, false, null);
324+
}
325+
return true;
326+
}
327+
if (keyMetaBefore != null && keyMetaBefore.isUnchangedOnDisk()) {
328+
if (configStable) {
329+
cachedAgentIdentityInfo =
330+
new CachedAgentIdentityInfo(
331+
configMetaBefore, certMetaBefore, keyMetaBefore, true, certInfo);
332+
}
333+
return true;
334+
}
335+
return false;
336+
}
337+
338+
/**
339+
* Checks if a file exists and is readable, throwing {@link AccessDeniedException} if permission
340+
* is denied.
341+
*/
342+
static boolean checkExistsOrAccessDenied(final Path path) throws AccessDeniedException {
343+
try {
344+
Files.readAttributes(path, BasicFileAttributes.class);
345+
if (!Files.isReadable(path)) {
346+
if (!Files.exists(path)) {
347+
return false; // Deleted mid-rotation; let the caller retry.
348+
}
349+
throw new AccessDeniedException(path.toString());
350+
}
351+
return true;
352+
} catch (AccessDeniedException e) {
353+
throw e;
354+
} catch (IOException e) {
355+
return false;
356+
}
357+
}
358+
}

0 commit comments

Comments
 (0)