From 8e5f0050df113ec17959eab76115311672db3be2 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Fri, 18 Sep 2026 02:18:28 +0000 Subject: [PATCH 1/5] feat(oauth2): support non-mTLS token URLs and unbound actor tokens in IdentityPoolCredentials --- .../auth/oauth2/IdentityPoolCredentials.java | 64 +--- .../oauth2/IdentityPoolCredentialsTest.java | 293 +++++++++++------- 2 files changed, 189 insertions(+), 168 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index e6846eaee550..b09b1eb9b7e9 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -40,7 +40,6 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; import java.io.ObjectInputStream; -import java.net.URI; import java.security.KeyStore; import java.util.ArrayList; import java.util.Collection; @@ -53,9 +52,9 @@ * *

By default, attempts to exchange the external credential for a GCP access token. * - *

Note: Actor token extraction is currently restricted to file-based JSON credential sources - * over mTLS endpoints. When configuring certificate-bound OAuth 2.0 tokens, ensure your transport - * layer is configured for mTLS in tandem. + *

Note: Actor token extraction from credential configuration files is currently restricted to + * file-based JSON credential sources. When configuring certificate-bound OAuth 2.0 tokens, ensure + * your transport layer and endpoints are configured for mTLS in tandem. */ @NullMarked public class IdentityPoolCredentials extends ExternalAccountCredentials { @@ -178,54 +177,6 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { throw new IllegalArgumentException( "An actorTokenSupplier must be specified when an actorTokenType is configured."); } - - if (this.actorTokenSupplier != null && !isMtlsConfigured()) { - throw new IllegalArgumentException( - "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" - + " source or MtlsHttpTransportFactory."); - } - - if (this.actorTokenSupplier != null) { - validateMtlsEndpoint(getTokenUrl(), "tokenUrl"); - if (getServiceAccountImpersonationUrl() != null) { - validateMtlsEndpoint(getServiceAccountImpersonationUrl(), "serviceAccountImpersonationUrl"); - } - } - } - - private static void validateMtlsEndpoint(@Nullable String url, String fieldName) { - if (url == null) { - return; - } - String host = URI.create(url).getHost(); - // For Google Default Universe (googleapis.com), actor tokens require mTLS binding so plain - // public endpoints (lacking '.mtls.' or Private Service Connect '.p.') are rejected early. - // Non-GDU domains (e.g. custom universes or TPC) may use different hostname conventions. - if (host != null - && host.endsWith("googleapis.com") - && !host.contains(".mtls.") - && !host.contains(".p.")) { - throw new IllegalArgumentException( - "The " - + fieldName - + " endpoint (" - + url - + ") cannot be used with actor tokens because it is a plain public Google API" - + " endpoint. Please use an mTLS endpoint (e.g. containing '.mtls.') or Private" - + " Service Connect (containing '.p.')."); - } - } - - /** - * Checks whether mTLS is properly configured by verifying that an X509Provider is set or the - * transport factory is an MtlsHttpTransportFactory with a non-null KeyStore. This avoids false - * positives from a no-arg-constructed MtlsHttpTransportFactory (e.g. after deserialization) that - * has no actual certificates. - */ - private boolean isMtlsConfigured() { - return this.x509Provider != null - || (this.transportFactory instanceof MtlsHttpTransportFactory - && ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()); } @Override @@ -479,12 +430,11 @@ public Builder setSubjectTokenSupplier(IdentityPoolSubjectTokenSupplier subjectT } /** - * Sets the actor token supplier used for certificate-bound OAuth 2.0 token exchanges. The - * supplier provides an actor token representing the entity on whose behalf the subject is - * acting. + * Sets the actor token supplier used for OAuth 2.0 token exchanges. The supplier provides an + * actor token representing the entity on whose behalf the subject is acting. * - *

An actor token supplier must be paired with an {@link #setActorTokenType actor token type} - * and requires an mTLS-configured transport. + *

An actor token supplier must be paired with an {@link #setActorTokenType actor token + * type}. * * @param actorTokenSupplier the supplier to use for retrieving actor tokens * @return this {@code Builder} object diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index a081814a9020..1252162a3459 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -1375,33 +1375,30 @@ void setShouldThrowOnGetKeyStore(boolean shouldThrow) { } @Test - void builder_actorTokenWithNonMtlsTransportFactory_throws() { + void builder_actorTokenWithNonMtlsTransportFactory_succeeds() { IdentityPoolCredentialSource credentialSource = createFileCredentialSource(); + IdentityPoolActorTokenSupplier actorSupplier = + new IdentityPoolActorTokenSupplier() { + @Override + public String getActorToken(ExternalAccountSupplierContext context) { + return "token"; + } + }; - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://invalid.googleapis.com/") - .setCredentialSource(credentialSource) - .setActorTokenType("actorTokenType") - .setActorTokenSupplier( - new IdentityPoolActorTokenSupplier() { - @Override - public String getActorToken(ExternalAccountSupplierContext context) { - return "token"; - } - }) - .build()); + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setCredentialSource(credentialSource) + .setActorTokenType("actorTokenType") + .setActorTokenSupplier(actorSupplier) + .build(); - assertEquals( - "Actor tokens are only supported for mTLS token exchanges. Please configure a certificate" - + " source or MtlsHttpTransportFactory.", - e.getMessage()); + assertNotNull(credentials); + assertSame(actorSupplier, credentials.getIdentityPoolActorTokenSupplier()); + assertEquals("actorTokenType", credentials.getActorTokenType()); } @Test @@ -1723,70 +1720,58 @@ void toBuilder_preservesActorTokenType() throws Exception { } @Test - void builder_actorTokenWithoutMtls_throws() { - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setActorTokenSupplier(testActorSupplier) - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://sts.googleapis.com/v1/token") - .build()); - assertTrue( - e.getMessage().contains("Actor tokens are only supported for mTLS token exchanges.")); + void builder_actorTokenWithoutMtls_succeeds() { + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .build(); + assertNotNull(credentials); + assertSame(testActorSupplier, credentials.getIdentityPoolActorTokenSupplier()); + assertEquals("urn:ietf:params:oauth:token-type:jwt", credentials.getActorTokenType()); } @Test - void builder_actorTokenWithNoArgMtlsFactory_throws() throws Exception { - // A no-arg MtlsHttpTransportFactory (e.g. from deserialization) has no KeyStore, - // so isMtlsConfigured() should return false and building should fail. + void builder_actorTokenWithNoArgMtlsFactory_succeeds() throws Exception { MtlsHttpTransportFactory noArgFactory = new MtlsHttpTransportFactory(); assertFalse(noArgFactory.hasKeyStore()); - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setActorTokenSupplier(testActorSupplier) - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setHttpTransportFactory(noArgFactory) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") - .build()); - assertTrue( - e.getMessage().contains("Actor tokens are only supported for mTLS token exchanges.")); + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(noArgFactory) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .build(); + assertNotNull(credentials); } @Test - void builder_actorTokenWithEmptyMtlsFactory_throws() throws Exception { + void builder_actorTokenWithEmptyMtlsFactory_succeeds() throws Exception { KeyStore emptyKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); emptyKeyStore.load(null, null); MtlsHttpTransportFactory emptyFactory = new MtlsHttpTransportFactory(emptyKeyStore); assertFalse(emptyFactory.hasKeyStore()); - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(testProvider) - .setActorTokenSupplier(testActorSupplier) - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setHttpTransportFactory(emptyFactory) - .setAudience("audience") - .setSubjectTokenType("subjectTokenType") - .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") - .build()); - assertTrue( - e.getMessage().contains("Actor tokens are only supported for mTLS token exchanges.")); + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setActorTokenSupplier(testActorSupplier) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setHttpTransportFactory(emptyFactory) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .build(); + assertNotNull(credentials); } @Test @@ -2697,52 +2682,43 @@ void serialize_deserialize_programmaticFlow_withMtlsTransport_restoresFactoryWit } @Test - void builder_actorTokenWithPlainPublicTokenUrl_throwsIllegalArgumentException() throws Exception { + void builder_actorTokenWithPlainPublicTokenUrl_succeeds() throws Exception { KeyStore keyStore = createPopulatedKeyStore(); HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(context -> "testSubjectToken") - .setActorTokenSupplier(context -> "testActorToken") - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setAudience( - "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") - .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") - .setTokenUrl("https://sts.googleapis.com/v1/token") - .setHttpTransportFactory(transportFactory) - .build()); - assertTrue(e.getMessage().contains("tokenUrl")); - assertTrue(e.getMessage().contains("plain public Google API endpoint")); + IdentityPoolCredentials cred = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setHttpTransportFactory(transportFactory) + .build(); + assertNotNull(cred); } @Test - void builder_actorTokenWithPlainPublicImpersonationUrl_throwsIllegalArgumentException() - throws Exception { + void builder_actorTokenWithPlainPublicImpersonationUrl_succeeds() throws Exception { KeyStore keyStore = createPopulatedKeyStore(); HttpTransportFactory transportFactory = new MtlsHttpTransportFactory(keyStore); - IllegalArgumentException e = - assertThrows( - IllegalArgumentException.class, - () -> - IdentityPoolCredentials.newBuilder() - .setSubjectTokenSupplier(context -> "testSubjectToken") - .setActorTokenSupplier(context -> "testActorToken") - .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") - .setAudience( - "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") - .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") - .setTokenUrl(MockExternalAccountCredentialsTransport.STS_MTLS_URL) - .setServiceAccountImpersonationUrl( - "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") - .setHttpTransportFactory(transportFactory) - .build()); - assertTrue(e.getMessage().contains("serviceAccountImpersonationUrl")); - assertTrue(e.getMessage().contains("plain public Google API endpoint")); + IdentityPoolCredentials cred = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl(MockExternalAccountCredentialsTransport.STS_MTLS_URL) + .setServiceAccountImpersonationUrl( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken") + .setHttpTransportFactory(transportFactory) + .build(); + assertNotNull(cred); } @Test @@ -3088,6 +3064,101 @@ public KeyStore getKeyStore() { assertNotSame(originalTransportFactory, scoped.getTransportFactory()); } + @Test + void refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRetry() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + + IdentityPoolCredentials.Builder builder = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> "testActorToken") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/p/providers/pr") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setHttpTransportFactory(transportFactory); + + TestableIdentityPoolCredentials testable = + new TestableIdentityPoolCredentials(builder, true, true); + + OAuthException thrown = assertThrows(OAuthException.class, testable::refreshAccessToken); + assertEquals(401, thrown.getHttpStatusCode()); + assertEquals(1, testable.getExchangeCallCount()); + } + + @Test + void + fromStream_fileCredentialSource_withoutCertificateConfig_andActorToken_withNonMtlsUrl_refreshesSuccessfully( + @TempDir Path tempDir) throws Exception { + Path tokenFile = tempDir.resolve("credential_non_mtls.json"); + GenericJson tokenJson = new GenericJson(); + tokenJson.setFactory(JSON_FACTORY); + tokenJson.put("subject_token", "nonMtlsSubjectToken"); + tokenJson.put("actor_token", "nonMtlsActorToken"); + OAuth2Utils.writeInputStreamToFile( + new ByteArrayInputStream(tokenJson.toPrettyString().getBytes(StandardCharsets.UTF_8)), + tokenFile.toString()); + + String configJson = + "{\n" + + " \"type\": \"external_account\",\n" + + " \"audience\":" + + " \"//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider\",\n" + + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"actor_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + + " \"token_url\": \"https://sts.googleapis.com/v1/token\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString() + + "\",\n" + + " \"format\": {\n" + + " \"type\": \"json\",\n" + + " \"subject_token_field_name\": \"subject_token\",\n" + + " \"actor_token_field_name\": \"actor_token\"\n" + + " }\n" + + " }\n" + + "}"; + + ExternalAccountCredentials credentials = + ExternalAccountCredentials.fromStream( + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8))); + + assertTrue(credentials instanceof IdentityPoolCredentials); + IdentityPoolCredentials idp = (IdentityPoolCredentials) credentials; + assertNull(idp.getX509Provider()); + assertEquals("urn:ietf:params:oauth:token-type:jwt", idp.getActorTokenType()); + assertFalse(idp.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertSame(idp.getIdentityPoolSubjectTokenSupplier(), idp.getIdentityPoolActorTokenSupplier()); + + AtomicReference capturedRequest = new AtomicReference<>(); + AtomicReference capturedFactory = new AtomicReference<>(); + IdentityPoolCredentials testable = + new IdentityPoolCredentials(idp.toBuilder()) { + @Override + protected AccessToken exchangeExternalCredentialForAccessToken( + StsTokenExchangeRequest stsTokenExchangeRequest, + HttpTransportFactory cycleTransportFactory) { + capturedRequest.set(stsTokenExchangeRequest); + capturedFactory.set(cycleTransportFactory); + return new AccessToken("nonMtlsAccessToken", null); + } + }; + + AccessToken token = testable.refreshAccessToken(); + assertEquals("nonMtlsAccessToken", token.getTokenValue()); + assertNotNull(capturedRequest.get()); + assertEquals("nonMtlsSubjectToken", capturedRequest.get().getSubjectToken()); + assertNotNull(capturedRequest.get().getActingParty()); + assertEquals("nonMtlsActorToken", capturedRequest.get().getActingParty().getActorToken()); + assertEquals( + "urn:ietf:params:oauth:token-type:jwt", + capturedRequest.get().getActingParty().getActorTokenType()); + assertNotNull(capturedFactory.get()); + assertFalse(capturedFactory.get() instanceof MtlsHttpTransportFactory); + } + // ================================================================================== // Helper: TestableIdentityPoolCredentials — overrides exchange for 401 testing // ================================================================================== From 5b01057d42f7866aaec73b44086ffdda41fcc5b5 Mon Sep 17 00:00:00 2001 From: macastelaz <34776182+macastelaz@users.noreply.github.com> Date: Thu, 17 Sep 2026 21:25:07 -0500 Subject: [PATCH 2/5] Apply suggestion from @gemini-code-assist[bot] Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../com/google/auth/oauth2/IdentityPoolCredentialsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 1252162a3459..12e6234dbdaa 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -3111,7 +3111,7 @@ void refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRet + " \"token_url\": \"https://sts.googleapis.com/v1/token\",\n" + " \"credential_source\": {\n" + " \"file\": \"" - + tokenFile.toString() + + tokenFile.toString().replace("\\", "\\\\") + "\",\n" + " \"format\": {\n" + " \"type\": \"json\",\n" From 82254dbfd95f151c82d593d322073369a9e302ce Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Fri, 18 Sep 2026 03:12:07 +0000 Subject: [PATCH 3/5] test(oauth2): strengthen non-mTLS actor token unit tests --- .../oauth2/IdentityPoolCredentialsTest.java | 70 ++++++++++++------- 1 file changed, 43 insertions(+), 27 deletions(-) diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 12e6234dbdaa..8fdf7efd128b 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -1752,6 +1752,9 @@ void builder_actorTokenWithNoArgMtlsFactory_succeeds() throws Exception { .setTokenUrl("https://sts.googleapis.com/v1/token") .build(); assertNotNull(credentials); + assertSame(testActorSupplier, credentials.getIdentityPoolActorTokenSupplier()); + assertEquals("urn:ietf:params:oauth:token-type:jwt", credentials.getActorTokenType()); + assertEquals("https://sts.googleapis.com/v1/token", credentials.getTokenUrl()); } @Test @@ -1772,6 +1775,9 @@ void builder_actorTokenWithEmptyMtlsFactory_succeeds() throws Exception { .setTokenUrl("https://sts.googleapis.com/v1/token") .build(); assertNotNull(credentials); + assertSame(testActorSupplier, credentials.getIdentityPoolActorTokenSupplier()); + assertEquals("urn:ietf:params:oauth:token-type:jwt", credentials.getActorTokenType()); + assertEquals("https://sts.googleapis.com/v1/token", credentials.getTokenUrl()); } @Test @@ -2698,6 +2704,8 @@ void builder_actorTokenWithPlainPublicTokenUrl_succeeds() throws Exception { .setHttpTransportFactory(transportFactory) .build(); assertNotNull(cred); + assertEquals("urn:ietf:params:oauth:token-type:jwt", cred.getActorTokenType()); + assertEquals("https://sts.googleapis.com/v1/token", cred.getTokenUrl()); } @Test @@ -2719,6 +2727,8 @@ void builder_actorTokenWithPlainPublicImpersonationUrl_succeeds() throws Excepti .setHttpTransportFactory(transportFactory) .build(); assertNotNull(cred); + assertEquals("urn:ietf:params:oauth:token-type:jwt", cred.getActorTokenType()); + assertEquals(MockExternalAccountCredentialsTransport.STS_MTLS_URL, cred.getTokenUrl()); } @Test @@ -3081,10 +3091,11 @@ void refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRet .setHttpTransportFactory(transportFactory); TestableIdentityPoolCredentials testable = - new TestableIdentityPoolCredentials(builder, true, true); + new TestableIdentityPoolCredentials(builder, true, false); OAuthException thrown = assertThrows(OAuthException.class, testable::refreshAccessToken); assertEquals(401, thrown.getHttpStatusCode()); + assertEquals(0, thrown.getSuppressed().length); assertEquals(1, testable.getExchangeCallCount()); } @@ -3092,6 +3103,9 @@ void refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRet void fromStream_fileCredentialSource_withoutCertificateConfig_andActorToken_withNonMtlsUrl_refreshesSuccessfully( @TempDir Path tempDir) throws Exception { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + Path tokenFile = tempDir.resolve("credential_non_mtls.json"); GenericJson tokenJson = new GenericJson(); tokenJson.setFactory(JSON_FACTORY); @@ -3108,7 +3122,9 @@ void refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRet + " \"//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider\",\n" + " \"subject_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" + " \"actor_token_type\": \"urn:ietf:params:oauth:token-type:jwt\",\n" - + " \"token_url\": \"https://sts.googleapis.com/v1/token\",\n" + + " \"token_url\": \"" + + transportFactory.transport.getStsUrl() + + "\",\n" + " \"credential_source\": {\n" + " \"file\": \"" + tokenFile.toString().replace("\\", "\\\\") @@ -3123,7 +3139,8 @@ void refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRet ExternalAccountCredentials credentials = ExternalAccountCredentials.fromStream( - new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8))); + new ByteArrayInputStream(configJson.getBytes(StandardCharsets.UTF_8)), + transportFactory); assertTrue(credentials instanceof IdentityPoolCredentials); IdentityPoolCredentials idp = (IdentityPoolCredentials) credentials; @@ -3132,31 +3149,30 @@ void refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRet assertFalse(idp.getTransportFactory() instanceof MtlsHttpTransportFactory); assertSame(idp.getIdentityPoolSubjectTokenSupplier(), idp.getIdentityPoolActorTokenSupplier()); - AtomicReference capturedRequest = new AtomicReference<>(); - AtomicReference capturedFactory = new AtomicReference<>(); - IdentityPoolCredentials testable = - new IdentityPoolCredentials(idp.toBuilder()) { - @Override - protected AccessToken exchangeExternalCredentialForAccessToken( - StsTokenExchangeRequest stsTokenExchangeRequest, - HttpTransportFactory cycleTransportFactory) { - capturedRequest.set(stsTokenExchangeRequest); - capturedFactory.set(cycleTransportFactory); - return new AccessToken("nonMtlsAccessToken", null); - } - }; + AccessToken token = idp.refreshAccessToken(); + assertNotNull(token); + Map query = + TestUtils.parseQuery(transportFactory.transport.getLastRequest().getContentAsString()); + assertEquals("nonMtlsSubjectToken", query.get("subject_token")); + assertEquals("nonMtlsActorToken", query.get("actor_token")); + assertEquals("urn:ietf:params:oauth:token-type:jwt", query.get("actor_token_type")); - AccessToken token = testable.refreshAccessToken(); - assertEquals("nonMtlsAccessToken", token.getTokenValue()); - assertNotNull(capturedRequest.get()); - assertEquals("nonMtlsSubjectToken", capturedRequest.get().getSubjectToken()); - assertNotNull(capturedRequest.get().getActingParty()); - assertEquals("nonMtlsActorToken", capturedRequest.get().getActingParty().getActorToken()); - assertEquals( - "urn:ietf:params:oauth:token-type:jwt", - capturedRequest.get().getActingParty().getActorTokenType()); - assertNotNull(capturedFactory.get()); - assertFalse(capturedFactory.get() instanceof MtlsHttpTransportFactory); + // Verify createScoped preserves actor token config and atomic supplier sharing + IdentityPoolCredentials scoped = + idp.createScoped( + Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); + assertEquals("urn:ietf:params:oauth:token-type:jwt", scoped.getActorTokenType()); + assertNotNull(scoped.getIdentityPoolActorTokenSupplier()); + assertSame( + scoped.getIdentityPoolSubjectTokenSupplier(), scoped.getIdentityPoolActorTokenSupplier()); + + // Verify Java serialization/deserialization round-trip preserves actor token config + IdentityPoolCredentials deserialized = serializeAndDeserialize(idp); + assertEquals("urn:ietf:params:oauth:token-type:jwt", deserialized.getActorTokenType()); + assertNotNull(deserialized.getIdentityPoolActorTokenSupplier()); + assertSame( + deserialized.getIdentityPoolSubjectTokenSupplier(), + deserialized.getIdentityPoolActorTokenSupplier()); } // ================================================================================== From 961361bab8a42d1600ea98bd12255e0c4696823f Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Tue, 22 Sep 2026 14:06:03 +0000 Subject: [PATCH 4/5] fix(auth): address PR #14430 review feedback --- .../auth/oauth2/IdentityPoolCredentials.java | 11 ++-- .../oauth2/IdentityPoolCredentialsTest.java | 52 ++++++------------- 2 files changed, 24 insertions(+), 39 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index b09b1eb9b7e9..1263fbb134e9 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -392,11 +392,14 @@ public static class Builder extends ExternalAccountCredentials.Builder { if (this.credentialSource == null) { this.subjectTokenSupplier = credentials.subjectTokenSupplier; this.actorTokenSupplier = credentials.actorTokenSupplier; + } else if (credentials.actorTokenSupplier != credentials.subjectTokenSupplier) { + this.actorTokenSupplier = credentials.actorTokenSupplier; } - // Note: when credentialSource is present, subjectTokenSupplier and actorTokenSupplier - // are intentionally NOT copied here. They will be reconstructed from credentialSource - // during build(), which ensures they share the same FileIdentityPoolSubjectTokenSupplier - // instance for atomic token reads. + // Note: when credentialSource is present and actorTokenSupplier was reconstructed from the + // same FileIdentityPoolSubjectTokenSupplier instance as subjectTokenSupplier, neither is + // copied here so they can be reconstructed together during build() for atomic token reads. + // When a distinct actorTokenSupplier was explicitly provided alongside credentialSource, it + // is preserved above so createScoped() / toBuilder() do not drop it. this.actorTokenType = credentials.actorTokenType; this.x509Provider = credentials.x509Provider; } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 8fdf7efd128b..2f2d3ff4e1dd 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -1377,13 +1377,7 @@ void setShouldThrowOnGetKeyStore(boolean shouldThrow) { @Test void builder_actorTokenWithNonMtlsTransportFactory_succeeds() { IdentityPoolCredentialSource credentialSource = createFileCredentialSource(); - IdentityPoolActorTokenSupplier actorSupplier = - new IdentityPoolActorTokenSupplier() { - @Override - public String getActorToken(ExternalAccountSupplierContext context) { - return "token"; - } - }; + IdentityPoolActorTokenSupplier actorSupplier = context -> "token"; IdentityPoolCredentials credentials = IdentityPoolCredentials.newBuilder() @@ -1399,6 +1393,12 @@ public String getActorToken(ExternalAccountSupplierContext context) { assertNotNull(credentials); assertSame(actorSupplier, credentials.getIdentityPoolActorTokenSupplier()); assertEquals("actorTokenType", credentials.getActorTokenType()); + + IdentityPoolCredentials scoped = + credentials.createScoped( + Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); + assertSame(actorSupplier, scoped.getIdentityPoolActorTokenSupplier()); + assertEquals("actorTokenType", scoped.getActorTokenType()); } @Test @@ -1737,7 +1737,7 @@ void builder_actorTokenWithoutMtls_succeeds() { } @Test - void builder_actorTokenWithNoArgMtlsFactory_succeeds() throws Exception { + void builder_actorTokenWithNoArgMtlsFactory_succeeds() { MtlsHttpTransportFactory noArgFactory = new MtlsHttpTransportFactory(); assertFalse(noArgFactory.hasKeyStore()); @@ -1752,6 +1752,7 @@ void builder_actorTokenWithNoArgMtlsFactory_succeeds() throws Exception { .setTokenUrl("https://sts.googleapis.com/v1/token") .build(); assertNotNull(credentials); + assertSame(noArgFactory, credentials.getTransportFactory()); assertSame(testActorSupplier, credentials.getIdentityPoolActorTokenSupplier()); assertEquals("urn:ietf:params:oauth:token-type:jwt", credentials.getActorTokenType()); assertEquals("https://sts.googleapis.com/v1/token", credentials.getTokenUrl()); @@ -1775,32 +1776,12 @@ void builder_actorTokenWithEmptyMtlsFactory_succeeds() throws Exception { .setTokenUrl("https://sts.googleapis.com/v1/token") .build(); assertNotNull(credentials); + assertSame(emptyFactory, credentials.getTransportFactory()); assertSame(testActorSupplier, credentials.getIdentityPoolActorTokenSupplier()); assertEquals("urn:ietf:params:oauth:token-type:jwt", credentials.getActorTokenType()); assertEquals("https://sts.googleapis.com/v1/token", credentials.getTokenUrl()); } - @Test - void mtlsHttpTransportFactory_hasKeyStore_withPopulatedKeyStore_returnsTrue() throws Exception { - KeyStore ks = createPopulatedKeyStore(); - MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(ks); - assertTrue(factory.hasKeyStore()); - } - - @Test - void mtlsHttpTransportFactory_hasKeyStore_withEmptyKeyStore_returnsFalse() throws Exception { - KeyStore emptyKeyStore = KeyStore.getInstance(KeyStore.getDefaultType()); - emptyKeyStore.load(null, null); - MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(emptyKeyStore); - assertFalse(factory.hasKeyStore()); - } - - @Test - void mtlsHttpTransportFactory_hasKeyStore_noArg_returnsFalse() { - MtlsHttpTransportFactory factory = new MtlsHttpTransportFactory(); - assertFalse(factory.hasKeyStore()); - } - // ================================================================================== // Section A: Cert Pinning & Transport Factory Tests // ================================================================================== @@ -2729,6 +2710,9 @@ void builder_actorTokenWithPlainPublicImpersonationUrl_succeeds() throws Excepti assertNotNull(cred); assertEquals("urn:ietf:params:oauth:token-type:jwt", cred.getActorTokenType()); assertEquals(MockExternalAccountCredentialsTransport.STS_MTLS_URL, cred.getTokenUrl()); + assertEquals( + "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/test@project.iam.gserviceaccount.com:generateAccessToken", + cred.getServiceAccountImpersonationUrl()); } @Test @@ -3076,9 +3060,6 @@ public KeyStore getKeyStore() { @Test void refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRetry() { - MockExternalAccountCredentialsTransportFactory transportFactory = - new MockExternalAccountCredentialsTransportFactory(); - IdentityPoolCredentials.Builder builder = IdentityPoolCredentials.newBuilder() .setSubjectTokenSupplier(context -> "testSubjectToken") @@ -3088,10 +3069,9 @@ void refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRet "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/p/providers/pr") .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") .setTokenUrl("https://sts.googleapis.com/v1/token") - .setHttpTransportFactory(transportFactory); + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY); - TestableIdentityPoolCredentials testable = - new TestableIdentityPoolCredentials(builder, true, false); + TestableIdentityPoolCredentials testable = new TestableIdentityPoolCredentials(builder, true); OAuthException thrown = assertThrows(OAuthException.class, testable::refreshAccessToken); assertEquals(401, thrown.getHttpStatusCode()); @@ -3168,6 +3148,8 @@ void refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRet // Verify Java serialization/deserialization round-trip preserves actor token config IdentityPoolCredentials deserialized = serializeAndDeserialize(idp); + assertNull(deserialized.getX509Provider()); + assertFalse(deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory); assertEquals("urn:ietf:params:oauth:token-type:jwt", deserialized.getActorTokenType()); assertNotNull(deserialized.getIdentityPoolActorTokenSupplier()); assertSame( From 78790db66d3b2d82389d43a3a748cafc49f62860 Mon Sep 17 00:00:00 2001 From: Matt Castelaz Date: Wed, 23 Sep 2026 03:31:44 +0000 Subject: [PATCH 5/5] fix(oauth2): address review feedback on actor token validation, impersonation caching, and custom transport deserialization --- .../oauth2/ExternalAccountCredentials.java | 25 +++- .../FileIdentityPoolSubjectTokenSupplier.java | 21 +-- .../auth/oauth2/IdentityPoolCredentials.java | 134 ++++++++++++------ ...eIdentityPoolSubjectTokenSupplierTest.java | 25 ++++ .../oauth2/IdentityPoolCredentialsTest.java | 117 ++++++++++++++- 5 files changed, 256 insertions(+), 66 deletions(-) diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 7191be5ca3fc..1fa07bbe81ee 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -522,8 +522,21 @@ private static boolean isAwsCredential(Map credentialSource) { && ((String) credentialSource.get("environment_id")).startsWith("aws"); } - private boolean shouldBuildImpersonatedCredential() { - return this.serviceAccountImpersonationUrl != null && this.impersonatedCredentials == null; + @Nullable ImpersonatedCredentials getImpersonatedCredentials() { + if (this.serviceAccountImpersonationUrl == null) { + return null; + } + ImpersonatedCredentials local = this.impersonatedCredentials; + if (local == null) { + synchronized (this) { + local = this.impersonatedCredentials; + if (local == null) { + local = this.buildImpersonatedCredentials(); + this.impersonatedCredentials = local; + } + } + } + return local; } /** @@ -552,11 +565,9 @@ protected AccessToken exchangeExternalCredentialForAccessToken( StsTokenExchangeRequest stsTokenExchangeRequest, HttpTransportFactory cycleTransportFactory) throws IOException { // Handle service account impersonation if necessary. - if (this.shouldBuildImpersonatedCredential()) { - this.impersonatedCredentials = this.buildImpersonatedCredentials(); - } - if (this.impersonatedCredentials != null) { - return this.impersonatedCredentials.refreshAccessToken(); + ImpersonatedCredentials impersonated = getImpersonatedCredentials(); + if (impersonated != null) { + return impersonated.refreshAccessToken(); } StsRequestHandler.Builder requestHandler = diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java index 457de2bf9378..f0c84f1c52c0 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplier.java @@ -172,7 +172,12 @@ private static String extractField(GenericJson json, String fieldName) throws IO + " must be a String but was: " + value.getClass().getName()); } - return (String) value; + String token = (String) value; + if (token.trim().isEmpty()) { + throw new IOException( + "Invalid token field value. Empty token was found for field: " + fieldName); + } + return token; } /** Used primarily for UrlIdentityPoolSubjectTokenSupplier */ @@ -195,19 +200,7 @@ static String parseToken( GenericJson fileContents = parser.parseAndClose(in, StandardCharsets.UTF_8, GenericJson.class); - Object value = fileContents.get(targetFieldName); - if (value == null || Data.isNull(value)) { - throw new IOException( - "Invalid token field name. No token was found for field: " + targetFieldName); - } - if (!(value instanceof String)) { - throw new IOException( - "Token field value for " - + targetFieldName - + " must be a String but was: " - + value.getClass().getName()); - } - return (String) value; + return extractField(fileContents, targetFieldName); } } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java index 1263fbb134e9..29e7cce3ae50 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/IdentityPoolCredentials.java @@ -71,7 +71,8 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { private final @Nullable String actorTokenType; // Transient: not serialized directly. Reconstructed in readObject() from the credentialSource // certificate config so deserialized credentials remain usable for mTLS and refresh. - private transient @Nullable X509Provider x509Provider; + private transient volatile @Nullable X509Provider x509Provider; + private final boolean useMtlsTransportFactory; private final ExternalAccountSupplierContext supplierContext; private final String metricsHeaderValue; @@ -97,8 +98,13 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { "A subjectTokenSupplier or a credentialSource must be provided."); } - // Store the x509Provider for per-cycle cert pinning. + // Store the x509Provider and useMtlsTransportFactory flag for per-cycle cert pinning and + // deserialization recovery. this.x509Provider = builder.x509Provider; + this.useMtlsTransportFactory = + builder.useMtlsTransportFactory != null + ? builder.useMtlsTransportFactory + : isDefaultOrMtlsTransportFactory(builder.transportFactory); // Initialize based on the source type if (builder.subjectTokenSupplier != null) { @@ -107,22 +113,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { } else if (credentialSource.credentialSourceType == IdentityPoolCredentialSourceType.FILE) { if (credentialSource.getCertificateConfig() != null) { try { - X509Provider x509Provider = getX509Provider(builder, credentialSource); - this.x509Provider = x509Provider; - KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - if (builder.transportFactory == null - || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY - || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class) { - this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); - } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { - LOGGER_PROVIDER - .getLogger() - .debug( - "Custom HttpTransportFactory provided with certificate configuration; skipping" - + " automatic MtlsHttpTransportFactory upgrade. Ensure the custom transport" - + " factory is configured for mTLS if required by the token endpoint."); - } + initializeMtlsTransport(builder, credentialSource); } catch (Exception e) { throw new RuntimeException( "Failed to initialize mTLS transport for file credential source due to certificate" @@ -179,13 +170,44 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { } } + static boolean isDefaultOrMtlsTransportFactory(@Nullable HttpTransportFactory transportFactory) { + return transportFactory == null + || transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY + || transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory + || transportFactory.getClass() == MtlsHttpTransportFactory.class + || (transportFactory instanceof MtlsHttpTransportFactory + && !((MtlsHttpTransportFactory) transportFactory).hasKeyStore()); + } + + @VisibleForTesting + boolean shouldUseMtlsTransportFactory() { + return this.useMtlsTransportFactory + || (this.transportFactory instanceof MtlsHttpTransportFactory + && !((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore()); + } + + boolean hasInitializedMtlsTransport() { + return this.x509Provider != null + && (!shouldUseMtlsTransportFactory() + || (this.transportFactory instanceof MtlsHttpTransportFactory + && ((MtlsHttpTransportFactory) this.transportFactory).hasKeyStore())); + } + @Override public AccessToken refreshAccessToken() throws IOException { // Per-cycle cert pinning: snapshot the KeyStore at the start of each refresh cycle. HttpTransportFactory cycleTransportFactory = this.transportFactory; - if (this.x509Provider != null && this.transportFactory instanceof MtlsHttpTransportFactory) { + if (this.x509Provider != null && shouldUseMtlsTransportFactory()) { KeyStore pinnedKeyStore = this.x509Provider.getKeyStore(); - cycleTransportFactory = new MtlsHttpTransportFactory(pinnedKeyStore); + cycleTransportFactory = createMtlsTransportFactory(pinnedKeyStore); + if (!hasInitializedMtlsTransport()) { + this.transportFactory = cycleTransportFactory; + } + } + + ImpersonatedCredentials impersonated = getImpersonatedCredentials(); + if (impersonated != null) { + return impersonated.refreshAccessToken(); } // Read subject and actor tokens, atomically if from the same file supplier. @@ -209,8 +231,13 @@ public AccessToken refreshAccessToken() throws IOException { StsTokenExchangeRequest.newBuilder(subjectToken, getSubjectTokenType()) .setAudience(getAudience()); - if (actorToken != null && this.actorTokenType != null) { - stsTokenExchangeRequest.setActingParty(new ActingParty(actorToken, this.actorTokenType)); + if (this.actorTokenSupplier != null) { + if (actorToken == null || actorToken.trim().isEmpty()) { + throw new IOException("The provided actor token cannot be null or empty."); + } + if (this.actorTokenType != null) { + stsTokenExchangeRequest.setActingParty(new ActingParty(actorToken, this.actorTokenType)); + } } Collection scopes = getScopes(); @@ -224,11 +251,14 @@ public AccessToken refreshAccessToken() throws IOException { } catch (OAuthException e) { if (e.getHttpStatusCode() == 401 && this.x509Provider != null - && this.transportFactory instanceof MtlsHttpTransportFactory) { + && shouldUseMtlsTransportFactory()) { try { // On 401, re-read from X509Provider for fresh certs and retry once. KeyStore freshKeyStore = this.x509Provider.getKeyStore(); - HttpTransportFactory retryTransportFactory = new MtlsHttpTransportFactory(freshKeyStore); + HttpTransportFactory retryTransportFactory = createMtlsTransportFactory(freshKeyStore); + if (!hasInitializedMtlsTransport()) { + this.transportFactory = retryTransportFactory; + } return exchangeExternalCredentialForAccessToken( stsTokenExchangeRequest.build(), retryTransportFactory); } catch (IOException retryException) { @@ -275,6 +305,11 @@ HttpTransportFactory getTransportFactory() { return this.x509Provider; } + @VisibleForTesting + HttpTransportFactory createMtlsTransportFactory(KeyStore keyStore) { + return new MtlsHttpTransportFactory(keyStore); + } + /** Clones the IdentityPoolCredentials with the specified scopes. */ @Override public IdentityPoolCredentials createScoped(Collection newScopes) { @@ -294,17 +329,16 @@ public Builder toBuilder() { return new Builder(this); } - private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( + private void initializeMtlsTransport( Builder builder, IdentityPoolCredentialSource credentialSource) throws IOException { - // Configure the mTLS transport with the x509 keystore if custom transport was not provided. X509Provider x509Provider = getX509Provider(builder, credentialSource); this.x509Provider = x509Provider; + if (builder.isClonedTransportInitialized) { + return; + } KeyStore mtlsKeyStore = x509Provider.getKeyStore(); - if (builder.transportFactory == null - || builder.transportFactory == OAuth2Utils.HTTP_TRANSPORT_FACTORY - || builder.transportFactory instanceof OAuth2Utils.DefaultHttpTransportFactory - || builder.transportFactory.getClass() == MtlsHttpTransportFactory.class) { - this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); + if (shouldUseMtlsTransportFactory()) { + this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); } else if (!(builder.transportFactory instanceof MtlsHttpTransportFactory)) { LOGGER_PROVIDER .getLogger() @@ -313,12 +347,20 @@ private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( + " automatic MtlsHttpTransportFactory upgrade. Ensure the custom transport" + " factory is configured for mTLS if required by the token endpoint."); } + } - // Initialize the subject token supplier with the certificate path. - String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); - credentialSource.setCredentialLocation( - MtlsUtils.getCertificatePath( - getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath)); + private IdentityPoolSubjectTokenSupplier createCertificateSubjectTokenSupplier( + Builder builder, IdentityPoolCredentialSource credentialSource) throws IOException { + // Configure the mTLS transport with the x509 keystore if custom transport was not provided. + initializeMtlsTransport(builder, credentialSource); + + // Initialize the subject token supplier with the certificate path if not already set. + if (credentialSource.getCredentialLocation() == null) { + String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); + credentialSource.setCredentialLocation( + MtlsUtils.getCertificatePath( + getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath)); + } return new CertificateIdentityPoolSubjectTokenSupplier(credentialSource); } @@ -344,11 +386,13 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou String explicitCertConfigPath = getExplicitCertConfigPath(credentialSource); this.x509Provider = new X509Provider(getEnvironmentProvider(), getPropertyProvider(), explicitCertConfigPath); - try { - KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); - this.transportFactory = new MtlsHttpTransportFactory(mtlsKeyStore); - } catch (Exception e) { - // Cert loading failure will be handled on refreshAccessToken() + if (shouldUseMtlsTransportFactory()) { + try { + KeyStore mtlsKeyStore = this.x509Provider.getKeyStore(); + this.transportFactory = createMtlsTransportFactory(mtlsKeyStore); + } catch (Exception e) { + // Cert loading failure will be handled on refreshAccessToken() + } } } } @@ -384,6 +428,8 @@ public static class Builder extends ExternalAccountCredentials.Builder { private @Nullable IdentityPoolActorTokenSupplier actorTokenSupplier; private @Nullable String actorTokenType; private @Nullable X509Provider x509Provider; + private @Nullable Boolean useMtlsTransportFactory; + private boolean isClonedTransportInitialized; Builder() {} @@ -402,6 +448,8 @@ public static class Builder extends ExternalAccountCredentials.Builder { // is preserved above so createScoped() / toBuilder() do not drop it. this.actorTokenType = credentials.actorTokenType; this.x509Provider = credentials.x509Provider; + this.useMtlsTransportFactory = credentials.useMtlsTransportFactory; + this.isClonedTransportInitialized = credentials.hasInitializedMtlsTransport(); } /** @@ -417,6 +465,7 @@ public static class Builder extends ExternalAccountCredentials.Builder { @VisibleForTesting Builder setX509Provider(X509Provider x509Provider) { this.x509Provider = x509Provider; + this.isClonedTransportInitialized = false; return this; } @@ -469,6 +518,8 @@ Builder setActorTokenType(String actorTokenType) { @CanIgnoreReturnValue public Builder setHttpTransportFactory(HttpTransportFactory transportFactory) { super.setHttpTransportFactory(transportFactory); + this.useMtlsTransportFactory = isDefaultOrMtlsTransportFactory(transportFactory); + this.isClonedTransportInitialized = false; return this; } @@ -503,6 +554,7 @@ public Builder setTokenUrl(String tokenUrl) { @CanIgnoreReturnValue public Builder setCredentialSource(IdentityPoolCredentialSource credentialSource) { super.setCredentialSource(credentialSource); + this.isClonedTransportInitialized = false; return this; } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java index 7cfc4ce5719a..cf05025e8fae 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/FileIdentityPoolSubjectTokenSupplierTest.java @@ -449,6 +449,31 @@ void readTokens_missingSubjectField_throwsIOException(@TempDir Path tempDir) thr assertTrue(exception.getMessage().contains("No token was found for field: sub_token")); } + @Test + void readTokens_emptyOrWhitespaceActorField_throwsIOException(@TempDir Path tempDir) + throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, + "{\"sub_token\": \"my_sub_token\", \"act_token\": \" \"}" + .getBytes(StandardCharsets.UTF_8)); + + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", credentialFile.toString()); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "sub_token"); + formatMap.put("actor_token_field_name", "act_token"); + credentialSourceMap.put("format", formatMap); + + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + + IOException exception = assertThrows(IOException.class, () -> supplier.readTokens(null)); + assertTrue(exception.getMessage().contains("Empty token was found for field: act_token")); + } + @Test void readTokens_noActorFieldConfigured_returnsNullActor(@TempDir Path tempDir) throws IOException { diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java index 2f2d3ff4e1dd..03a1268d396a 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/IdentityPoolCredentialsTest.java @@ -484,7 +484,7 @@ void refreshAccessToken_withServiceAccountImpersonation() throws IOException { // Validate metrics header is set correctly on the sts request. Map> headers = - transportFactory.transport.getRequests().get(2).getHeaders(); + transportFactory.transport.getRequests().get(1).getHeaders(); ExternalAccountCredentialsTest.validateMetricsHeader(headers, "url", true, false); } @@ -525,7 +525,7 @@ void refreshAccessToken_withServiceAccountImpersonationOptions() throws IOExcept // Validate metrics header is set correctly on the sts request. Map> headers = - transportFactory.transport.getRequests().get(2).getHeaders(); + transportFactory.transport.getRequests().get(1).getHeaders(); ExternalAccountCredentialsTest.validateMetricsHeader(headers, "url", true, true); } @@ -3053,9 +3053,14 @@ public KeyStore getKeyStore() { credential.createScoped( Collections.singletonList("https://www.googleapis.com/auth/cloud-platform")); - assertEquals(2, getKeyStoreCount.get()); + assertEquals(1, getKeyStoreCount.get()); assertTrue(scoped.getTransportFactory() instanceof MtlsHttpTransportFactory); - assertNotSame(originalTransportFactory, scoped.getTransportFactory()); + assertSame(originalTransportFactory, scoped.getTransportFactory()); + + IdentityPoolCredentials rebuiltWithProvider = + credential.toBuilder().setX509Provider(trackingProvider).build(); + assertEquals(2, getKeyStoreCount.get()); + assertNotSame(originalTransportFactory, rebuiltWithProvider.getTransportFactory()); } @Test @@ -3157,6 +3162,110 @@ void refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRet deserialized.getIdentityPoolActorTokenSupplier()); } + @Test + void refreshAccessToken_withImpersonationAndActorToken_fetchesTokensOnceAndCachesStsToken() + throws Exception { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + transportFactory.transport.setExpireTime(TestUtils.getDefaultExpireTime()); + AtomicInteger subjectTokenCount = new AtomicInteger(0); + AtomicInteger actorTokenCount = new AtomicInteger(0); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier( + context -> "subjectToken-" + subjectTokenCount.incrementAndGet()) + .setActorTokenSupplier(context -> "actorToken-" + actorTokenCount.incrementAndGet()) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl(transportFactory.transport.getStsUrl()) + .setServiceAccountImpersonationUrl( + transportFactory.transport.getServiceAccountImpersonationUrl()) + .setHttpTransportFactory(transportFactory) + .build(); + + AccessToken firstToken = credentials.refreshAccessToken(); + assertNotNull(firstToken); + assertEquals(1, subjectTokenCount.get()); + assertEquals(1, actorTokenCount.get()); + + // Second refresh while intermediate STS token is still cached should not re-fetch subject or + // actor tokens. + AccessToken secondToken = credentials.refreshAccessToken(); + assertNotNull(secondToken); + assertEquals(1, subjectTokenCount.get()); + assertEquals(1, actorTokenCount.get()); + } + + @Test + void refreshAccessToken_withNullOrWhitespaceActorToken_throwsIOException() { + IdentityPoolCredentials nullActorCredentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> null) + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .build(); + + IOException nullException = + assertThrows(IOException.class, nullActorCredentials::refreshAccessToken); + assertEquals("The provided actor token cannot be null or empty.", nullException.getMessage()); + + IdentityPoolCredentials blankActorCredentials = + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(context -> "testSubjectToken") + .setActorTokenSupplier(context -> " ") + .setActorTokenType("urn:ietf:params:oauth:token-type:jwt") + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:jwt") + .setTokenUrl("https://sts.googleapis.com/v1/token") + .setHttpTransportFactory(OAuth2Utils.HTTP_TRANSPORT_FACTORY) + .build(); + + IOException blankException = + assertThrows(IOException.class, blankActorCredentials::refreshAccessToken); + assertEquals("The provided actor token cannot be null or empty.", blankException.getMessage()); + } + + @Test + void serialize_deserialize_withCustomTransportFactoryAndCertConfig_preservesCustomFactory() + throws Exception { + MockExternalAccountCredentialsTransportFactory customFactory = + new MockExternalAccountCredentialsTransportFactory(); + + Map certMap = new HashMap<>(); + certMap.put("use_default_certificate_config", true); + Map sourceMap = new HashMap<>(); + sourceMap.put("file", "credential.json"); + sourceMap.put("certificate", certMap); + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(sourceMap); + + KeyStore ks = createPopulatedKeyStore(); + X509Provider x509Provider = new TestX509Provider(ks, "certificate_config_location"); + + IdentityPoolCredentials credentials = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setX509Provider(x509Provider) + .setHttpTransportFactory(customFactory) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + + assertFalse(credentials.getTransportFactory() instanceof MtlsHttpTransportFactory); + + IdentityPoolCredentials deserialized = serializeAndDeserialize(credentials); + assertNotNull(deserialized.getX509Provider()); + assertFalse(deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertEquals(customFactory.getClass(), deserialized.getTransportFactory().getClass()); + } + // ================================================================================== // Helper: TestableIdentityPoolCredentials — overrides exchange for 401 testing // ==================================================================================