Skip to content

Commit 1de87fd

Browse files
authored
feat(auth): add DISABLE_BOUND_ID_TOKEN option to opt out of certificate-bound ID tokens (#14559)
> [!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](https://docs.google.com/document/d/1yL_xdDimlUlUyid6HDtqZUIMQjX9VznwzXn1SBABjZc/edit?pli=1&resourcekey=0-GxSXGdfH5Ni_nSI9f7xkQg&tab=t.0#heading=h.94fnt3dk2gx) (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`: ```java 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](https://paste.googleplex.com/5926225961418752) · [harness](https://paste.googleplex.com/5713819712749568) · cluster [setup](https://paste.googleplex.com/5635555912712192) and [manifests](https://paste.googleplex.com/6258309762514944) (shared with #14595)
1 parent 03af200 commit 1de87fd

3 files changed

Lines changed: 122 additions & 2 deletions

File tree

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -490,14 +490,19 @@ public AccessToken refreshAccessToken() throws IOException {
490490
* <br>
491491
* IdTokenProvider.Option.FORMAT_FULL<br>
492492
* IdTokenProvider.Option.LICENSES_TRUE<br>
493+
* IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN (request an ID token that is not bound to the
494+
* agent identity certificate)<br>
493495
* If no options are set, the defaults are "&amp;format=standard&amp;licenses=false"
494496
* @throws IOException if the attempt to get an IdToken failed
495497
* @return IdToken object which includes the raw id_token, JsonWebSignature
496498
*/
497499
@Override
498500
public IdToken idTokenWithAudience(
499501
String targetAudience, @Nullable List<IdTokenProvider.Option> options) throws IOException {
500-
String boundTokenPayload = AgentIdentityUtils.getBoundTokenPayload();
502+
// Checked before getBoundTokenPayload() so an opted-out target skips the certificate lookup.
503+
boolean bindIdToken =
504+
options == null || !options.contains(IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN);
505+
String boundTokenPayload = bindIdToken ? AgentIdentityUtils.getBoundTokenPayload() : null;
501506
GenericUrl documentUrl = new GenericUrl(getIdentityDocumentUrl());
502507
if (boundTokenPayload != null) {
503508
documentUrl.set("format", "full");

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

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ public interface IdTokenProvider {
4848
* <ul>
4949
* <li>FORMAT_FULL
5050
* <li>LICENSES_TRUE
51+
* <li>DISABLE_BOUND_ID_TOKEN
5152
* </ul>
5253
*
5354
* <br>
@@ -60,7 +61,28 @@ public interface IdTokenProvider {
6061
public enum Option {
6162
FORMAT_FULL("formatFull"),
6263
LICENSES_TRUE("licensesTrue"),
63-
INCLUDE_EMAIL("includeEmail");
64+
INCLUDE_EMAIL("includeEmail"),
65+
/**
66+
* Requests an ID token that is not bound to the workload's agent identity certificate.
67+
*
68+
* <p>This option is only supported by {@link ComputeEngineCredentials}; other {@link
69+
* IdTokenProvider} implementations do not request bound ID tokens and ignore this option.
70+
*
71+
* <p>When an agent identity certificate is available, {@link ComputeEngineCredentials} requests
72+
* certificate-bound ID tokens by default. A bound ID token is only accepted by targets that
73+
* authenticate the caller over mTLS with the same certificate. Pass this option for targets
74+
* that are called over standard (non-mTLS) HTTPS, for example a Cloud Run service reached
75+
* through its {@code *.run.app} URL or a custom domain.
76+
*
77+
* <p>Unlike the {@code GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false} environment variable, which
78+
* globally disables certificate-bound access tokens and ID tokens across the entire process,
79+
* this option applies per call (or per {@link IdTokenCredentials} instance) and leaves access
80+
* tokens and other ID token requests bound. If this option is not set, the library decides
81+
* whether to bind the ID token. Currently, it is bound whenever an agent identity certificate
82+
* is available and token binding is not globally disabled by {@code
83+
* GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false}.
84+
*/
85+
DISABLE_BOUND_ID_TOKEN("disableBoundIdToken");
6486

6587
private final String option;
6688

‎google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java‎

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1386,6 +1386,99 @@ void idTokenWithAudience_withValidCertAndKey_requestsBoundToken() throws IOExcep
13861386
assertEquals(expectedCert, ((String) bodyJson.get("certificate_chain")).trim());
13871387
}
13881388

1389+
@Test
1390+
void idTokenWithAudience_disableBoundIdToken_requestsUnboundToken() throws IOException {
1391+
setupCertAndKeyConfig();
1392+
envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true");
1393+
MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory();
1394+
transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL);
1395+
transportFactory.transport.setIdToken(STANDARD_ID_TOKEN);
1396+
1397+
ComputeEngineCredentials credentials =
1398+
ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build();
1399+
IdToken token =
1400+
credentials.idTokenWithAudience(
1401+
"https://foo.bar", Arrays.asList(IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN));
1402+
1403+
assertNotNull(token);
1404+
MockLowLevelHttpRequest request = transportFactory.transport.getRequest();
1405+
assertEquals("GET", transportFactory.transport.getRequestMethod());
1406+
assertTrue(request.getUrl().contains("audience=https://foo.bar"));
1407+
assertFalse(request.getUrl().contains("format=full"));
1408+
assertNull(request.getStreamingContent());
1409+
}
1410+
1411+
@Test
1412+
void idTokenWithAudience_disableBoundIdTokenWithFormatFull_requestsUnboundFullToken()
1413+
throws IOException {
1414+
setupCertAndKeyConfig();
1415+
envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true");
1416+
MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory();
1417+
transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL);
1418+
transportFactory.transport.setIdToken(FULL_ID_TOKEN);
1419+
1420+
ComputeEngineCredentials credentials =
1421+
ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build();
1422+
credentials.idTokenWithAudience(
1423+
"https://foo.bar",
1424+
Arrays.asList(
1425+
IdTokenProvider.Option.FORMAT_FULL, IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN));
1426+
1427+
MockLowLevelHttpRequest request = transportFactory.transport.getRequest();
1428+
assertEquals("GET", transportFactory.transport.getRequestMethod());
1429+
assertTrue(request.getUrl().contains("format=full"));
1430+
assertNull(request.getStreamingContent());
1431+
}
1432+
1433+
@Test
1434+
void idTokenWithAudience_disableBoundIdToken_skipsCertificateLookup() throws IOException {
1435+
// The certificate config points to a missing file, which fails a bound token request. With
1436+
// DISABLE_BOUND_ID_TOKEN the certificate is never looked up, so the unbound request succeeds.
1437+
envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true");
1438+
envProvider.setEnv(
1439+
AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG,
1440+
tempDir.resolve("missing_config.json").toAbsolutePath().toString());
1441+
AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/");
1442+
AgentIdentityUtils.setTimeService(millis -> {});
1443+
MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory();
1444+
transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL);
1445+
transportFactory.transport.setIdToken(STANDARD_ID_TOKEN);
1446+
ComputeEngineCredentials credentials =
1447+
ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build();
1448+
1449+
assertThrows(IOException.class, () -> credentials.idTokenWithAudience("https://foo.bar", null));
1450+
1451+
IdToken token =
1452+
credentials.idTokenWithAudience(
1453+
"https://foo.bar", Arrays.asList(IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN));
1454+
assertNotNull(token);
1455+
assertEquals("GET", transportFactory.transport.getRequestMethod());
1456+
}
1457+
1458+
@Test
1459+
void idTokenCredentials_withDisableBoundIdTokenOption_requestsUnboundToken() throws IOException {
1460+
setupCertAndKeyConfig();
1461+
envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true");
1462+
MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory();
1463+
transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL);
1464+
transportFactory.transport.setIdToken(STANDARD_ID_TOKEN);
1465+
ComputeEngineCredentials credentials =
1466+
ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build();
1467+
1468+
IdTokenCredentials idTokenCredentials =
1469+
IdTokenCredentials.newBuilder()
1470+
.setIdTokenProvider(credentials)
1471+
.setTargetAudience("https://foo.bar")
1472+
.setOptions(Arrays.asList(IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN))
1473+
.build();
1474+
idTokenCredentials.refresh();
1475+
1476+
MockLowLevelHttpRequest request = transportFactory.transport.getRequest();
1477+
assertEquals("GET", transportFactory.transport.getRequestMethod());
1478+
assertTrue(request.getUrl().contains("audience=https://foo.bar"));
1479+
assertNull(request.getStreamingContent());
1480+
}
1481+
13891482
@Test
13901483
void refreshAccessToken_boundToken404_throwsEndpointDoesNotSupportBoundTokensMessage()
13911484
throws IOException {

0 commit comments

Comments
 (0)