diff --git a/appengine/java/com/google/auth/appengine/AppEngineCredentials.java b/appengine/java/com/google/auth/appengine/AppEngineCredentials.java index 16d046907..4bf3f2614 100644 --- a/appengine/java/com/google/auth/appengine/AppEngineCredentials.java +++ b/appengine/java/com/google/auth/appengine/AppEngineCredentials.java @@ -43,6 +43,8 @@ import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; import java.io.ObjectInputStream; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; import java.util.Collection; import java.util.Date; import java.util.Objects; @@ -132,7 +134,29 @@ public boolean equals(Object obj) { private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { input.defaultReadObject(); - appIdentityService = newInstance(appIdentityServiceClassName); + try { + // Load the class without initializing it (second argument: false) to prevent + // static initializers from running, which could potentially be used for + // malicious purposes. Use the class loader of AppIdentityService (third + // argument) to ensure the class is loaded from the same context as the library, + // preventing class loading manipulation. + Class clazz = + Class.forName( + appIdentityServiceClassName, false, AppIdentityService.class.getClassLoader()); + if (!AppIdentityService.class.isAssignableFrom(clazz)) { + throw new IOException( + String.format( + "The class, %s, is not assignable from %s.", + appIdentityServiceClassName, AppIdentityService.class.getName())); + } + Constructor constructor = clazz.getConstructor(); + appIdentityService = (AppIdentityService) constructor.newInstance(); + } catch (InstantiationException + | IllegalAccessException + | NoSuchMethodException + | InvocationTargetException e) { + throw new IOException(e); + } } public static Builder newBuilder() { diff --git a/appengine/javatests/com/google/auth/appengine/AppEngineDeserializationSecurityTest.java b/appengine/javatests/com/google/auth/appengine/AppEngineDeserializationSecurityTest.java new file mode 100644 index 000000000..42b6a6f08 --- /dev/null +++ b/appengine/javatests/com/google/auth/appengine/AppEngineDeserializationSecurityTest.java @@ -0,0 +1,134 @@ +/* + * Copyright 2026, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.appengine; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.lang.reflect.Field; +import java.util.Collections; +import org.junit.jupiter.api.Test; + +class AppEngineDeserializationSecurityTest { + + /** A class that does not implement HttpTransportFactory. */ + static class ArbitraryClass {} + + @Test + void testArbitraryClassInstantiationPrevented() throws Exception { + // 1. Create valid credentials + AppEngineCredentials credentials = + AppEngineCredentials.newBuilder().setScopes(Collections.singleton("scope")).build(); + + // 2. Use reflection to set appIdentityServiceClassName to ArbitraryClass + // as the setter must be of AppIdentityService + Field classNameField = + AppEngineCredentials.class.getDeclaredField("appIdentityServiceClassName"); + classNameField.setAccessible(true); + classNameField.set(credentials, ArbitraryClass.class.getName()); + + // 3. Serialize + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(credentials); + oos.close(); + + // 4. Deserialize + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bis); + + // 5. Assert that IOException is thrown (validation failure) + assertThrows(IOException.class, ois::readObject); + } + + @Test + void testValidServiceDeserialization() throws Exception { + // 1. Create valid credentials with MockAppIdentityService + MockAppIdentityService mockService = new MockAppIdentityService(); + AppEngineCredentials credentials = + AppEngineCredentials.newBuilder() + .setScopes(Collections.singleton("scope")) + .setAppIdentityService(mockService) + .build(); + + // 2. Serialize + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(credentials); + oos.close(); + + // 3. Deserialize + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bis); + + AppEngineCredentials deserialized = (AppEngineCredentials) ois.readObject(); + + // 4. Verify deserialization success and field type + assertNotNull(deserialized); + Field serviceField = AppEngineCredentials.class.getDeclaredField("appIdentityService"); + serviceField.setAccessible(true); + Object service = serviceField.get(deserialized); + assertEquals(MockAppIdentityService.class, service.getClass()); + } + + @Test + void testNonExistentClassDeserialization() throws Exception { + // 1. Create valid credentials + AppEngineCredentials credentials = + AppEngineCredentials.newBuilder().setScopes(Collections.singleton("scope")).build(); + + // 2. Use reflection to set appIdentityServiceClassName to non-existent class + Field classNameField = + AppEngineCredentials.class.getDeclaredField("appIdentityServiceClassName"); + classNameField.setAccessible(true); + classNameField.set(credentials, "com.google.nonexistent.Class"); + + // 3. Serialize + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(credentials); + oos.close(); + + // 4. Deserialize + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bis); + + // 5. Assert ClassNotFoundException + assertThrows(ClassNotFoundException.class, ois::readObject); + } +} diff --git a/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java index d36b5c3df..c190a62ab 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ComputeEngineCredentials.java @@ -673,6 +673,9 @@ public boolean equals(Object obj) { private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { input.defaultReadObject(); + // Use Oauth2Credential's newInstance() to try to safely instantiate the transport factory, + // with best-effort prevention against RCE attacks by validating the class name and loading + // behavior. transportFactory = newInstance(transportFactoryClassName); } diff --git a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java index eb2d64e09..fab66c79d 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountAuthorizedUserCredentials.java @@ -355,6 +355,9 @@ static ExternalAccountAuthorizedUserCredentials fromJson( private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { input.defaultReadObject(); + // Use Oauth2Credential's newInstance() to try to safely instantiate the transport factory, + // with best-effort prevention against RCE attacks by validating the class name and loading + // behavior. transportFactory = newInstance(transportFactoryClassName); } diff --git a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java index 7f9f0c207..fc0b4c72f 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ExternalAccountCredentials.java @@ -605,6 +605,9 @@ public CredentialSource getCredentialSource() { private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { // Properly deserialize the transient transportFactory. input.defaultReadObject(); + // Use Oauth2Credential's newInstance() to try to safely instantiate the transport factory, + // with best-effort prevention against RCE attacks by validating the class name and loading + // behavior. transportFactory = newInstance(transportFactoryClassName); } diff --git a/oauth2_http/java/com/google/auth/oauth2/GdchCredentials.java b/oauth2_http/java/com/google/auth/oauth2/GdchCredentials.java index c5e8bd576..7dc7766e4 100644 --- a/oauth2_http/java/com/google/auth/oauth2/GdchCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/GdchCredentials.java @@ -378,6 +378,9 @@ public Builder toBuilder() { private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { // properly deserialize the transient transportFactory. input.defaultReadObject(); + // Use Oauth2Credential's newInstance() to try to safely instantiate the transport factory, + // with best-effort prevention against RCE attacks by validating the class name and loading + // behavior. transportFactory = newInstance(transportFactoryClassName); } diff --git a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 5b3df6fec..66192f328 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -911,6 +911,9 @@ public ImpersonatedCredentials build() { private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { input.defaultReadObject(); + // Use Oauth2Credential's newInstance() to try to safely instantiate the transport factory, + // with best-effort prevention against RCE attacks by validating the class name and loading + // behavior. transportFactory = newInstance(transportFactoryClassName); } } diff --git a/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java b/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java index dfeb5966a..e4cfa945c 100644 --- a/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/OAuth2Credentials.java @@ -35,6 +35,7 @@ import com.google.auth.Credentials; import com.google.auth.RequestMetadataCallback; import com.google.auth.http.AuthHttpConstants; +import com.google.auth.http.HttpTransportFactory; import com.google.common.annotations.VisibleForTesting; import com.google.common.base.MoreObjects; import com.google.common.base.Preconditions; @@ -51,6 +52,8 @@ import java.io.IOException; import java.io.ObjectInputStream; import java.io.Serializable; +import java.lang.reflect.Constructor; +import java.lang.reflect.InvocationTargetException; import java.net.URI; import java.time.Duration; import java.util.ArrayList; @@ -475,11 +478,60 @@ private void readObject(ObjectInputStream input) throws IOException, ClassNotFou refreshTask = null; } - @SuppressWarnings("unchecked") - protected static T newInstance(String className) throws IOException, ClassNotFoundException { + /** + * Best-effort safe mechanism to attempt to instantiate an {@link HttpTransportFactory} from a + * class name. + * + *

This method attempts to avoid Arbitrary Code Execution (ACE) vulnerabilities by: + * + *

    + *
  1. Checking if the class name matches the default or ServiceLoader-provided factory, and + * returning that instance if so. + *
  2. If not, loading the class using reflection without running static initializers. + *
  3. Verifying that the loaded class is assignable to {@link HttpTransportFactory}. + *
  4. Only after verification, instantiating the class using its default constructor. + *
+ * + * @param className The fully qualified name of the class to instantiate. + * @return An instance of {@link HttpTransportFactory}. + * @throws IOException If the class cannot be loaded, is the wrong type, or cannot be + * instantiated. + * @throws ClassNotFoundException If the class cannot be found. + */ + protected static HttpTransportFactory newInstance(String className) + throws IOException, ClassNotFoundException { + // Check if the requested class matches the default or ServiceLoader-provided + // factory. This avoids unsafe reflection for the most common use cases. + // This check runs first to replicate the logic in Credential constructor. + HttpTransportFactory currentFactory = + getFromServiceLoader(HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY); + // It is possible that there is a custom implementation of HttpTransportFactory + if (className.equals(currentFactory.getClass().getName())) { + return currentFactory; + } + + // Fallback to reflection if the requested class differs from the ServiceLoader + // default. This handles cases where a custom factory was used during + // serialization but is not the currently active ServiceLoader provider. try { - return (T) Class.forName(className).newInstance(); - } catch (InstantiationException | IllegalAccessException e) { + // Load the class without initializing it (second argument: false) to prevent + // static initializers from running, which could potentially be used for + // malicious purposes. Use the class loader of HttpTransportFactory (third argument) + // to ensure the class is loaded from the same context as the library, preventing + // class loading manipulation. + Class clazz = Class.forName(className, false, HttpTransportFactory.class.getClassLoader()); + if (!HttpTransportFactory.class.isAssignableFrom(clazz)) { + throw new IOException( + String.format( + "The class, %s, is not assignable from %s.", + className, HttpTransportFactory.class.getName())); + } + Constructor constructor = clazz.getDeclaredConstructor(); + return (HttpTransportFactory) constructor.newInstance(); + } catch (InstantiationException + | IllegalAccessException + | NoSuchMethodException + | InvocationTargetException e) { throw new IOException(e); } } diff --git a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java index 15700dd7f..db846e780 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ServiceAccountCredentials.java @@ -1126,6 +1126,9 @@ private Map> getRequestMetadataWithSelfSignedJwt(URI uri) private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { // properly deserialize the transient transportFactory input.defaultReadObject(); + // Use Oauth2Credential's newInstance() to try to safely instantiate the transport factory, + // with best-effort prevention against RCE attacks by validating the class name and loading + // behavior. transportFactory = newInstance(transportFactoryClassName); } diff --git a/oauth2_http/java/com/google/auth/oauth2/UserCredentials.java b/oauth2_http/java/com/google/auth/oauth2/UserCredentials.java index fa4399765..303cf6c32 100644 --- a/oauth2_http/java/com/google/auth/oauth2/UserCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/UserCredentials.java @@ -389,6 +389,9 @@ public boolean equals(Object obj) { private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { input.defaultReadObject(); + // Use Oauth2Credential's newInstance() to try to safely instantiate the transport factory, + // with best-effort prevention against RCE attacks by validating the class name and loading + // behavior. transportFactory = newInstance(transportFactoryClassName); } diff --git a/oauth2_http/javatests/com/google/auth/oauth2/DeserializationSecurityTest.java b/oauth2_http/javatests/com/google/auth/oauth2/DeserializationSecurityTest.java new file mode 100644 index 000000000..633d1e5a8 --- /dev/null +++ b/oauth2_http/javatests/com/google/auth/oauth2/DeserializationSecurityTest.java @@ -0,0 +1,317 @@ +/* + * Copyright 2026, Google Inc. All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are + * met: + * + * * Redistributions of source code must retain the above copyright + * notice, this list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above + * copyright notice, this list of conditions and the following disclaimer + * in the documentation and/or other materials provided with the + * distribution. + * + * * Neither the name of Google Inc. nor the names of its + * contributors may be used to endorse or promote products derived from + * this software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS + * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT + * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR + * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT + * OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, + * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT + * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, + * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY + * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE + * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ + +package com.google.auth.oauth2; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import com.google.api.client.http.HttpTransport; +import com.google.api.client.http.javanet.NetHttpTransport; +import com.google.auth.http.HttpTransportFactory; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.io.ObjectInputStream; +import java.io.ObjectOutputStream; +import java.lang.reflect.Field; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.concurrent.Callable; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class DeserializationSecurityTest { + + /** A class that does not implement HttpTransportFactory. */ + static class ArbitraryClass {} + + /** A custom implementation of HttpTransportFactory that should be allowed. */ + static class CustomTransportFactory implements HttpTransportFactory { + @Override + public HttpTransport create() { + return new NetHttpTransport(); + } + } + + /** + * An implementation of HttpTransportFactory that can registered to the ServiceLoader. Used in + * {@link #runWithTempServiceLoader} to load to `META_INF/services/*`. + */ + public static class TestServiceLoaderFactory implements HttpTransportFactory { + @Override + public HttpTransport create() { + return new NetHttpTransport(); + } + } + + private ServiceAccountCredentials createCredentials() throws Exception { + String json = + "{" + + "\"type\": \"service_account\"," + + "\"project_id\": \"project-id\"," + + "\"private_key_id\": \"private-key-id\"," + + "\"private_key\": \"" + + getSAPrivateKey() + + "\"," + + "\"client_email\": \"client-email\"," + + "\"client_id\": \"client-id\"," + + "\"auth_uri\": \"https://accounts.google.com/o/oauth2/auth\"," + + "\"token_uri\": \"https://accounts.google.com/o/oauth2/token\"," + + "\"auth_provider_x509_cert_url\": \"https://www.googleapis.com/oauth2/v1/certs\"," + + "\"client_x509_cert_url\": \"https://www.googleapis.com/robot/v1/metadata/x509/client-email\"" + + "}"; + return ServiceAccountCredentials.fromStream(new ByteArrayInputStream(json.getBytes())); + } + + private static String getSAPrivateKey() { + return ServiceAccountCredentialsTest.PRIVATE_KEY_PKCS8.replace("\n", "\\n"); + } + + /** + * Helper method to run a task within a temporary ServiceLoader environment. + * + *

This method prevents test pollution by isolating the ServiceLoader configuration. It creates + * a temporary directory structure for META-INF/services, writes the service provider + * configuration file, and creates a URLClassLoader that includes this temporary directory. It + * then sets the current thread's context class loader (TCCL) to this custom class loader before + * executing the task. This ensures that ServiceLoader.load() calls within the task will find the + * specific service provider defined for the test, without affecting other tests or the global + * environment. + * + * @param task The task to execute within the isolated environment. + * @param tempDir The temporary directory to use for ServiceLoader configuration. + * @throws Exception If any error occurs during setup, execution, or cleanup. + */ + private void runWithTempServiceLoader(Callable task, Path tempDir) throws Exception { + ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader(); + try { + Path servicesDir = tempDir.resolve("META-INF").resolve("services"); + Files.createDirectories(servicesDir); + Path serviceFile = servicesDir.resolve(HttpTransportFactory.class.getName()); + Files.write( + serviceFile, TestServiceLoaderFactory.class.getName().getBytes(StandardCharsets.UTF_8)); + + URL[] urls = new URL[] {tempDir.toUri().toURL()}; + try (URLClassLoader testClassLoader = new URLClassLoader(urls, originalClassLoader)) { + Thread.currentThread().setContextClassLoader(testClassLoader); + task.call(); + } + } finally { + Thread.currentThread().setContextClassLoader(originalClassLoader); + } + } + + @Test + void testArbitraryClassInstantiationPrevented() throws Exception { + // 1. Create a valid ServiceAccountCredentials + ServiceAccountCredentials credentials = createCredentials(); + + // 2. Use reflection to set transportFactoryClassName to our arbitrary class. + // We expect verification failure because ArbitraryClass does not implement + // HttpTransportFactory. We use reflection here to verify that we cannot + // deserialize into an arbitrary class, simulating a malicious stream which + // could not be created via the public Builder API. + Field transportFactoryClassNameField = + ServiceAccountCredentials.class.getDeclaredField("transportFactoryClassName"); + transportFactoryClassNameField.setAccessible(true); + transportFactoryClassNameField.set(credentials, ArbitraryClass.class.getName()); + + // 3. Serialize + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(credentials); + oos.close(); + + // 4. Deserialize + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bis); + + // 5. Assert that an exception is thrown because ArbitraryClass is not a valid + // HttpTransportFactory + assertThrows(IOException.class, ois::readObject); + } + + @Test + void testValidTransportFactoryDeserialization() throws Exception { + // 1. Create a valid ServiceAccountCredentials + ServiceAccountCredentials credentials = createCredentials(); + + // 2. Use the builder to set the transport factory. + // This will set the transportFactoryClassName field used during serialization. + credentials = + credentials.toBuilder() + .setHttpTransportFactory(new OAuth2Utils.DefaultHttpTransportFactory()) + .build(); + + // 3. Serialize + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(credentials); + oos.close(); + + // 4. Deserialize + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bis); + + ServiceAccountCredentials deserialized = (ServiceAccountCredentials) ois.readObject(); + + // 5. Assert that it deserialized correctly and has the correct factory type + assertNotNull(deserialized); + + // Use reflection to get the field as there is no getter for transportFactory. + Field transportFactoryField = + ServiceAccountCredentials.class.getDeclaredField("transportFactory"); + transportFactoryField.setAccessible(true); + Object factory = transportFactoryField.get(deserialized); + + assertEquals( + OAuth2Utils.DefaultHttpTransportFactory.class.getName(), factory.getClass().getName()); + } + + @Test + void testNonExistentClassDeserialization() throws Exception { + // 1. Create a valid ServiceAccountCredentials + ServiceAccountCredentials credentials = createCredentials(); + + // 2. Use reflection to set transportFactoryClassName to a non-existent class. + // We use reflection here to verify that we cannot deserialize into a + // non-existent class, simulating a malicious stream which could not be created + // via the public Builder API. + Field transportFactoryClassNameField = + ServiceAccountCredentials.class.getDeclaredField("transportFactoryClassName"); + transportFactoryClassNameField.setAccessible(true); + transportFactoryClassNameField.set(credentials, "com.malicious.NonExistentClass"); + + // 3. Serialize + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(credentials); + oos.close(); + + // 4. Deserialize + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bis); + + // 5. Assert that ClassNotFoundException is thrown + assertThrows(ClassNotFoundException.class, ois::readObject); + } + + @Test + void testCustomTransportFactory() throws Exception { + // 1. Create a valid ServiceAccountCredentials + ServiceAccountCredentials credentials = createCredentials(); + + // 2. Use the builder to set our custom transport factory. + // This will set the transportFactoryClassName field used during serialization. + credentials = + credentials.toBuilder().setHttpTransportFactory(new CustomTransportFactory()).build(); + + // 3. Serialize + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(credentials); + oos.close(); + + // 4. Deserialize + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bis); + + ServiceAccountCredentials deserialized = (ServiceAccountCredentials) ois.readObject(); + + // 5. Assert that it IS instantiated (by verifying the factory type) + assertNotNull(deserialized); + + // Use reflection to get the field as there is no getter for transportFactory. + Field transportFactoryField = + ServiceAccountCredentials.class.getDeclaredField("transportFactory"); + transportFactoryField.setAccessible(true); + Object factory = transportFactoryField.get(deserialized); + + assertEquals(CustomTransportFactory.class.getName(), factory.getClass().getName()); + } + + @Test + void testServiceLoaderPathDeserialization(@TempDir Path tempDir) throws Exception { + runWithTempServiceLoader( + () -> { + // 1. Create a ServiceAccountCredentials using the builder WITHOUT setting the + // transport factory. The constructor should automatically look up the factory + // via ServiceLoader. + ServiceAccountCredentials credentials = + ServiceAccountCredentials.newBuilder() + .setClientEmail("client-email") + .setPrivateKeyId("private-key-id") + .setPrivateKeyString(ServiceAccountCredentialsTest.PRIVATE_KEY_PKCS8) + .setProjectId("project-id") + .build(); + + // 2. Verify that the credentials were created with the + // TestServiceLoaderFactory. + // This confirms that the ServiceLoader mechanism in the constructor is working. + Field transportFactoryClassNameField = + ServiceAccountCredentials.class.getDeclaredField("transportFactoryClassName"); + transportFactoryClassNameField.setAccessible(true); + assertEquals( + TestServiceLoaderFactory.class.getName(), + transportFactoryClassNameField.get(credentials)); + + // 3. Serialize + ByteArrayOutputStream bos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(bos); + oos.writeObject(credentials); + oos.close(); + + // 4. Deserialize + ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray()); + ObjectInputStream ois = new ObjectInputStream(bis); + + ServiceAccountCredentials deserialized = (ServiceAccountCredentials) ois.readObject(); + + // 5. Assert that it deserialized correctly and has the correct factory type. + // This confirms that newInstance() found it via the ServiceLoader path. + assertNotNull(deserialized); + + Field transportFactoryField = + ServiceAccountCredentials.class.getDeclaredField("transportFactory"); + transportFactoryField.setAccessible(true); + Object factory = transportFactoryField.get(deserialized); + + assertEquals(TestServiceLoaderFactory.class.getName(), factory.getClass().getName()); + return null; + }, + tempDir); + } +}