Skip to content

feat(auth): add DISABLE_BOUND_ID_TOKEN option to opt out of certificate-bound ID tokens - #14559

Merged
macastelaz merged 5 commits into
googleapis:agentic-identities-bound-tokenfrom
macastelaz:defer-bound-id-tokens
Oct 10, 2026
Merged

macastelaz merged 5 commits into
googleapis:agentic-identities-bound-tokenfrom
macastelaz:defer-bound-id-tokens

Conversation

@macastelaz

@macastelaz macastelaz commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor

Important

This must merge before the agentic-identities-bound-token feature branch is merged to main. Docs PR #14557 documents this option.

Why

When an agent identity certificate is available, ComputeEngineCredentials requests certificate-bound ID tokens by default. A bound ID token is only accepted by targets that authenticate the caller over mTLS with the same certificate. Targets reached over standard HTTPS, such as a Cloud Run service's *.run.app URL or a custom domain, reject it with 401 Unauthorized.

Per the ID token binding 1-pager (Sep 30 decision), ID tokens stay bound by default, and callers get a per-target bind_id_token setting to turn binding off where needed. The process-wide opt-out (GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false) is unchanged.

What changes

  • New IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN. Callers pass it per target, the same way as FORMAT_FULL and LICENSES_TRUE:

    IdTokenCredentials.newBuilder()
        .setIdTokenProvider((IdTokenProvider) credentials)
        .setTargetAudience("https://my-service-12345.us-central1.run.app")
        .setOptions(Arrays.asList(IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN))
        .build();
  • ComputeEngineCredentials.idTokenWithAudience(): with the option, the certificate lookup is skipped and the identity endpoint is called with a plain GET. Without it, behavior is unchanged (bound whenever a certificate is available and binding isn't turned off by the env var).

  • Other credential types (ServiceAccountCredentials, ImpersonatedCredentials, UserCredentials) already ignore options they don't use, so the same code works outside agent identity environments.

  • Access tokens are unaffected.

API shape

Option is a set of on/off flags rather than a value, so this adds only the "off" flag (DISABLE_BOUND_ID_TOKEN). Leaving it unset means "auto", which currently means bound. This matches the 1-pager's tri-state (unset = auto) without adding an "enable" flag that would do nothing today; one can be added later without breaking callers if "auto" starts deciding per target. Python exposes the same control as bind_id_token: Optional[bool] (googleapis/google-cloud-python#18559).

Tests

Unit tests

New ComputeEngineCredentialsTest cases, all with an agent identity certificate present and binding enabled:

  • idTokenWithAudience_disableBoundIdToken_requestsUnboundToken: GET, no request body, no forced format=full.
  • idTokenWithAudience_disableBoundIdTokenWithFormatFull_requestsUnboundFullToken: still GET; format=full is kept when the caller asks for it.
  • idTokenWithAudience_disableBoundIdToken_skipsCertificateLookup: with a certificate config pointing to a missing file, the default request fails, but the opted-out request succeeds, which shows the certificate is never read.
  • idTokenCredentials_withDisableBoundIdTokenOption_requestsUnboundToken: the option set on IdTokenCredentials reaches ComputeEngineCredentials.

The existing bound-ID-token tests are unchanged and still pass. Full oauth2_http suite: 1064 tests, 0 failures. fmt is clean.

Live test on GKE

Run on a GKE cluster with agent identity enabled, using a test-only local merge of this PR with #14595 (GKE support) and #14556 (already on the base branch). A single process fetches ID tokens through IdTokenCredentials from ComputeEngineCredentials (ADC) and sends them to an agent-to-agent receiver pod. The receiver accepts only bound tokens over mTLS (whose cnf must match the client certificate), and only unbound tokens over plain HTTP.

Check Defaults GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false
ID token with DISABLE_BOUND_ID_TOKEN unbound (no cnf); receiver: plain HTTP 200, mTLS 401 unbound
ID token for another target without the option, same credentials, fetched afterwards still bound (cnf = certificate leaf); receiver: mTLS 200 unbound
Access token after the ID-token opt-out still bound (401 on a plain, non-mTLS endpoint) unbound

The option unbinds only the ID token it's set on. Other targets and access tokens are unaffected, and it's harmless when binding is already off.

Internal links (Googlers only): results · harness · cluster setup and manifests (shared with #14595)

@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 AgentIdentityUtils to support Agent Identity runtime certificate discovery and token binding, enabling ComputeEngineCredentials to request certificate-bound access tokens from the GCE metadata server. Feedback on the changes highlights several locations in ComputeEngineCredentials where HttpResponse connections are not disconnected or closed upon receiving a 404 status code, potentially leading to HTTP connection leaks.

@macastelaz
macastelaz force-pushed the defer-bound-id-tokens branch from 8b9072e to dbf54df Compare October 6, 2026 15:04
@macastelaz macastelaz changed the title fix(auth): defer certificate-bound ID tokens; bind access tokens only [blocked on #13873] feat(auth): add BIND_ID_TOKEN_FALSE option to opt out of certificate-bound ID tokens Oct 6, 2026
macastelaz added a commit that referenced this pull request Oct 6, 2026
…igquery pom (#14587)

## Why

Every PR into `agentic-identities-bound-token` is currently failing CI
(for example #14559 and #14557, about 35 jobs each) with:

```
[ERROR] 'dependencies.dependency.(groupId:artifactId:type:classifier)' must be unique: com.google.cloud:google-cloud-storage:jar -> duplicate declaration of version (?)
[ERROR] 'dependencies.dependency.(groupId:artifactId:type:classifier)' must be unique: com.google.cloud:google-cloud-datacatalog:jar -> duplicate declaration of version 1.102.0-SNAPSHOT
[ERROR] The build could not read 1 project
```

`java-bigquery/google-cloud-bigquery/pom.xml` declares
`google-cloud-storage` and `google-cloud-datacatalog` twice in
`<dependencies>`. This has been the case for a while, but Maven 3.9 only
logged a `[WARNING]`. The GitHub `ubuntu-24.04` runner image `20261004`
upgraded Maven from 3.9.16 to 3.10.0, which treats it as an error, so
any job that loads the reactor now fails immediately.

## Change

Remove the second declaration of each dependency (11 lines, one file):

- `google-cloud-storage`: drops the later test-scoped entry. This is the
same change as #14586 on `main`.
- `google-cloud-datacatalog`: drops the second, identical test-scoped
entry. `main` no longer has this dependency in this pom, so #14586
doesn't need it.

## Verification

- `mvn help:effective-pom` on the module before and after: the effective
`<dependencies>` section is identical. `google-cloud-storage` stays
test-scoped because the parent's `dependencyManagement` sets
`<scope>test</scope>`, and the two datacatalog entries were identical.
- Each artifact now appears once in `<dependencies>`; the pom parses as
valid XML.
…bound ID tokens

ComputeEngineCredentials requests certificate-bound ID tokens by default when
an agent identity certificate is available. Bound ID tokens are only accepted
by targets that authenticate the caller over mTLS with the same certificate,
so targets reached over standard HTTPS (for example a Cloud Run *.run.app URL
or a custom domain) reject them with 401.

Add IdTokenProvider.Option.BIND_ID_TOKEN_FALSE so callers can request an
unbound ID token per target, through idTokenWithAudience() or
IdTokenCredentials.Builder.setOptions(). When the option is present, the
certificate lookup is skipped and the identity endpoint is called with a
plain GET. Without it, behavior is unchanged: ID tokens are bound whenever a
certificate is available and GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN is not
false. Other credential types ignore the option.
@macastelaz
macastelaz force-pushed the defer-bound-id-tokens branch from dbf54df to f58f3a3 Compare October 6, 2026 17:08
@macastelaz
macastelaz marked this pull request as ready for review October 6, 2026 17:21
@macastelaz
macastelaz requested review from a team as code owners October 6, 2026 17:21
@macastelaz
macastelaz requested review from lqiu96 and nbayati October 6, 2026 17:21
@macastelaz macastelaz changed the title feat(auth): add BIND_ID_TOKEN_FALSE option to opt out of certificate-bound ID tokens feat(auth): add DISABLE_BOUND_ID_TOKEN option to opt out of certificate-bound ID tokens Oct 8, 2026
@macastelaz
macastelaz requested a review from lqiu96 October 8, 2026 01:13

@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. FYi, I think you can update the PR and the CI should pass now

@macastelaz
macastelaz merged commit 1de87fd into googleapis:agentic-identities-bound-token Oct 10, 2026
282 of 284 checks passed
macastelaz added a commit that referenced this pull request Oct 10, 2026
…14557)

> [!IMPORTANT]
> **Merge after #14559**, which adds
`IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN` referenced here. Both
must merge before the `agentic-identities-bound-token` feature branch is
merged to `main`.

## What this adds

Docs only; no code changes.

- **`ComputeEngineCredentials` class Javadoc**: adds a brief summary
noting that when a workload certificate for an agent identity is
available, `ComputeEngineCredentials` requests certificate-bound access
tokens and ID tokens by default, with pointers to
`IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN` and the [Google Auth
Library
guide](https://cloud.google.com/java/getting-started/getting-started-with-google-auth-library).
- The full "Certificate-bound tokens for agent identities" user guide
section (covering environment variables, per-target ID token opt-out,
and the ADK for Java limitation) is being added to the DevSite
`getting-started-with-google-auth-library` page (`cl/996749889`)
alongside the Cloud Run documentation (`cl/990396526`).

## Release notes

`google-auth-library-java/CHANGELOG.md` is generated, so release notes
come from commit messages. Suggested text for the
`BEGIN_COMMIT_OVERRIDE` block of the feature-branch → `main` merge PR:

```
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.
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.
```

## Testing

- `google-java-format` and `javadoc:javadoc` pass.
macastelaz added a commit that referenced this pull request Oct 14, 2026
…kens and mTLS (#14595)

## Why

GKE delivers agent identity credentials (Pod Certificates) as a single
combined PEM file, holding the certificate chain and a PKCS#8 private
key, at:

```
/var/run/secrets/workload-spiffe-credentials/x509.credential-bundle.private-key.pem
```

The feature branch already handles Cloud Run, where a certificate config
points into the well-known directory. On GKE there is no certificate
config, so neither the transport nor the token side found the
certificate. This is the Java counterpart of
googleapis/google-cloud-python#18559. Background:
go/gke-security-identity/features/agent_identity_diy.md and
go/sdk-mds-bound-token.

## What changes

When **no certificate configuration exists**
(`GOOGLE_API_CERTIFICATE_CONFIG` is unset and there is no
`~/.config/gcloud/certificate_config.json`), a readable, non-empty
bundle now:

- **Transport:** turns on mTLS automatically.
- `MtlsUtils.getWorkloadCertPath` / `useMtlsClientCertificate` return
the bundle path.
- `DefaultMtlsProviderFactory.create()` builds its keystore from the
bundle via a new package-private `X509Provider.createForTransport()`.
- Every GAX transport (`EndpointContext`, gRPC, HTTP/JSON) goes through
that factory.
- **Tokens:** `AgentIdentityUtils` resolves the same bundle, so
`ComputeEngineCredentials` requests tokens bound to the certificate the
transport presents.
- With `GOOGLE_API_USE_CLIENT_CERTIFICATE=true`, the bundle takes
precedence over the Cloud Run well-known files for the same reason.

**Kept unchanged:**

- `GOOGLE_API_USE_CLIENT_CERTIFICATE=false` and
`GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false` still opt out.
- An explicit or default certificate config (including ECP) always beats
the bundle, on both sides.
- **The bundle is never polled for.** GKE writes it before the container
starts, and polling for a missing file would delay every workload that
isn't an agent.
- **Workload identity federation X.509 is unchanged.** The public
`X509Provider` constructors don't fall back to the bundle. Only the
transport factory does.
- If the bundle disappears during the read (rotation), the transport
reports `CertificateSourceUnavailableException` rather than a generic
`IOException`. If it's briefly missing after a credential was cached,
the token side falls back to the cached credential, but only while the
bundle is still allowed (no certificate configuration has appeared).

**New API:** a public static
`MtlsUtils.hasCertificateConfiguration(...)` on an `@InternalApi` class.
This is additive only.

## Differences from Python (intentional)

- Python also checks
`CLOUDSDK_CONTEXT_AWARE_CERTIFICATE_CONFIG_FILE_PATH`. Java has no
equivalent env var.
- Python temporarily keeps default **ID tokens** unbound on Cloud Run
(when `GOOGLE_API_CERTIFICATE_CONFIG` is set). That gate is temporary
and Python-only, so it isn't copied here. On GKE, both libraries bind ID
tokens by default. #14559 adds the per-target `DISABLE_BOUND_ID_TOKEN`
opt-out.

## Related

- #14556 (HTTP/JSON mTLS fix, merged) enables mTLS over HTTP/JSON
transports.
- #14559 (`DISABLE_BOUND_ID_TOKEN`) is the per-target ID-token opt-out.

## Tests

### Unit tests

- `MtlsUtilsTest`:
  - bundle present, absent, a directory, or empty;
  - `USE_CLIENT_CERTIFICATE=false`;
  - a default config without a workload section (ECP) blocks the bundle;
  - an explicit config takes precedence;
  - `hasCertificateConfiguration` / `getGkeCredentialBundlePath`.
- `X509ProviderTest`:
  - transport provider with cert-first and key-first bundles;
  - an explicit config, or an override path, takes precedence;
  - unavailable when there is no bundle;
  - the public constructor ignores the bundle (WIF).
- `AgentIdentityUtilsTest`:
  - implicit discovery with zero sleeps;
  - the cached credential stays bound;
- opt-outs, a default gcloud config, and a non-agent cert all leave the
token unbound;
  - an empty or absent bundle returns null without polling;
- explicit true finds the bundle, never polls for it, and prefers it
over the Cloud Run files;
- fallback to the cache after the bundle is deleted, and no fallback
once a certificate config has appeared (implicit and explicit paths).

Full `oauth2_http` suite: 1089 tests, 0 failures. `fmt` is clean.

### Live test on GKE

- **Cluster:** a GKE cluster with agent identity enabled.
- **Pods:** the test pods get the agent identity credential bundle at
`/var/run/secrets/workload-spiffe-credentials/x509.credential-bundle.private-key.pem`.
- **Image:** built from this PR's jars merged with #14556, which is
already on the base branch and is required for HTTP/JSON mTLS. It uses
ADC with default settings and no client options.
- **Details:** cluster setup, manifests and full logs are in the
internal links below.

**Checks:**
- **GCS over raw HTTP:** read through `storage.mtls.googleapis.com` with
the transport certificate. The same token on plain
`storage.googleapis.com` gets 401, which proves the token is bound.
- **GAX Resource Manager client:** `getProject` over gRPC and HTTP/JSON.
- **ID tokens:** sent to an agent-to-agent receiver pod. It accepts only
bound tokens over mTLS whose `cnf` matches the client certificate, and
only unbound tokens over plain HTTP.

| Scenario | Token | GAX resolved endpoint | GAX gRPC / HTTP-JSON | GCS
mTLS / plain | ID token mTLS / plain |
|---|---|---|---|---|---|
| Defaults | Bound; `cnf` = bundle leaf = transport cert |
`cloudresourcemanager.mtls.googleapis.com` | ✅ / ✅ | 200 / **401** |
**200** / 401 |
| `GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false` | Unbound | mTLS
endpoint | ✅ / ✅ | 200 / 200 | 401 / **200** |
| `GOOGLE_API_USE_CLIENT_CERTIFICATE=false` | Unbound | plain endpoint |
✅ / ✅ | 200 / 200 | 401 / **200** |
| `GOOGLE_API_USE_CLIENT_CERTIFICATE=true` | Bound (bundle) | mTLS
endpoint | ✅ / ✅ | 200 / **401** | **200** / 401 |
| Pod without agent identity | Unbound, no mTLS | plain endpoint |
authenticated / authenticated | — / authenticated | n/a |

**Timing:** the first token fetch took about 0.5–1s in every scenario. A
pod without the bundle has no polling delay.

**Internal links (Googlers only):**
[setup](https://paste.googleplex.com/5635555912712192) ·
[manifests](https://paste.googleplex.com/6258309762514944) ·
[harness](https://paste.googleplex.com/4882197073494016) ·
[build/run](https://paste.googleplex.com/5942061774274560) ·
[results](https://paste.googleplex.com/5072605959290880)

**Out of scope for this PR (tracked separately):**
- **Leaf-only keystore:** `X509Provider`'s keystore holds only the leaf
certificate, through google-http-client
`SecurityUtils.createMtlsKeyStore`. Google APIs over
`*.mtls.googleapis.com` are unaffected. Agent-to-agent mTLS against a
peer that trusts only the SPIFFE root fails unless the caller presents
the full chain.
macastelaz added a commit that referenced this pull request Oct 14, 2026
…14626)

## Why

After the `main` sync (`03af2000542`), the `dependencies` check fails on
every PR targeting this branch, for example #14559:

```
[ERROR] Used undeclared dependencies found:
[ERROR]    com.google.code.findbugs:jsr305:jar:3.0.2:compile
... gax-grpc / gax-httpjson: Dependency problems found
```

`main` moved GAX off `jsr305`: it uses JSpecify for nullness, and
`gax-grpc`/`gax-httpjson` no longer declare `jsr305`. Two
`javax.annotation` uses that came in with #13995 (certificate rotation
retries) are still on this branch. They compile, because `jsr305` still
arrives transitively, but `dependency:analyze` correctly flags them.

## What changes

These are annotation-only edits with no behavior change:

- **`gax-grpc/.../ChannelPool.java`:** the
`@GuardedBy("entryWriteLock")` annotation (from
`javax.annotation.concurrent`) becomes a `// Guarded by entryWriteLock.`
comment. I didn't switch to Error Prone's `@GuardedBy`, because
`gax-grpc` doesn't depend on Error Prone annotations, so that would just
trade one undeclared dependency for another. On `main`, `gax-grpc` uses
no `@GuardedBy`.
- **`gax-httpjson/.../RefreshingHttpJsonChannelTest.java`:**
`javax.annotation.Nullable` becomes `org.jspecify.annotations.Nullable`,
as in the rest of GAX.

These are the only `javax.annotation` imports in `gax-java`,
`google-auth-library-java` and `java-core` that are on this branch but
not on `main`.

## Tests

`dependency:analyze` was run the same way as `.kokoro/dependencies.sh`
(`mvn dependency:analyze -Pquick-build -DfailOnWarning=true
-Dmdep.analyze.skip=false`) on `gax-grpc` and `gax-httpjson`:
- without this change: `Used undeclared dependencies found:
com.google.code.findbugs:jsr305`, BUILD FAILURE (the same as CI);
- with this change: BUILD SUCCESS.

Unit tests: `gax-grpc` 252 and `gax-httpjson` 234 tests, 0 failures.
`fmt` is clean.
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.

3 participants