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 d1d6c4d55918..06765b154fb1 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 @@ -42,11 +42,11 @@ 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; import java.util.Map; +import java.util.Objects; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -55,9 +55,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 { @@ -104,10 +104,7 @@ public class IdentityPoolCredentials extends ExternalAccountCredentials { // 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); + this.useMtlsTransportFactory = builder.useMtlsTransportFactory; // Initialize based on the source type if (builder.subjectTokenSupplier != null) { @@ -171,43 +168,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" - + " configuration in the credential source or provide an MtlsHttpTransportFactory" - + " constructed with a KeyStore."); - } - - 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.')."); - } } /** @@ -242,7 +202,6 @@ boolean isMtlsConfigured() { */ 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 @@ -266,7 +225,7 @@ boolean hasMtlsProviderForImpersonation() { * Returns whether a clone of this credential can skip reading the KeyStore because the transport * factory is already initialized (an mTLS factory with a KeyStore, or a custom non-mTLS factory). */ - boolean hasInitializedMtlsTransport() { + private boolean hasInitializedMtlsTransport() { return this.x509Provider != null && (!shouldUseMtlsTransportFactory() || (this.transportFactory instanceof MtlsHttpTransportFactory @@ -463,8 +422,12 @@ private AccessToken buildAndExchangeStsRequest( 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."); + } + stsTokenExchangeRequest.setActingParty( + new ActingParty(actorToken, Objects.requireNonNull(this.actorTokenType))); } Collection scopes = getScopes(); @@ -640,7 +603,7 @@ 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 useMtlsTransportFactory = true; private boolean isClonedTransportInitialized; Builder() {} @@ -653,10 +616,11 @@ public static class Builder extends ExternalAccountCredentials.Builder { } else if (credentials.actorTokenSupplier != credentials.subjectTokenSupplier) { this.actorTokenSupplier = credentials.actorTokenSupplier; } - // Note: when credentialSource is present, subjectTokenSupplier and file-based - // 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; // Preserve the original mTLS flag across clones because super(credentials) copies @@ -696,12 +660,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 acting party that acts on behalf of the subject. * - *

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 @@ -768,6 +731,12 @@ public Builder setTokenUrl(String tokenUrl) { @CanIgnoreReturnValue public Builder setCredentialSource(IdentityPoolCredentialSource credentialSource) { + if (this.credentialSource != null && this.credentialSource != credentialSource) { + this.x509Provider = null; + if (this.useMtlsTransportFactory) { + this.transportFactory = null; + } + } 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..5564116ee245 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 @@ -380,6 +380,36 @@ void parseToken_jsonFormat_missingFieldName_throws() { "Target field name must be specified for JSON credentials.", exception.getMessage()); } + @Test + void parseToken_jsonFormat_emptyOrWhitespaceField_throws(@TempDir Path tempDir) + throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write(credentialFile, "{\"sub_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"); + credentialSourceMap.put("format", formatMap); + IdentityPoolCredentialSource source = new IdentityPoolCredentialSource(credentialSourceMap); + + FileIdentityPoolSubjectTokenSupplier supplier = + new FileIdentityPoolSubjectTokenSupplier(source); + IOException getSubjectException = + assertThrows(IOException.class, () -> supplier.getSubjectToken(null)); + assertTrue( + getSubjectException.getMessage().contains("Empty token was found for field: sub_token")); + + ByteArrayInputStream stream = + new ByteArrayInputStream("{\"sub_token\": \" \"}".getBytes(StandardCharsets.UTF_8)); + IOException parseException = + assertThrows( + IOException.class, + () -> FileIdentityPoolSubjectTokenSupplier.parseToken(stream, source, "sub_token")); + assertTrue(parseException.getMessage().contains("Empty token was found for field: sub_token")); + } + @Test void readTokens_extractsBothFields(@TempDir Path tempDir) throws IOException { Path credentialFile = tempDir.resolve("credential.json"); @@ -449,6 +479,56 @@ 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_emptyOrWhitespaceSubjectField_throwsIOException(@TempDir Path tempDir) + throws IOException { + Path credentialFile = tempDir.resolve("credential.json"); + Files.write( + credentialFile, + "{\"sub_token\": \" \", \"act_token\": \"my_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: sub_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 79fefa6a5cc5..8ccf8916baab 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 @@ -1468,34 +1468,30 @@ void setShouldThrowOnGetKeyStore(boolean shouldThrow) { } @Test - void builder_actorTokenWithNonMtlsTransportFactory_throws() { + void builder_actorTokenWithNonMtlsTransportFactory_succeeds() { IdentityPoolCredentialSource credentialSource = createFileCredentialSource(); + IdentityPoolActorTokenSupplier actorSupplier = context -> "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" - + " configuration in the credential source or provide an MtlsHttpTransportFactory" - + " constructed with a KeyStore.", - e.getMessage()); + 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 @@ -1817,91 +1813,66 @@ 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() { 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); + 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()); } @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.")); - } - - @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()); + 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); + 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()); } // ================================================================================== @@ -2901,52 +2872,50 @@ 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); + assertEquals("urn:ietf:params:oauth:token-type:jwt", cred.getActorTokenType()); + assertEquals("https://sts.googleapis.com/v1/token", cred.getTokenUrl()); } @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); + 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 @@ -3323,6 +3292,336 @@ public KeyStore getKeyStore() { assertNotSame(originalTransportFactory, rebuiltWithProvider.getTransportFactory()); } + @Test + void toBuilder_setCredentialSource_withNewSource_clearsCopiedX509Provider() throws Exception { + Map certificateMap = new HashMap<>(); + certificateMap.put("use_default_certificate_config", false); + certificateMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + Map formatMap = new HashMap<>(); + formatMap.put("type", "json"); + formatMap.put("subject_token_field_name", "subject_token"); + Map credentialSourceMap = new HashMap<>(); + credentialSourceMap.put("file", "credential.json"); + credentialSourceMap.put("format", formatMap); + credentialSourceMap.put("certificate", certificateMap); + + KeyStore ks = createPopulatedKeyStore(); + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider trackingProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + getKeyStoreCount.incrementAndGet(); + return ks; + } + }; + + IdentityPoolCredentials credential = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(new IdentityPoolCredentialSource(credentialSourceMap)) + .setX509Provider(trackingProvider) + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token") + .build(); + assertSame(trackingProvider, credential.getX509Provider()); + assertEquals(1, getKeyStoreCount.get()); + + IdentityPoolCredentials rebuilt = + credential.toBuilder() + .setCredentialSource(new IdentityPoolCredentialSource(credentialSourceMap)) + .build(); + + assertNotNull(rebuilt.getX509Provider()); + assertNotSame(trackingProvider, rebuilt.getX509Provider()); + assertNotSame(credential.getTransportFactory(), rebuilt.getTransportFactory()); + assertEquals(1, getKeyStoreCount.get()); + + Map sourceMapWithoutCert = new HashMap<>(); + sourceMapWithoutCert.put("file", "credential.json"); + sourceMapWithoutCert.put("format", formatMap); + IdentityPoolCredentials rebuiltWithoutCert = + credential.toBuilder() + .setCredentialSource(new IdentityPoolCredentialSource(sourceMapWithoutCert)) + .build(); + + assertNull(rebuiltWithoutCert.getX509Provider()); + assertFalse(rebuiltWithoutCert.getTransportFactory() instanceof MtlsHttpTransportFactory); + } + + @Test + void toBuilder_setHttpTransportFactory_withCustomFactoryAndCertConfig_preservesCustomFactory() + throws Exception { + Map certMap = new HashMap<>(); + certMap.put("use_default_certificate_config", false); + certMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + Map sourceMap = new HashMap<>(); + sourceMap.put("certificate", certMap); + IdentityPoolCredentialSource credentialSource = new IdentityPoolCredentialSource(sourceMap); + + MockExternalAccountCredentialsTransportFactory customFactory = + new MockExternalAccountCredentialsTransportFactory(); + IdentityPoolCredentials credential = + IdentityPoolCredentials.newBuilder() + .setCredentialSource(credentialSource) + .setAudience("audience") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:mtls") + .setTokenUrl(customFactory.transport.getStsUrl()) + .build(); + assertTrue(credential.getTransportFactory() instanceof MtlsHttpTransportFactory); + + IdentityPoolCredentials rebuilt = + credential.toBuilder().setHttpTransportFactory(customFactory).build(); + + assertSame(customFactory, rebuilt.getTransportFactory()); + rebuilt.refreshAccessToken(); + assertSame(customFactory, rebuilt.getTransportFactory()); + assertEquals(1, customFactory.transport.getRequests().size()); + } + + @Test + void refreshAccessToken_withoutImpersonation_readsKeyStoreOncePerCycle() throws Exception { + AtomicInteger getKeyStoreCount = new AtomicInteger(0); + X509Provider countingProvider = + new X509Provider() { + @Override + public KeyStore getKeyStore() { + getKeyStoreCount.incrementAndGet(); + return createPopulatedKeyStore(); + } + }; + + TransportCapturingCredentials credential = + new TransportCapturingCredentials( + IdentityPoolCredentials.newBuilder() + .setSubjectTokenSupplier(testProvider) + .setX509Provider(countingProvider) + .setAudience( + "//iam.googleapis.com/projects/123/locations/global/workloadIdentityPools/pool/providers/provider") + .setSubjectTokenType("urn:ietf:params:oauth:token-type:id_token") + .setTokenUrl("https://sts.mtls.googleapis.com/v1/token")); + assertEquals(0, getKeyStoreCount.get()); + + // First cycle: lazy initialization loads the KeyStore once and the exchange reuses it. + credential.refreshAccessToken(); + assertEquals(1, getKeyStoreCount.get()); + assertSame(credential.getTransportFactory(), credential.getCapturedFactories().get(0)); + + // Later cycles: one fresh per-cycle snapshot each. + credential.refreshAccessToken(); + assertEquals(2, getKeyStoreCount.get()); + assertNotSame(credential.getTransportFactory(), credential.getCapturedFactories().get(1)); + assertTrue(credential.getCapturedFactories().get(1) instanceof MtlsHttpTransportFactory); + } + + @Test + void refreshAccessToken_401WithActorTokenAndNonMtlsTransport_bubblesUpWithoutRetry() { + 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") + .setX509Provider( + new TestX509Provider(createPopulatedKeyStore(), "certificate_config_location")) + .setHttpTransportFactory(new MockExternalAccountCredentialsTransportFactory()); + + TestableIdentityPoolCredentials testable = new TestableIdentityPoolCredentials(builder, true); + + OAuthException thrown = assertThrows(OAuthException.class, testable::refreshAccessToken); + assertEquals(401, thrown.getHttpStatusCode()); + assertEquals(0, thrown.getSuppressed().length); + assertEquals(1, testable.getExchangeCallCount()); + } + + @Test + 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); + 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\": \"" + + transportFactory.transport.getStsUrl() + + "\",\n" + + " \"credential_source\": {\n" + + " \"file\": \"" + + tokenFile.toString().replace("\\", "\\\\") + + "\",\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)), + transportFactory); + + 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()); + + 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")); + + // 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); + assertNull(deserialized.getX509Provider()); + assertFalse(deserialized.getTransportFactory() instanceof MtlsHttpTransportFactory); + assertEquals("urn:ietf:params:oauth:token-type:jwt", deserialized.getActorTokenType()); + assertNotNull(deserialized.getIdentityPoolActorTokenSupplier()); + assertSame( + deserialized.getIdentityPoolSubjectTokenSupplier(), + 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() { + MockExternalAccountCredentialsTransportFactory transportFactory = + new MockExternalAccountCredentialsTransportFactory(); + 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(STS_URL) + .setHttpTransportFactory(transportFactory) + .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(STS_URL) + .setHttpTransportFactory(transportFactory) + .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", false); + certMap.put("certificate_config_location", "testresources/mtls/certificate_config.json"); + 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 // ==================================================================================