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
// ==================================================================================