Skip to content

feat: Enable Bound Token for Agentic Identities - #13873

Merged
macastelaz merged 41 commits into
googleapis:agentic-identities-bound-tokenfrom
macastelaz:pr-13169-fixes
Oct 3, 2026
Merged

macastelaz merged 41 commits into
googleapis:agentic-identities-bound-tokenfrom
macastelaz:pr-13169-fixes

Conversation

@macastelaz

@macastelaz macastelaz commented Jul 23, 2026 •

Copy link
Copy Markdown
Contributor

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:
      "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:

# 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

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces Agent Identity token binding support for Cloud Run. It adds AgentIdentityUtils to resolve, load, and verify certificates and private keys, and updates ComputeEngineCredentials to request bound tokens via POST requests when a valid certificate chain is present. The review feedback suggests a cohesive improvement to implement a single-read pattern for certificate files. By reading the certificate chain once, caching it in CertInfo, and passing it to parseCertificate and getBoundTokenPayload, the implementation can avoid redundant disk I/O and prevent potential race conditions during certificate rotation.

@macastelaz macastelaz changed the title Pr 13169 fixes feat: Enable Bound Token for Agentic Identities Jul 23, 2026
// Environment variables
static final String GOOGLE_API_CERTIFICATE_CONFIG = "GOOGLE_API_CERTIFICATE_CONFIG";
static final String GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES =
"GOOGLE_API_PREVENT_TOKEN_SHARING_FOR_GCP_SERVICES";

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note that based on googleapis/google-cloud-python#17698 (comment) this is not yet finalized

@macastelaz
macastelaz marked this pull request as ready for review July 24, 2026 03:21
@macastelaz
macastelaz requested review from a team as code owners July 24, 2026 03:21
@lsirac
lsirac requested a review from nbayati July 24, 2026 17:43
@macastelaz macastelaz closed this Jul 28, 2026
@macastelaz macastelaz reopened this Jul 28, 2026
@macastelaz
macastelaz requested a review from a team as a code owner July 28, 2026 18:35
vverman and others added 13 commits July 29, 2026 03:35
1. POST request to MDS with cert-chain

2. Cert-key matching

3. Included logic to consider the user's choice by looking at GOOGLE_API_USE_CLIENT_CERTIFICATE env variable

4. Bound ID tokens.

# Conflicts:
#	google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java
#	google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/MockMetadataServerTransport.java
…etry logic.

Nit fixes.

# Conflicts:
#	google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java
# Conflicts:
#	google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java
static boolean isCachedInfoValid(
final CachedAgentIdentityInfo cached,
final String certConfigPath,
final String wellKnownDir) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the wellKnownDir path shouldn't change from different invocations? Can we just refernece the wellknowndir constant instead of using it as a param?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — removed the wellKnownDir parameter from isCachedInfoValid and referenced AgentIdentityUtils.getWellKnownDir() directly inside the method.

* @throws IOException If an I/O error occurs while reading the files, or if the key-pair
* verification fails after retries.
*/
static CertInfo getAgentIdentityCertInfo() throws IOException {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This logic is a bit hard for me to follow and I think we can try to simplify it.

  1. getCachedAgentIdentityInfo() looks like it can return null and we should guard against that
  2. The Fast-Path checks sense, I think we should clarify why we can hard-code certPresent = true for this case.
  3. From what I see, if ResolvedCertAndKeyPaths == null, then configExists always is false. If that's the case, then shouldEnableMtls should always return false. I think we guard against that, then shouldEnableMtls doesn't need the configExists param as we can check it here in getAgentIdentityCertInfo()

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated getAgentIdentityCertInfo() to address these points:

  1. Added an explicit initialCached != null guard before calling isCachedInfoValid.
  2. Added an inline comment explaining that isCachedInfoValid verifies the cached certificate file still exists on disk with matching metadata, which guarantees certsPresent = true in the fast-path.
  3. Removed the redundant paths != null checks (since resolveCertAndKeyPaths never returns null). Note that shouldEnableMtls(certsPresent, configExists) still needs configExists because configExists == false does not always evaluate to false: when certsPresent == true && configExists == false (certificates discovered in the well-known directory without a config file), shouldEnableMtls(true, false) returns true when GOOGLE_API_USE_CLIENT_CERTIFICATE="true" (Case 1), whereas it returns false when GOOGLE_API_USE_CLIENT_CERTIFICATE is unset (Case 3). Added Javadoc on shouldEnableMtls to clarify this.

@macastelaz
macastelaz requested a review from lqiu96 October 1, 2026 21:21
final CachedAgentIdentityInfo initialCached) throws IOException {
String useClientCert = getUseClientCertificateEnv();
boolean explicitMtls = isMtlsExplicitlyEnabled();
if (!explicitMtls && !Files.exists(Paths.get(wellKnownDir))) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getWellKnownCertificatePathWithRetry only runs when GOOGLE_API_CERTIFICATE_CONFIG is unset, where configExists is always false. Since shouldEnableMtls now returns false whenever GOOGLE_API_USE_CLIENT_CERTIFICATE is not "true" and configExists is false, getWellKnownCertificatePathWithRetry can return new ResolvedCertAndKeyPaths(null, null, false) immediately when !explicitMtls instead of probing wellKnownDir and its certificate files on every token refresh.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — updated getWellKnownCertificatePathWithRetry to return new ResolvedCertAndKeyPaths(null, null, false) immediately when !isMtlsExplicitlyEnabled().

}
}
}
initialStartupCompleted = true;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Setting initialStartupCompleted = true here before the debug log at line 734 means initialStartupCompleted always prints true in that log message, even on the initial startup call. Also, when isMtlsExplicitlyDisabled() is true, we should skip logging the missing well-known certificate fallback message just like getPathsFromConfigWithRetry does.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

With the early return when !isMtlsExplicitlyEnabled() at the top of getWellKnownCertificatePathWithRetry (from the comment above), the remainder of this method only runs when explicit mTLS is enabled, so the !explicitMtls fallback log at the end was unreachable and has been removed.

for (int cycle = 0; cycle < maxCycles; cycle++) {
try {
if (AgentIdentityCacheUtils.checkExistsOrAccessDenied(Paths.get(certConfigPath))) {
ResolvedCertAndKeyPaths paths = extractPathsFromConfig(certConfigPath);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

small nit (possible I may be missing some edge case), so if we can't or shouldn't do this then please ignore.

I think all paths that lead to a valid ResolvedCertAndKeyPaths should just have the certpath and keypaths already validated so we don't need to validate this in the calling method.

L567 and L570 don't need explicit checkExistsOrAccessDenied calls here as it'll know that it's either valid or null since it got back a ResolvedCertAndKeyPaths object.

There are also a lot of paths.getCertPath() calls from a ResolvedCertAndKeyPaths so perhaps certPath and keyPath fields should just be a Path object.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • On validating file existence inside extractPathsFromConfig: The edge case here is when certificate_config.json itself resides outside wellKnownDir (shouldPoll == false initially) while its cert_path / key_path point inside wellKnownDir and haven't been delivered yet on cycle 0. getPathsFromConfigWithRetry needs the parsed certPath and keyPath from extractPathsFromConfig before checking file existence so it can inspect isPathInWellKnownDir(...) and enable startup polling (shouldPoll = true; maxCycles = TOTAL_POLL_CYCLES).
  • On using Path vs. String in ResolvedCertAndKeyPaths: Both the inputs (JSON config strings and fallbackCached.certMetadata.getPath()) and the downstream consumers (loadAndVerifyCredentials, FileMetadata, isPostResolutionCacheHit, readCertificateChain, and readPrivateKey) take and store String, so keeping String avoids String -> Path -> String round-trip conversions and null-guarded .toString() calls.

Comment on lines +562 to +563
&& (isPathInWellKnownDir(paths.getCertPath())
|| isPathInWellKnownDir(paths.getKeyPath()))) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe worth adding a comment in the code, I'm not sure why this needs to check if it exists in the well known dir here?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — added an inline comment explaining that the config file itself may reside outside wellKnownDir while referencing cert_path or key_path inside wellKnownDir that are still being delivered at startup.

@lqiu96

lqiu96 commented Oct 2, 2026

Copy link
Copy Markdown
Member

I think the code looks good to me. I will do a final pass through the tests tomorrow to see if there is any cases that I may have missed.

@macastelaz
macastelaz requested a review from lqiu96 October 2, 2026 14:09
Comment on lines +572 to +575
// Incomplete workload config (missing cert_path or key_path) will never become ready;
// fail fast without polling.
lastParseException = e;
break;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

qq: for this case, it mentions fail-fast but this ends up going to the fallback value in the cache. Is this intended or should we bubble this up to the user/ calling method?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch on the comment wording — "fail fast without polling" here meant skipping the 30-second startup polling loop (via break;), not bypassing the steady-state cache fallback:

  • On initial startup (fallbackCached == null), breaking out of the loop immediately throws the IOException (with IncompleteWorkloadConfigException as the cause) without waiting 30s.
  • In steady state (fallbackCached != null, meaning a valid config and verified certificate were already cached earlier), breaking out of the loop falls back to the previously validated cached paths if a later config rewrite is incomplete, matching the behavior when the config file is deleted or unreadable after caching.

Updated the inline comment in 9afe807bca3 to make this distinction explicit.

* @throws IOException If an I/O error occurs while reading the files, or if the key-pair
* verification fails after retries.
*/
static CertInfo getAgentIdentityCertInfo() throws IOException {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

additional thoughts:

Tracing this from ComputeEngineCredentials, I see this is called from refreshAcessToken() -> getBoundToken() which already handles the request coalescing to mitigate the thundering herd possibility. Perhaps it maybe worth a small callout in the javadocs the mention this for future maintainers, so they know why we don't have any need for coalescing here or concerns about syncing across multiple requests

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good call — added a note to getAgentIdentityCertInfo()'s Javadoc in 9afe807bca3 calling out that callers like ComputeEngineCredentials.refreshAccessToken() invoke this via getBoundTokenPayload() under OAuth2Credentials's token refresh coalescing.

* Utility class for in-memory caching and filesystem metadata validation of Agent Identity
* certificates and configuration files.
*/
final class AgentIdentityCacheUtils {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For the util classes, can you add some quick @NullMarked and @nullable annotations. Feel free to add them in follow up PRs (I don't think annotations are blocking for this)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 9afe807bca3 — added @NullMarked and @Nullable annotations across AgentIdentityCacheUtils, AgentIdentityCertificateValidationUtils, and AgentIdentityUtils.

@lqiu96 lqiu96 left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM, thanks for all the quick iterations! I took one final pass throughout the PR and I think the logic makes sense. I don't have any additional concerns with the code and I think we can make follow fixes after additional testing/ feedback.

@macastelaz
macastelaz merged commit acc7c9d into googleapis:agentic-identities-bound-token Oct 3, 2026
174 of 179 checks passed
macastelaz added a commit that referenced this pull request Oct 7, 2026
…ext (#14556)

> [!IMPORTANT]
> **This must merge before the `agentic-identities-bound-token` feature
branch is merged to `main`.** Without it, HTTP/JSON clients fail by
default wherever mTLS is enabled automatically (see below).

## Problem

When mTLS is active,
`InstantiatingHttpJsonChannelProvider.configureMtls()` builds a
Conscrypt `SSLContext` but initializes it with the JDK (SunJSSE) PKIX
`TrustManagerFactory`. On TLS 1.3, Conscrypt passes authType `"GENERIC"`
to the trust manager. SunJSSE's end-entity checks reject that authType
for server certificates that are CA-issued and carry a KeyUsage
extension, which includes Google front ends. So every mTLS HTTP/JSON
handshake fails with:

```
javax.net.ssl.SSLHandshakeException: Unknown authType: GENERIC
Caused by: java.security.cert.CertificateException: Unknown authType: GENERIC
```

The bug is already on `main`, but there it only triggers when
`GOOGLE_API_USE_CLIENT_CERTIFICATE=true` is set explicitly. On this
feature branch, #13995 enables mTLS automatically whenever a workload
certificate config is present, so on Cloud Run (agent identity) **every
HTTP/JSON client fails by default**. gRPC isn't affected. The non-mTLS
path isn't affected either, because google-http-client already pairs
Conscrypt with a provider-matched trust manager there.

A second provider mismatch shows up on JDK 26: the mTLS `SSLContext`
also used the JDK's default (`SunX509`) `KeyManagerFactory`. Since JDK
26 ([JDK-8359956](https://bugs.openjdk.org/browse/JDK-8359956)), that
key manager applies algorithm constraints to the client certificate.
Called from a Conscrypt handshake, it rejects valid certificates (e.g.
`SHA256withRSA`), so the client sends an empty certificate chain and the
server aborts with `certificate_required`.

## Fix

Use Conscrypt's own PKIX `TrustManagerFactory`
(`TrustManagerFactory.getInstance("PKIX", conscryptProvider)`) for the
mTLS `SSLContext`. I called the JDK API directly rather than
`SslUtils.getPkixTrustManagerFactory(Provider)`, which only exists in
google-http-client 2.2.0+.

Likewise, use Conscrypt's own `KeyManagerFactory`
(`KeyManagerFactory.getInstance("PKIX", conscryptProvider)`), so the
trust manager and key manager both come from the same provider as the
`SSLContext`.

Conscrypt's trust manager loads the same default trust store as the JDK.
Verified on JDK 21: 174 anchors in both, the same set, and both honor
`-Djavax.net.ssl.trustStore` overrides identically.

## Testing

- New `InstantiatingHttpJsonChannelProviderTls13Test`: a local JDK TLS
1.3 server presents a CA-issued leaf with KeyUsage and requires a client
certificate. The test asserts that the transport completes the request
and presents its client certificate.
  - Without the fix: fails with `Unknown authType: GENERIC`.
  - With the fix: passes.
  - Skips when Conscrypt native or TLS 1.3 is unavailable.
- Full `gax-httpjson` suite on the current
`agentic-identities-bound-token` (with #13995 merged): 194/194 pass on
JDK 8, 11, 17, 21, 25 and 26. On JDK 26, the Tls13 test fails without
the `KeyManagerFactory` change: the client sends an empty certificate
chain and times out.
- End-to-end with #13995's certificate-rotation support, on JDK 21 with
Conscrypt active: 22 scenarios with real KMS and BigQuery Storage GAPIC
clients against local mTLS servers, where the certificate rotates on
disk.
- With this fix: all 22 pass. That includes HTTP/JSON rotation,
in-flight calls during refresh, and stress with `close()`. The refreshed
transport still uses Conscrypt's socket factory.
- Without this fix: every HTTP/JSON mTLS scenario fails with `Unknown
authType: GENERIC`. Limiting the test server to TLS 1.2 makes the error
go away, which confirms that the TLS 1.3 authType is the trigger.
- Logs: https://paste.googleplex.com/5563956459077632 (with fix),
https://paste.googleplex.com/5467233782988800 (without fix, JDK 21 set)
- Live on Cloud Run (agent identity), combined #13873 + #13995 build,
default env:

  | | gRPC | HTTP/JSON |
  |---|---|---|
  | Before | ✅ | ❌ `Unknown authType: GENERIC` |
  | After | ✅ | ✅ authenticated over mTLS with a bound token |
macastelaz added a commit that referenced this pull request Oct 19, 2026
…r agent identities (#14668)

## Summary

Merges the `agentic-identities-bound-token` feature branch into `main`,
adding end-to-end support for certificate-bound tokens and automatic
mTLS with transparent certificate rotation for agent identities on Cloud
Run and GKE.

### `google-auth-library-java` (`oauth2_http`)

- **Default certificate-bound tokens (`#13873`, `#14595`, `#14658`):**
When an agent identity SPIFFE workload certificate is present (either
via `GOOGLE_API_CERTIFICATE_CONFIG` on Cloud Run or the GKE credential
bundle at
`/var/run/secrets/workload-spiffe-credentials/x509.credential-bundle.private-key.pem`),
`ComputeEngineCredentials` requests certificate-bound access tokens and
ID tokens via `POST` to the Metadata Server with the leaf certificate
chain.
- **Opt-out controls (`#13873`, `#14559`, `#14557`):**
- Process-wide: `GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false` (or legacy
`GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES=false`)
disables token binding.
- Per-target ID tokens: `IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN`
requests an unbound ID token for specific targets (such as plain HTTPS
`*.run.app` URLs) while keeping other ID tokens and access tokens bound.
- **Automatic mTLS & strict configuration (`#13995`, `#14595`,
`#14658`):**
- `MtlsUtils.useMtlsClientCertificate()` enables mTLS automatically when
a workload certificate config or GKE credential bundle is present,
unless `GOOGLE_API_USE_CLIENT_CERTIFICATE` is set to a non-`"true"`
value.
- Missing, unreadable, or malformed explicit
`GOOGLE_API_CERTIFICATE_CONFIG` files fail closed with
`IllegalStateException`.
- **Full mTLS certificate chain (`#14621`):** `X509Provider` and
`SecureConnectProvider` now populate the mTLS key store with the full
certificate chain (leaf + intermediates) via
`MtlsKeyStoreUtils.createMtlsKeyStore` rather than only the leaf
certificate.

### `sdk-platform-java/gax-java` (`gax`, `gax-grpc`, `gax-httpjson`)

- **Transparent retries on mTLS certificate rotation (`#13995`):**
- `CertificateRotationTracker` checks the SHA-256 fingerprint of the
workload certificate on disk after an `UNAUTHENTICATED` failure.
- `ChannelPool` (gRPC) and `RefreshingHttpJsonChannel` (HTTP/JSON)
rebuild transport channels with the rotated certificate without
interrupting in-flight RPCs.
- `AttemptCallable` (unary) and `ServerStreamingAttemptCallable` (server
streaming) issue one immediate free retry on the refreshed channel when
a certificate rotation is detected.
- **Fail-closed default mTLS provider loading (`#14658`):** When
`useMtlsClientCertificate()` is `true`, `EndpointContext`,
`InstantiatingGrpcChannelProvider`, and
`InstantiatingHttpJsonChannelProvider` propagate `IOException`s (while
still falling back cleanly on `CertificateSourceUnavailableException`
when no certificate source exists on disk).
- **Conscrypt TLS 1.3 mTLS fix (`#14556`):**
`InstantiatingHttpJsonChannelProvider` uses Conscrypt's own
`TrustManagerFactory` and `KeyManagerFactory` for the mTLS `SSLContext`,
fixing `Unknown authType: GENERIC` on TLS 1.3 and key-manager algorithm
constraint mismatches on JDK 26+.

## Sub-PRs Included on `agentic-identities-bound-token`

| PR | Description |
| :--- | :--- |
| `#13873` | `feat: Enable Bound Token for Agentic Identities` |
| `#13995` | `feat(gax): support transparent retries during mTLS
certificate rotations` |
| `#14556` | `fix(gax-httpjson): use Conscrypt TrustManagerFactory for
mTLS SSLContext` |
| `#14559` | `feat(auth): add DISABLE_BOUND_ID_TOKEN option to opt out
of certificate-bound ID tokens` |
| `#14557` | `docs(auth): document certificate-bound tokens for agent
identities` |
| `#14595` | `feat(auth): support GKE agent identity credential bundle
for bound tokens and mTLS` |
| `#14626` | `fix(gax): drop jsr305 annotations left over after syncing
with main` |
| `#14621` | `fix(auth): present the full certificate chain for mTLS` |
| `#14658` | `fix(auth,gax): align Java mTLS and agent identity
certificate handling with Python parity` |

## Testing

- **Automated tests:** Full unit test suites in
`google-auth-library-java` (`credentials`, `oauth2_http`) and
`sdk-platform-java/gax-java` (`gax`, `gax-grpc`, `gax-httpjson`) pass
across JDK 8, 11, 17, 21, 25, and 26, including new unit tests for
certificate discovery, bound token requests, startup polling, rotation
retries, Conscrypt TLS 1.3 mTLS handshakes, and full certificate chains.
- **Integrated manual testing (`45/45` scenarios passed):** Built all 9
changed artifacts (`google-auth-library-credentials`,
`google-auth-library-oauth2-http`, `api-common`, `gax`, `gax-grpc`,
`gax-httpjson`, `google-cloud-core`, `google-cloud-core-http`,
`google-cloud-core-grpc`) together from the combined branch and ran 22
live GAPIC mTLS rotation scenarios, 14 local bound-token and GAX parity
checks, 3 live Cloud Run Agent Identity job executions, and 6 live GKE
Agent Identity pod scenarios.

<details>
<summary>Full manual test matrix (45 scenarios)</summary>

### 1. Live GAPIC mTLS Rotation (`22/22` Scenarios, `125/125` Checks)

Executed real `KeyManagementServiceClient` (gRPC and HTTP/JSON) and
`BigQueryReadClient` (server-streaming `ReadRows`) against live
`.mtls.googleapis.com` endpoints (`cloudkms.mtls.googleapis.com`,
`bigquerystorage.mtls.googleapis.com`) while rotating client
certificates on disk.

| Group | Scenarios | What Was Verified | Result |
| :--- | :--- | :--- | :--- |
| **Unary gRPC (`G1`–`G7`)** | `atomic-mv`, `inplace`, `symlink-swap`,
`corrupt-recover`, `missing-recover`, `idle-gap`, `burst-25-rpcs` |
`KeyManagementServiceClient` gRPC channel seamlessly picks up rotated
certs across atomic rename, in-place write, Kubernetes-style symlink
swap, corrupt/missing file recovery, idle gaps, and concurrent bursts. |
**7/7 Passed** |
| **Server-Streaming gRPC (`S1`–`S4`)** | `mid-stream`,
`between-streams`, `reconnect-after-rotate`, `corrupt-mid-stream` |
`BigQueryReadClient.readRowsCallable()` active streams continue
uninterrupted during rotation; subsequent streams and reconnects use the
new certificate. | **4/4 Passed** |
| **Unary HTTP/JSON (`H1`–`H5`)** | `atomic-mv`, `inplace`,
`corrupt-recover`, `missing-recover`, `burst-20-rpcs` |
`KeyManagementServiceClient` HTTP/JSON transport reloads rotated certs
and recovers from transient corrupt/missing files without dropping
requests. | **5/5 Passed** |
| **Startup & Providers (`N1a`, `N1b`, `C1`)** | `N1a`, `N1b`,
`C1-conscrypt`, `C1-jdk` | Missing cert config handling at startup; full
rotation works under both Conscrypt and JDK (`SunJSSE`) TLS providers. |
**4/4 Passed** |
| **Combined Assertions** | `G1`–`S4` assertions | Verified `125/125`
individual assertions across 22 isolated JVMs. | **22/22 Passed** |

### 2. Local Bound-Token & GAX Parity (`14/14` Checks)

| Check | PR / Area | What Was Verified | Result |
| :--- | :--- | :--- | :--- |
| **Scenarios 1–7** | `#13873` / `#14595` | Default bound token
(`cnf.x5t#S256` matches leaf),
`GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false`,
`GOOGLE_API_USE_CLIENT_CERTIFICATE=false`, cert rotation updates
`x5t#S256`, and
`GOOGLE_API_PREVENT_AGENT_TOKEN_SHARING_FOR_GCP_SERVICES` behavior. |
**Passed** |
| **Scenario 8d** | `#14658` | `GOOGLE_API_USE_CLIENT_CERTIFICATE=true`
with no certs falls back immediately (`43ms` GET) with no 30s polling
delay. | **Passed** |
| **Scenario 8e** | `#14658` | Invalid explicit
`GOOGLE_API_CERTIFICATE_CONFIG` fails closed with `IOException` instead
of silently downgrading. | **Passed** |
| **Scenario 10** | `#14658` | 0-byte startup cert file is polled until
populated rather than failing immediately. | **Passed** |
| **Scenario 11** | `#14658` | Kubernetes `..data` symlinked parent
directory resolves to the real directory for file watching. | **Passed**
|
| **Scenario 12a** | `#14559` |
`IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN` returns an unbound ID
token without leaking state to subsequent default calls. | **Passed** |
| **Scenario 12b** | `#14621` | `X509Provider` preserves the full
2-certificate chain (`leaf + intermediate`) in the `KeyStore`. |
**Passed** |
| **GAX `fail-closed`** | `#14658` | Missing explicit
`GOOGLE_API_CERTIFICATE_CONFIG` throws `IllegalStateException` in both
`InstantiatingGrpcChannelProvider` and
`InstantiatingHttpJsonChannelProvider`. | **Passed** |
| **GAX `zero-ms-fallback`** | `#14658` |
`GOOGLE_API_USE_CLIENT_CERTIFICATE=true` with no certs builds plain gRPC
and HTTP/JSON channels immediately (`0ms` polling delay). | **Passed** |

### 3. Live Cloud Run Agent Identity (`3/3` Scenarios)

Tested on a Cloud Run Agent Identity job (`--functional-type=agent
--identity-type=agent-identity`).

| Scenario | Env Vars | Key Results |
| :--- | :--- | :--- |
| **Default (Bound)** | *(unset)* | `DefaultMtlsProviderFactory` chain
length = `2` (`#14621`). Access token acquired in `414ms`. ID token
`cnf.x5t#S256` matched leaf cert. `DISABLE_BOUND_ID_TOKEN` (`#14559`)
returned `cnf: false` and next default call restored `cnf: true`. GAX
`ProjectsClient` (gRPC & HTTP/JSON) resolved
`cloudresourcemanager.mtls.googleapis.com:443` and passed mTLS +
bound-token auth (`PERMISSION_DENIED` IAM check). Non-mTLS transport
rejected bound token (`401`), true mTLS passed auth (`403`), and
mismatched client cert was rejected (`401`). |
| **Runtime Bound Opt-Out** |
`GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false` | Unbound access token and
ID token (`cnf` absent). Non-mTLS endpoints passed auth (`403` IAM) and
live Java ADK `LlmAgent` + `Gemini` call on Vertex AI succeeded (`200
OK`). |
| **Client Cert Opt-Out** | `GOOGLE_API_USE_CLIENT_CERTIFICATE=false` |
Unbound access token (`450ms`) and ID token (`cnf` absent). GAX
`ProjectsClient` (gRPC & HTTP/JSON) resolved plain
`cloudresourcemanager.googleapis.com:443` and passed auth
(`PERMISSION_DENIED`). Live Java ADK `LlmAgent` + `Gemini` call on
Vertex AI succeeded (`200 OK`). |

### 4. Live GKE Agent Identity (`6/6` Scenarios)

Tested on a GKE Agent Identity cluster against live GCP APIs
(`cloudresourcemanager`, `storage`) and an in-cluster SPIFFE mTLS
receiver configured to trust **only** the SPIFFE root CA (requiring the
full leaf + intermediate chain from `#14621`).

| Scenario | Pod Annotations | Env Vars | Key Results |
| :--- | :--- | :--- | :--- |
| **`s1-default`** | `inject-podcertificates=true` | *(unset)* | `bundle
exists=true`, first token fetch `1430ms`, SDK KeyStore chain length =
`2`, mTLS receiver (SDK KeyStore) = `200 OK`, mTLS GCS passed auth
(`404`), plain GCS rejected bound token (`401`), gRPC & HTTP/JSON
resolved `.mtls.googleapis.com:443` and passed auth,
`DISABLE_BOUND_ID_TOKEN` -> plain `200` / mTLS `401` / default after
opt-out -> mTLS `200` / access token still bound -> plain GCS `401`. |
| **`s2-runtime-bound-false`** | `inject-podcertificates=true` |
`GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false` | Unbound tokens
(`cnf=null`), mTLS receiver rejected unbound token (`401`), plain
receiver accepted (`200`), raw GCS and Storage SDK (HTTP & gRPC) passed
auth (`404`), gRPC & HTTP/JSON resolved `.mtls.googleapis.com:443` and
passed auth. |
| **`s3-use-cert-false`** | `inject-podcertificates=true` |
`GOOGLE_API_USE_CLIENT_CERTIFICATE=false` | Unbound tokens (`cnf=null`),
gRPC & HTTP/JSON resolved plain
`cloudresourcemanager.googleapis.com:443` and passed auth, plain
receiver = `200`, plain GCS passed auth (`404`). |
| **`s4-use-cert-true`** | `inject-podcertificates=true` |
`GOOGLE_API_USE_CLIENT_CERTIFICATE=true` | Identical bound behavior to
`s1-default`: SDK KeyStore mTLS receiver = `200`,
`.mtls.googleapis.com:443` resolved and passed auth, all
`DISABLE_BOUND_ID_TOKEN` checks passed. |
| **`s5-no-annotation`** | *(none)* | *(unset)* | `bundle exists=false`,
first token fetch `646ms` (no 30s hang), resolved plain
`cloudresourcemanager.googleapis.com:443`, passed auth
(`PERMISSION_DENIED` / `403`). |
| **`s6-use-cert-true-no-annotation`** | *(none)* |
`GOOGLE_API_USE_CLIENT_CERTIFICATE=true` | Verified `#14658` on real
GKE: `bundle exists=false`, first token fetch `609ms` (no 30s polling
delay), resolved plain `cloudresourcemanager.googleapis.com:443`, passed
auth (`PERMISSION_DENIED` / `403`). |

</details>

BEGIN_COMMIT_OVERRIDE
feat(auth): request certificate-bound tokens for agent identities by
default. Set GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false to opt out.
feat(auth): add IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN to request
unbound ID tokens for specific targets.
feat(auth): support GKE agent identity credential bundle for bound
tokens and mTLS.
fix(auth): present the full certificate chain for mTLS.
docs(auth): known limitation: ADK for Java doesn't support mTLS yet;
agents that use it must set GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false.
feat(gax): enable mTLS automatically when a workload certificate is
present and support transparent retries during mTLS certificate
rotations.
fix(gax-httpjson): use Conscrypt TrustManagerFactory and
KeyManagerFactory for mTLS SSLContext.
END_COMMIT_OVERRIDE

---------

Co-authored-by: Kirill Logachev <kirl@google.com>
Co-authored-by: Min Zhu <zhumin@google.com>
Co-authored-by: Lawrence Qiu <lawrenceqiu@google.com>
Co-authored-by: yangyzs <171981480+yangyzs@users.noreply.github.com>
Co-authored-by: cloud-java-bot <122572305+cloud-java-bot@users.noreply.github.com>
Co-authored-by: Keshav Dandeva <keshavdandeva@google.com>
Co-authored-by: Mattie Fu <mattiefu@google.com>
Co-authored-by: Will Howes <whowes@google.com>
Co-authored-by: Jin Seop Kim <jinseop@google.com>
Co-authored-by: Jiang Zhu <jiangzzhu@google.com>
Co-authored-by: Knut Olav Løite <koloite@gmail.com>
Co-authored-by: release-please[bot] <55107282+release-please[bot]@users.noreply.github.com>
Co-authored-by: rahul2393 <irahul@google.com>
Co-authored-by: Sakthivel Subramanian <179120858+sakthivelmanii@users.noreply.github.com>
Co-authored-by: Gaole Meng <gaole@google.com>
Co-authored-by: Pranav Iyer <pjiyer@google.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants