diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CredentialAccessBoundary.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CredentialAccessBoundary.java index 324d53d0b782..fec03a918df7 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CredentialAccessBoundary.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CredentialAccessBoundary.java @@ -36,6 +36,7 @@ import com.google.api.client.json.GenericJson; import com.google.errorprone.annotations.CanIgnoreReturnValue; +import java.io.Serializable; import java.util.ArrayList; import java.util.List; import org.jspecify.annotations.NullMarked; @@ -49,7 +50,9 @@ * information. */ @NullMarked -public final class CredentialAccessBoundary { +public final class CredentialAccessBoundary implements Serializable { + + private static final long serialVersionUID = 1L; private static final int RULES_SIZE_LIMIT = 10; @@ -63,7 +66,7 @@ public final class CredentialAccessBoundary { accessBoundaryRules.size() < RULES_SIZE_LIMIT, String.format( "The provided list has more than %s access boundary rules.", RULES_SIZE_LIMIT)); - this.accessBoundaryRules = accessBoundaryRules; + this.accessBoundaryRules = new ArrayList<>(accessBoundaryRules); } /** @@ -161,7 +164,9 @@ public CredentialAccessBoundary build() { * .build(); * */ - public static final class AccessBoundaryRule { + public static final class AccessBoundaryRule implements Serializable { + + private static final long serialVersionUID = 1L; private final String availableResource; private final List availablePermissions; @@ -293,7 +298,10 @@ public AccessBoundaryRule build() { * .build(); * */ - public static final class AvailabilityCondition { + public static final class AvailabilityCondition implements Serializable { + + private static final long serialVersionUID = 1L; + private final String expression; @Nullable private final String title; diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/DownscopedCredentials.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/DownscopedCredentials.java index d681d1f1bc69..cac377eb787d 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/DownscopedCredentials.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/DownscopedCredentials.java @@ -40,6 +40,7 @@ import com.google.common.annotations.VisibleForTesting; import com.google.errorprone.annotations.CanIgnoreReturnValue; import java.io.IOException; +import java.io.ObjectInputStream; import org.jspecify.annotations.NullMarked; import org.jspecify.annotations.Nullable; @@ -99,11 +100,14 @@ @NullMarked public final class DownscopedCredentials extends OAuth2Credentials { + private static final long serialVersionUID = 3159528426501345486L; + private final GoogleCredentials sourceCredential; private final CredentialAccessBoundary credentialAccessBoundary; private final String universeDomain; + private final String transportFactoryClassName; - private final transient HttpTransportFactory transportFactory; + private transient HttpTransportFactory transportFactory; private final String tokenExchangeEndpoint; @@ -113,6 +117,7 @@ private DownscopedCredentials(Builder builder) { firstNonNull( builder.transportFactory, getFromServiceLoader(HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY)); + this.transportFactoryClassName = this.transportFactory.getClass().getName(); this.sourceCredential = checkNotNull(builder.sourceCredential); this.credentialAccessBoundary = checkNotNull(builder.credentialAccessBoundary); @@ -199,6 +204,11 @@ HttpTransportFactory getTransportFactory() { return transportFactory; } + private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { + input.defaultReadObject(); + transportFactory = OAuth2Credentials.newInstance(transportFactoryClassName); + } + public static Builder newBuilder() { return new Builder(); } diff --git a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java index 9a95701371b3..391e6dccc33a 100644 --- a/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java +++ b/google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java @@ -39,8 +39,11 @@ import com.google.api.client.http.HttpResponse; import com.google.api.client.json.JsonObjectParser; import com.google.auth.http.HttpTransportFactory; +import com.google.common.annotations.VisibleForTesting; import java.io.IOException; +import java.io.ObjectInputStream; import org.jspecify.annotations.NullMarked; +import org.jspecify.annotations.Nullable; /** * Provider for retrieving the subject tokens for {@link IdentityPoolCredentials} to exchange for @@ -55,7 +58,8 @@ class UrlIdentityPoolSubjectTokenSupplier implements IdentityPoolSubjectTokenSup private static final long serialVersionUID = 4964578313468011844L; private final IdentityPoolCredentialSource credentialSource; - private final transient HttpTransportFactory transportFactory; + private final String transportFactoryClassName; + private transient HttpTransportFactory transportFactory; /** * Constructor for UrlIdentityPoolSubjectTokenProvider. @@ -64,9 +68,12 @@ class UrlIdentityPoolSubjectTokenSupplier implements IdentityPoolSubjectTokenSup * @param transportFactory the transport factory to use for calling the URL. */ UrlIdentityPoolSubjectTokenSupplier( - IdentityPoolCredentialSource credentialSource, HttpTransportFactory transportFactory) { + IdentityPoolCredentialSource credentialSource, + @Nullable HttpTransportFactory transportFactory) { this.credentialSource = credentialSource; - this.transportFactory = transportFactory; + this.transportFactory = + transportFactory != null ? transportFactory : OAuth2Utils.HTTP_TRANSPORT_FACTORY; + this.transportFactoryClassName = this.transportFactory.getClass().getName(); } @Override @@ -100,4 +107,14 @@ public String getSubjectToken(ExternalAccountSupplierContext context) throws IOE String.format("Error getting subject token from metadata server: %s", e.getMessage()), e); } } + + private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException { + input.defaultReadObject(); + transportFactory = OAuth2Credentials.newInstance(transportFactoryClassName); + } + + @VisibleForTesting + HttpTransportFactory getTransportFactory() { + return transportFactory; + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/CredentialAccessBoundaryTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/CredentialAccessBoundaryTest.java index 04abd3d9d23c..4c88727bdcca 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/CredentialAccessBoundaryTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/CredentialAccessBoundaryTest.java @@ -43,7 +43,7 @@ import org.junit.jupiter.api.Test; /** Tests for {@link CredentialAccessBoundary} and encompassing classes. */ -class CredentialAccessBoundaryTest { +class CredentialAccessBoundaryTest extends BaseSerializationTest { @Test void credentialAccessBoundary() { @@ -288,4 +288,29 @@ void availabilityCondition_emptyExpression_throws() { assertThrows(IllegalArgumentException.class, builder::build); assertEquals("The provided expression is empty.", exception.getMessage()); } + + @Test + void serializeAndDeserialize_success() throws Exception { + AvailabilityCondition availabilityCondition = + AvailabilityCondition.newBuilder() + .setExpression("expression") + .setTitle("title") + .setDescription("description") + .build(); + + AccessBoundaryRule rule = + AccessBoundaryRule.newBuilder() + .setAvailableResource("resource") + .addAvailablePermission("permission") + .setAvailabilityCondition(availabilityCondition) + .build(); + + CredentialAccessBoundary cab = CredentialAccessBoundary.newBuilder().addRule(rule).build(); + + // Verify CredentialAccessBoundary and nested rules/conditions can be serialized and + // deserialized + // so downscoped credentials containing them do not throw NotSerializableException. + CredentialAccessBoundary deserialized = serializeAndDeserialize(cab); + assertEquals(cab.toJson(), deserialized.toJson()); + } } diff --git a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DownscopedCredentialsTest.java b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DownscopedCredentialsTest.java index 34a52b4154fb..e2759a292439 100644 --- a/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DownscopedCredentialsTest.java +++ b/google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DownscopedCredentialsTest.java @@ -43,10 +43,11 @@ import java.io.IOException; import java.util.Date; import java.util.Map; +import java.util.concurrent.TimeUnit; import org.junit.jupiter.api.Test; /** Tests for {@link DownscopedCredentials}. */ -class DownscopedCredentialsTest { +class DownscopedCredentialsTest extends BaseSerializationTest { private static final String SA_PRIVATE_KEY_PKCS8 = "-----BEGIN PRIVATE KEY-----\n" @@ -81,6 +82,54 @@ public HttpTransport create() { } } + public static class StatefulMockStsTransportFactory implements HttpTransportFactory { + + private static MockStsTransport transport = new MockStsTransport(); + + public StatefulMockStsTransportFactory() {} + + @Override + public HttpTransport create() { + return transport; + } + } + + @Test + void refreshAccessToken_reserialized_success() throws Exception { + StatefulMockStsTransportFactory.transport = new MockStsTransport(); + StatefulMockStsTransportFactory transportFactory = new StatefulMockStsTransportFactory(); + + // Set token expiration to 1 hour (3_600_000 ms) in the future so that + // sourceCredential.refreshIfExpired() considers the source token valid and + // does not attempt an external network call to refresh the source credential. + long oneHourInMillis = TimeUnit.HOURS.toMillis(1); + AccessToken sourceAccessToken = + new AccessToken( + "sourceAccessToken", new Date(System.currentTimeMillis() + oneHourInMillis)); + GoogleCredentials sourceCredentials = + ((ServiceAccountCredentials) getServiceAccountSourceCredentials(/* canRefresh= */ true)) + .toBuilder().setAccessToken(sourceAccessToken).build(); + + DownscopedCredentials downscopedCredentials = + DownscopedCredentials.newBuilder() + .setSourceCredential(sourceCredentials) + .setCredentialAccessBoundary(CREDENTIAL_ACCESS_BOUNDARY) + .setHttpTransportFactory(transportFactory) + .build(); + + // Verify deserialization succeeds and reconstructs the transient HTTP transport factory + // so that subsequent token refreshes do not throw a NullPointerException. + DownscopedCredentials deserialized = serializeAndDeserialize(downscopedCredentials); + assertNotNull(deserialized.getTransportFactory()); + assertEquals(downscopedCredentials, deserialized); + assertEquals(downscopedCredentials.hashCode(), deserialized.hashCode()); + + // In-memory mock transport responds with a valid token exchange without network calls. + AccessToken accessToken = deserialized.refreshAccessToken(); + assertEquals( + StatefulMockStsTransportFactory.transport.getAccessToken(), accessToken.getTokenValue()); + } + @Test void refreshAccessToken() throws IOException { MockStsTransportFactory transportFactory = new MockStsTransportFactory(); 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 a1662cc10191..aa3e5242b1c5 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 @@ -1016,6 +1016,60 @@ void serialize() throws IOException, ClassNotFoundException { assertSame(Clock.SYSTEM, deserializedCredentials.clock); } + @Test + void serialize_urlSourced_refreshesSuccessfully() throws Exception { + StatefulMockExternalAccountCredentialsTransportFactory.transport = + new MockExternalAccountCredentialsTransport(); + StatefulMockExternalAccountCredentialsTransportFactory transportFactory = + new StatefulMockExternalAccountCredentialsTransportFactory(); + + IdentityPoolCredentials testCredentials = + IdentityPoolCredentials.newBuilder(createBaseFileSourcedCredentials()) + .setHttpTransportFactory(transportFactory) + .setCredentialSource( + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl())) + .build(); + + // Verify deserialization recreates the transient HTTP transport factory inside + // UrlIdentityPoolSubjectTokenSupplier so subject token retrieval and token refresh succeed. + IdentityPoolCredentials deserializedCredentials = serializeAndDeserialize(testCredentials); + assertEquals(testCredentials, deserializedCredentials); + + // In-memory mock transport serves both the metadata server subject token and the STS exchange + // response without network calls. + AccessToken accessToken = deserializedCredentials.refreshAccessToken(); + assertEquals("accessToken", accessToken.getTokenValue()); + } + + @Test + void serialize_urlIdentityPoolSubjectTokenSupplier_success() throws Exception { + StatefulMockExternalAccountCredentialsTransportFactory.transport = + new MockExternalAccountCredentialsTransport(); + StatefulMockExternalAccountCredentialsTransportFactory transportFactory = + new StatefulMockExternalAccountCredentialsTransportFactory(); + + IdentityPoolCredentialSource credentialSource = + buildUrlBasedCredentialSource(transportFactory.transport.getMetadataUrl()); + UrlIdentityPoolSubjectTokenSupplier supplier = + new UrlIdentityPoolSubjectTokenSupplier(credentialSource, transportFactory); + + // Verify deserialization recreates the transient HTTP transport factory so that subject token + // retrieval succeeds without throwing a NullPointerException. + UrlIdentityPoolSubjectTokenSupplier deserializedSupplier = serializeAndDeserialize(supplier); + assertNotNull(deserializedSupplier.getTransportFactory()); + + // In-memory mock transport returns the subject token without network calls. + ExternalAccountSupplierContext context = + ExternalAccountSupplierContext.newBuilder() + .setAudience("audience") + .setSubjectTokenType("subjectTokenType") + .build(); + String subjectToken = deserializedSupplier.getSubjectToken(context); + assertEquals( + StatefulMockExternalAccountCredentialsTransportFactory.transport.getSubjectToken(), + subjectToken); + } + @Test void build_withCertificateSource_succeeds() throws Exception { // Set up credential source for certificate type. @@ -1276,6 +1330,20 @@ public HttpTransport create() { } } + public static class StatefulMockExternalAccountCredentialsTransportFactory + implements HttpTransportFactory { + + private static MockExternalAccountCredentialsTransport transport = + new MockExternalAccountCredentialsTransport(); + + public StatefulMockExternalAccountCredentialsTransportFactory() {} + + @Override + public HttpTransport create() { + return transport; + } + } + private static class TestX509Provider extends X509Provider { private final KeyStore keyStore; private final String certificatePath;