From f58f3a3eb8f7bdbc612c90723fff11b6eacb833c Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 6 Oct 2026 15:03:55 +0000 Subject: [PATCH 1/3] feat(auth): add BIND_ID_TOKEN_FALSE option to opt out of certificate-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. --- .../auth/oauth2/ComputeEngineCredentials.java | 7 +- .../google/auth/oauth2/IdTokenProvider.java | 18 +++- .../oauth2/ComputeEngineCredentialsTest.java | 93 +++++++++++++++++++ 3 files changed, 116 insertions(+), 2 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java index f79facbb6ccc..96f4903a6637 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java @@ -490,6 +490,8 @@ public AccessToken refreshAccessToken() throws IOException { *
* IdTokenProvider.Option.FORMAT_FULL
* IdTokenProvider.Option.LICENSES_TRUE
+ * IdTokenProvider.Option.BIND_ID_TOKEN_FALSE (request an ID token that is not bound to the + * agent identity certificate)
* If no options are set, the defaults are "&format=standard&licenses=false" * @throws IOException if the attempt to get an IdToken failed * @return IdToken object which includes the raw id_token, JsonWebSignature @@ -497,7 +499,10 @@ public AccessToken refreshAccessToken() throws IOException { @Override public IdToken idTokenWithAudience(String targetAudience, List options) throws IOException { - String boundTokenPayload = AgentIdentityUtils.getBoundTokenPayload(); + // Checked before getBoundTokenPayload() so an opted-out target skips the certificate lookup. + boolean bindIdToken = + options == null || !options.contains(IdTokenProvider.Option.BIND_ID_TOKEN_FALSE); + String boundTokenPayload = bindIdToken ? AgentIdentityUtils.getBoundTokenPayload() : null; GenericUrl documentUrl = new GenericUrl(getIdentityDocumentUrl()); if (boundTokenPayload != null) { documentUrl.set("format", "full"); diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdTokenProvider.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdTokenProvider.java index bb1e66ac8536..631b0e4f3fc2 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdTokenProvider.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdTokenProvider.java @@ -47,6 +47,7 @@ public interface IdTokenProvider { * * *
@@ -59,7 +60,22 @@ public interface IdTokenProvider { public enum Option { FORMAT_FULL("formatFull"), LICENSES_TRUE("licensesTrue"), - INCLUDE_EMAIL("includeEmail"); + INCLUDE_EMAIL("includeEmail"), + /** + * Requests an ID token that is not bound to the workload's agent identity certificate. + * + *

When an agent identity certificate is available, {@link 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. Pass this option for targets + * that are called over standard (non-mTLS) HTTPS, for example a Cloud Run service reached + * through its {@code *.run.app} URL or a custom domain. + * + *

If this option is not set, the library decides whether to bind the ID token; it is + * currently bound whenever an agent identity certificate is available and token binding is not + * disabled by {@code GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false}. Credential types that do not + * request bound ID tokens ignore this option. + */ + BIND_ID_TOKEN_FALSE("bindIdTokenFalse"); private final String option; diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 9d3e0f6d2b11..66bbfab7bcef 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -1386,6 +1386,99 @@ void idTokenWithAudience_withValidCertAndKey_requestsBoundToken() throws IOExcep assertEquals(expectedCert, ((String) bodyJson.get("certificate_chain")).trim()); } + @Test + void idTokenWithAudience_bindIdTokenFalse_requestsUnboundToken() throws IOException { + setupCertAndKeyConfig(); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true"); + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setIdToken(STANDARD_ID_TOKEN); + + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + IdToken token = + credentials.idTokenWithAudience( + "https://foo.bar", Arrays.asList(IdTokenProvider.Option.BIND_ID_TOKEN_FALSE)); + + assertNotNull(token); + MockLowLevelHttpRequest request = transportFactory.transport.getRequest(); + assertEquals("GET", transportFactory.transport.getRequestMethod()); + assertTrue(request.getUrl().contains("audience=https://foo.bar")); + assertFalse(request.getUrl().contains("format=full")); + assertNull(request.getStreamingContent()); + } + + @Test + void idTokenWithAudience_bindIdTokenFalseWithFormatFull_requestsUnboundFullToken() + throws IOException { + setupCertAndKeyConfig(); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true"); + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setIdToken(FULL_ID_TOKEN); + + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + credentials.idTokenWithAudience( + "https://foo.bar", + Arrays.asList( + IdTokenProvider.Option.FORMAT_FULL, IdTokenProvider.Option.BIND_ID_TOKEN_FALSE)); + + MockLowLevelHttpRequest request = transportFactory.transport.getRequest(); + assertEquals("GET", transportFactory.transport.getRequestMethod()); + assertTrue(request.getUrl().contains("format=full")); + assertNull(request.getStreamingContent()); + } + + @Test + void idTokenWithAudience_bindIdTokenFalse_skipsCertificateLookup() throws IOException { + // The certificate config points to a missing file, which fails a bound token request. With + // BIND_ID_TOKEN_FALSE the certificate is never looked up, so the unbound request succeeds. + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true"); + envProvider.setEnv( + AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, + tempDir.resolve("missing_config.json").toAbsolutePath().toString()); + AgentIdentityUtils.setWellKnownDir(tempDir.toAbsolutePath().toString() + "/"); + AgentIdentityUtils.setTimeService(millis -> {}); + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setIdToken(STANDARD_ID_TOKEN); + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + + assertThrows(IOException.class, () -> credentials.idTokenWithAudience("https://foo.bar", null)); + + IdToken token = + credentials.idTokenWithAudience( + "https://foo.bar", Arrays.asList(IdTokenProvider.Option.BIND_ID_TOKEN_FALSE)); + assertNotNull(token); + assertEquals("GET", transportFactory.transport.getRequestMethod()); + } + + @Test + void idTokenCredentials_withBindIdTokenFalseOption_requestsUnboundToken() throws IOException { + setupCertAndKeyConfig(); + envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true"); + MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); + transportFactory.transport.setServiceAccountEmail(SA_CLIENT_EMAIL); + transportFactory.transport.setIdToken(STANDARD_ID_TOKEN); + ComputeEngineCredentials credentials = + ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); + + IdTokenCredentials idTokenCredentials = + IdTokenCredentials.newBuilder() + .setIdTokenProvider(credentials) + .setTargetAudience("https://foo.bar") + .setOptions(Arrays.asList(IdTokenProvider.Option.BIND_ID_TOKEN_FALSE)) + .build(); + idTokenCredentials.refresh(); + + MockLowLevelHttpRequest request = transportFactory.transport.getRequest(); + assertEquals("GET", transportFactory.transport.getRequestMethod()); + assertTrue(request.getUrl().contains("audience=https://foo.bar")); + assertNull(request.getStreamingContent()); + } + @Test void refreshAccessToken_boundToken404_throwsEndpointDoesNotSupportBoundTokensMessage() throws IOException { From 9420bb1a6eb450eecbbfe63fdd0659a9b346c6c0 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 8 Oct 2026 01:06:58 +0000 Subject: [PATCH 2/3] feat(auth): rename option to DISABLE_BOUND_ID_TOKEN and clarify Javadocs --- .../auth/oauth2/ComputeEngineCredentials.java | 4 ++-- .../google/auth/oauth2/IdTokenProvider.java | 17 +++++++++++------ .../oauth2/ComputeEngineCredentialsTest.java | 18 +++++++++--------- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java index 96f4903a6637..ec8c2b10c82f 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java @@ -490,7 +490,7 @@ public AccessToken refreshAccessToken() throws IOException { *
* IdTokenProvider.Option.FORMAT_FULL
* IdTokenProvider.Option.LICENSES_TRUE
- * IdTokenProvider.Option.BIND_ID_TOKEN_FALSE (request an ID token that is not bound to the + * IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN (request an ID token that is not bound to the * agent identity certificate)
* If no options are set, the defaults are "&format=standard&licenses=false" * @throws IOException if the attempt to get an IdToken failed @@ -501,7 +501,7 @@ public IdToken idTokenWithAudience(String targetAudience, List *

  • FORMAT_FULL *
  • LICENSES_TRUE - *
  • BIND_ID_TOKEN_FALSE + *
  • DISABLE_BOUND_ID_TOKEN * * *
    @@ -64,18 +64,23 @@ public enum Option { /** * Requests an ID token that is not bound to the workload's agent identity certificate. * + *

    This option is only supported by {@link ComputeEngineCredentials}; other {@link + * IdTokenProvider} implementations do not request bound ID tokens and ignore this option. + * *

    When an agent identity certificate is available, {@link 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. Pass this option for targets * that are called over standard (non-mTLS) HTTPS, for example a Cloud Run service reached * through its {@code *.run.app} URL or a custom domain. * - *

    If this option is not set, the library decides whether to bind the ID token; it is - * currently bound whenever an agent identity certificate is available and token binding is not - * disabled by {@code GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false}. Credential types that do not - * request bound ID tokens ignore this option. + *

    Unlike the {@code GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false} environment variable, which + * globally disables certificate-bound access tokens and ID tokens across the entire process, + * this option applies per call (or per {@link IdTokenCredentials} instance) and leaves access + * tokens and other ID token requests bound. If this option is not set, the library binds the ID + * token whenever an agent identity certificate is available and token binding is not globally + * disabled by {@code GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false}. */ - BIND_ID_TOKEN_FALSE("bindIdTokenFalse"); + DISABLE_BOUND_ID_TOKEN("disableBoundIdToken"); private final String option; diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java index 66bbfab7bcef..bc589082af2b 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ComputeEngineCredentialsTest.java @@ -1387,7 +1387,7 @@ void idTokenWithAudience_withValidCertAndKey_requestsBoundToken() throws IOExcep } @Test - void idTokenWithAudience_bindIdTokenFalse_requestsUnboundToken() throws IOException { + void idTokenWithAudience_disableBoundIdToken_requestsUnboundToken() throws IOException { setupCertAndKeyConfig(); envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true"); MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); @@ -1398,7 +1398,7 @@ void idTokenWithAudience_bindIdTokenFalse_requestsUnboundToken() throws IOExcept ComputeEngineCredentials.newBuilder().setHttpTransportFactory(transportFactory).build(); IdToken token = credentials.idTokenWithAudience( - "https://foo.bar", Arrays.asList(IdTokenProvider.Option.BIND_ID_TOKEN_FALSE)); + "https://foo.bar", Arrays.asList(IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN)); assertNotNull(token); MockLowLevelHttpRequest request = transportFactory.transport.getRequest(); @@ -1409,7 +1409,7 @@ void idTokenWithAudience_bindIdTokenFalse_requestsUnboundToken() throws IOExcept } @Test - void idTokenWithAudience_bindIdTokenFalseWithFormatFull_requestsUnboundFullToken() + void idTokenWithAudience_disableBoundIdTokenWithFormatFull_requestsUnboundFullToken() throws IOException { setupCertAndKeyConfig(); envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true"); @@ -1422,7 +1422,7 @@ void idTokenWithAudience_bindIdTokenFalseWithFormatFull_requestsUnboundFullToken credentials.idTokenWithAudience( "https://foo.bar", Arrays.asList( - IdTokenProvider.Option.FORMAT_FULL, IdTokenProvider.Option.BIND_ID_TOKEN_FALSE)); + IdTokenProvider.Option.FORMAT_FULL, IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN)); MockLowLevelHttpRequest request = transportFactory.transport.getRequest(); assertEquals("GET", transportFactory.transport.getRequestMethod()); @@ -1431,9 +1431,9 @@ void idTokenWithAudience_bindIdTokenFalseWithFormatFull_requestsUnboundFullToken } @Test - void idTokenWithAudience_bindIdTokenFalse_skipsCertificateLookup() throws IOException { + void idTokenWithAudience_disableBoundIdToken_skipsCertificateLookup() throws IOException { // The certificate config points to a missing file, which fails a bound token request. With - // BIND_ID_TOKEN_FALSE the certificate is never looked up, so the unbound request succeeds. + // DISABLE_BOUND_ID_TOKEN the certificate is never looked up, so the unbound request succeeds. envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true"); envProvider.setEnv( AgentIdentityUtils.GOOGLE_API_CERTIFICATE_CONFIG, @@ -1450,13 +1450,13 @@ void idTokenWithAudience_bindIdTokenFalse_skipsCertificateLookup() throws IOExce IdToken token = credentials.idTokenWithAudience( - "https://foo.bar", Arrays.asList(IdTokenProvider.Option.BIND_ID_TOKEN_FALSE)); + "https://foo.bar", Arrays.asList(IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN)); assertNotNull(token); assertEquals("GET", transportFactory.transport.getRequestMethod()); } @Test - void idTokenCredentials_withBindIdTokenFalseOption_requestsUnboundToken() throws IOException { + void idTokenCredentials_withDisableBoundIdTokenOption_requestsUnboundToken() throws IOException { setupCertAndKeyConfig(); envProvider.setEnv(AgentIdentityUtils.GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN, "true"); MockMetadataServerTransportFactory transportFactory = new MockMetadataServerTransportFactory(); @@ -1469,7 +1469,7 @@ void idTokenCredentials_withBindIdTokenFalseOption_requestsUnboundToken() throws IdTokenCredentials.newBuilder() .setIdTokenProvider(credentials) .setTargetAudience("https://foo.bar") - .setOptions(Arrays.asList(IdTokenProvider.Option.BIND_ID_TOKEN_FALSE)) + .setOptions(Arrays.asList(IdTokenProvider.Option.DISABLE_BOUND_ID_TOKEN)) .build(); idTokenCredentials.refresh(); From 07abb306e8443816da96127d01f60315767b918e Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Thu, 8 Oct 2026 15:19:52 +0000 Subject: [PATCH 3/3] docs(auth): clarify that unset ID token binding is decided by the library --- .../java/com/google/auth/oauth2/IdTokenProvider.java | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdTokenProvider.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdTokenProvider.java index 33979c9d42c4..942b0c6310a6 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdTokenProvider.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdTokenProvider.java @@ -76,9 +76,10 @@ public enum Option { *

    Unlike the {@code GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false} environment variable, which * globally disables certificate-bound access tokens and ID tokens across the entire process, * this option applies per call (or per {@link IdTokenCredentials} instance) and leaves access - * tokens and other ID token requests bound. If this option is not set, the library binds the ID - * token whenever an agent identity certificate is available and token binding is not globally - * disabled by {@code GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false}. + * tokens and other ID token requests bound. If this option is not set, the library decides + * whether to bind the ID token. Currently, it is bound whenever an agent identity certificate + * is available and token binding is not globally disabled by {@code + * GOOGLE_API_ENABLE_RUNTIME_BOUND_TOKEN=false}. */ DISABLE_BOUND_ID_TOKEN("disableBoundIdToken");