Skip to content

Commit a3e3324

Browse files
authored
fix(auth): fix serialization gaps in DownscopedCredentials and UrlIdentityPoolSubjectTokenSupplier (#14341)
Fixes serialization issues tracked in #12580 ### Problem 1. **`DownscopedCredentials`**: - Held a `private final transient HttpTransportFactory transportFactory;` without retaining its class name or defining a `readObject` method. Upon deserialization, `transportFactory` was null, throwing a `NullPointerException` on `transportFactory.create()` during token refresh. - Contained a non-serializable `CredentialAccessBoundary` field, preventing instances of `DownscopedCredentials` from being serialized at all (`NotSerializableException`). 2. **`UrlIdentityPoolSubjectTokenSupplier`**: - Held a `private final transient HttpTransportFactory transportFactory;` without retaining its class name or defining `readObject`. When deserialized (either directly or as part of an `IdentityPoolCredentials` configured with a URL source), `transportFactory` was null, throwing a `NullPointerException` on `transportFactory.create()` when retrieving the subject token. ### Solution - Implemented `readObject()` in both `DownscopedCredentials` and `UrlIdentityPoolSubjectTokenSupplier` to reconstruct `transportFactory` via `newInstance(transportFactoryClassName)` / `OAuth2Credentials.newInstance(transportFactoryClassName)`. - Preserved backward compatibility by explicitly setting `serialVersionUID` for `DownscopedCredentials` to its previously computed default UID (`3159528426501345486L`). - Made `CredentialAccessBoundary` and its inner classes (`AccessBoundaryRule`, `AvailabilityCondition`) implement `Serializable`. - Added serialization unit tests in `DownscopedCredentialsTest`, `IdentityPoolCredentialsTest`, and `CredentialAccessBoundaryTest` verifying deserialization and token refresh using in-memory mock transports.
1 parent 50da24e commit a3e3324

6 files changed

Lines changed: 273 additions & 10 deletions

File tree

‎google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/CredentialAccessBoundary.java‎

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -36,8 +36,10 @@
3636

3737
import com.google.api.client.json.GenericJson;
3838
import com.google.errorprone.annotations.CanIgnoreReturnValue;
39+
import java.io.Serializable;
3940
import java.util.ArrayList;
4041
import java.util.List;
42+
import java.util.Objects;
4143
import org.jspecify.annotations.NullMarked;
4244
import org.jspecify.annotations.Nullable;
4345

@@ -49,7 +51,9 @@
4951
* information.</a>
5052
*/
5153
@NullMarked
52-
public final class CredentialAccessBoundary {
54+
public final class CredentialAccessBoundary implements Serializable {
55+
56+
private static final long serialVersionUID = 1L;
5357

5458
private static final int RULES_SIZE_LIMIT = 10;
5559

@@ -63,7 +67,7 @@ public final class CredentialAccessBoundary {
6367
accessBoundaryRules.size() < RULES_SIZE_LIMIT,
6468
String.format(
6569
"The provided list has more than %s access boundary rules.", RULES_SIZE_LIMIT));
66-
this.accessBoundaryRules = accessBoundaryRules;
70+
this.accessBoundaryRules = new ArrayList<>(accessBoundaryRules);
6771
}
6872

6973
/**
@@ -110,6 +114,20 @@ public List<AccessBoundaryRule> getAccessBoundaryRules() {
110114
return accessBoundaryRules;
111115
}
112116

117+
@Override
118+
public boolean equals(@Nullable Object obj) {
119+
if (!(obj instanceof CredentialAccessBoundary)) {
120+
return false;
121+
}
122+
CredentialAccessBoundary other = (CredentialAccessBoundary) obj;
123+
return Objects.equals(this.accessBoundaryRules, other.accessBoundaryRules);
124+
}
125+
126+
@Override
127+
public int hashCode() {
128+
return Objects.hashCode(accessBoundaryRules);
129+
}
130+
113131
public static Builder newBuilder() {
114132
return new Builder();
115133
}
@@ -161,7 +179,9 @@ public CredentialAccessBoundary build() {
161179
* .build();
162180
* </code></pre>
163181
*/
164-
public static final class AccessBoundaryRule {
182+
public static final class AccessBoundaryRule implements Serializable {
183+
184+
private static final long serialVersionUID = 1L;
165185

166186
private final String availableResource;
167187
private final List<String> availablePermissions;
@@ -202,6 +222,22 @@ public AvailabilityCondition getAvailabilityCondition() {
202222
return availabilityCondition;
203223
}
204224

225+
@Override
226+
public boolean equals(@Nullable Object obj) {
227+
if (!(obj instanceof AccessBoundaryRule)) {
228+
return false;
229+
}
230+
AccessBoundaryRule other = (AccessBoundaryRule) obj;
231+
return Objects.equals(this.availableResource, other.availableResource)
232+
&& Objects.equals(this.availablePermissions, other.availablePermissions)
233+
&& Objects.equals(this.availabilityCondition, other.availabilityCondition);
234+
}
235+
236+
@Override
237+
public int hashCode() {
238+
return Objects.hash(availableResource, availablePermissions, availabilityCondition);
239+
}
240+
205241
public static Builder newBuilder() {
206242
return new Builder();
207243
}
@@ -293,7 +329,10 @@ public AccessBoundaryRule build() {
293329
* .build();
294330
* </code></pre>
295331
*/
296-
public static final class AvailabilityCondition {
332+
public static final class AvailabilityCondition implements Serializable {
333+
334+
private static final long serialVersionUID = 1L;
335+
297336
private final String expression;
298337

299338
@Nullable private final String title;
@@ -322,6 +361,22 @@ public String getDescription() {
322361
return description;
323362
}
324363

364+
@Override
365+
public boolean equals(@Nullable Object obj) {
366+
if (!(obj instanceof AvailabilityCondition)) {
367+
return false;
368+
}
369+
AvailabilityCondition other = (AvailabilityCondition) obj;
370+
return Objects.equals(this.expression, other.expression)
371+
&& Objects.equals(this.title, other.title)
372+
&& Objects.equals(this.description, other.description);
373+
}
374+
375+
@Override
376+
public int hashCode() {
377+
return Objects.hash(expression, title, description);
378+
}
379+
325380
public static Builder newBuilder() {
326381
return new Builder();
327382
}

‎google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/DownscopedCredentials.java‎

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,8 @@
4040
import com.google.common.annotations.VisibleForTesting;
4141
import com.google.errorprone.annotations.CanIgnoreReturnValue;
4242
import java.io.IOException;
43+
import java.io.ObjectInputStream;
44+
import java.util.Objects;
4345
import org.jspecify.annotations.NullMarked;
4446
import org.jspecify.annotations.Nullable;
4547

@@ -99,11 +101,14 @@
99101
@NullMarked
100102
public final class DownscopedCredentials extends OAuth2Credentials {
101103

104+
private static final long serialVersionUID = 3159528426501345486L;
105+
102106
private final GoogleCredentials sourceCredential;
103107
private final CredentialAccessBoundary credentialAccessBoundary;
104108
private final String universeDomain;
109+
private final String transportFactoryClassName;
105110

106-
private final transient HttpTransportFactory transportFactory;
111+
private transient HttpTransportFactory transportFactory;
107112

108113
private final String tokenExchangeEndpoint;
109114

@@ -113,6 +118,7 @@ private DownscopedCredentials(Builder builder) {
113118
firstNonNull(
114119
builder.transportFactory,
115120
getFromServiceLoader(HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY));
121+
this.transportFactoryClassName = this.transportFactory.getClass().getName();
116122
this.sourceCredential = checkNotNull(builder.sourceCredential);
117123
this.credentialAccessBoundary = checkNotNull(builder.credentialAccessBoundary);
118124

@@ -199,6 +205,36 @@ HttpTransportFactory getTransportFactory() {
199205
return transportFactory;
200206
}
201207

208+
@Override
209+
public boolean equals(@Nullable Object obj) {
210+
if (!(obj instanceof DownscopedCredentials)) {
211+
return false;
212+
}
213+
DownscopedCredentials other = (DownscopedCredentials) obj;
214+
return super.equals(other)
215+
&& Objects.equals(this.sourceCredential, other.sourceCredential)
216+
&& Objects.equals(this.credentialAccessBoundary, other.credentialAccessBoundary)
217+
&& Objects.equals(this.universeDomain, other.universeDomain)
218+
&& Objects.equals(this.transportFactoryClassName, other.transportFactoryClassName)
219+
&& Objects.equals(this.tokenExchangeEndpoint, other.tokenExchangeEndpoint);
220+
}
221+
222+
@Override
223+
public int hashCode() {
224+
return Objects.hash(
225+
super.hashCode(),
226+
sourceCredential,
227+
credentialAccessBoundary,
228+
universeDomain,
229+
transportFactoryClassName,
230+
tokenExchangeEndpoint);
231+
}
232+
233+
private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException {
234+
input.defaultReadObject();
235+
transportFactory = OAuth2Credentials.newInstance(transportFactoryClassName);
236+
}
237+
202238
public static Builder newBuilder() {
203239
return new Builder();
204240
}

‎google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/UrlIdentityPoolSubjectTokenSupplier.java‎

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,15 +32,19 @@
3232
package com.google.auth.oauth2;
3333

3434
import static com.google.auth.oauth2.FileIdentityPoolSubjectTokenSupplier.parseToken;
35+
import static com.google.common.base.MoreObjects.firstNonNull;
3536

3637
import com.google.api.client.http.GenericUrl;
3738
import com.google.api.client.http.HttpHeaders;
3839
import com.google.api.client.http.HttpRequest;
3940
import com.google.api.client.http.HttpResponse;
4041
import com.google.api.client.json.JsonObjectParser;
4142
import com.google.auth.http.HttpTransportFactory;
43+
import com.google.common.annotations.VisibleForTesting;
4244
import java.io.IOException;
45+
import java.io.ObjectInputStream;
4346
import org.jspecify.annotations.NullMarked;
47+
import org.jspecify.annotations.Nullable;
4448

4549
/**
4650
* Provider for retrieving the subject tokens for {@link IdentityPoolCredentials} to exchange for
@@ -55,7 +59,8 @@ class UrlIdentityPoolSubjectTokenSupplier implements IdentityPoolSubjectTokenSup
5559
private static final long serialVersionUID = 4964578313468011844L;
5660

5761
private final IdentityPoolCredentialSource credentialSource;
58-
private final transient HttpTransportFactory transportFactory;
62+
private final String transportFactoryClassName;
63+
private transient HttpTransportFactory transportFactory;
5964

6065
/**
6166
* Constructor for UrlIdentityPoolSubjectTokenProvider.
@@ -64,9 +69,15 @@ class UrlIdentityPoolSubjectTokenSupplier implements IdentityPoolSubjectTokenSup
6469
* @param transportFactory the transport factory to use for calling the URL.
6570
*/
6671
UrlIdentityPoolSubjectTokenSupplier(
67-
IdentityPoolCredentialSource credentialSource, HttpTransportFactory transportFactory) {
72+
IdentityPoolCredentialSource credentialSource,
73+
@Nullable HttpTransportFactory transportFactory) {
6874
this.credentialSource = credentialSource;
69-
this.transportFactory = transportFactory;
75+
this.transportFactory =
76+
firstNonNull(
77+
transportFactory,
78+
OAuth2Credentials.getFromServiceLoader(
79+
HttpTransportFactory.class, OAuth2Utils.HTTP_TRANSPORT_FACTORY));
80+
this.transportFactoryClassName = this.transportFactory.getClass().getName();
7081
}
7182

7283
@Override
@@ -100,4 +111,14 @@ public String getSubjectToken(ExternalAccountSupplierContext context) throws IOE
100111
String.format("Error getting subject token from metadata server: %s", e.getMessage()), e);
101112
}
102113
}
114+
115+
private void readObject(ObjectInputStream input) throws IOException, ClassNotFoundException {
116+
input.defaultReadObject();
117+
transportFactory = OAuth2Credentials.newInstance(transportFactoryClassName);
118+
}
119+
120+
@VisibleForTesting
121+
HttpTransportFactory getTransportFactory() {
122+
return transportFactory;
123+
}
103124
}

‎google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/CredentialAccessBoundaryTest.java‎

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
import org.junit.jupiter.api.Test;
4444

4545
/** Tests for {@link CredentialAccessBoundary} and encompassing classes. */
46-
class CredentialAccessBoundaryTest {
46+
class CredentialAccessBoundaryTest extends BaseSerializationTest {
4747

4848
@Test
4949
void credentialAccessBoundary() {
@@ -288,4 +288,31 @@ void availabilityCondition_emptyExpression_throws() {
288288
assertThrows(IllegalArgumentException.class, builder::build);
289289
assertEquals("The provided expression is empty.", exception.getMessage());
290290
}
291+
292+
@Test
293+
void serializeAndDeserialize_success() throws Exception {
294+
AvailabilityCondition availabilityCondition =
295+
AvailabilityCondition.newBuilder()
296+
.setExpression("expression")
297+
.setTitle("title")
298+
.setDescription("description")
299+
.build();
300+
301+
AccessBoundaryRule rule =
302+
AccessBoundaryRule.newBuilder()
303+
.setAvailableResource("resource")
304+
.addAvailablePermission("permission")
305+
.setAvailabilityCondition(availabilityCondition)
306+
.build();
307+
308+
CredentialAccessBoundary cab = CredentialAccessBoundary.newBuilder().addRule(rule).build();
309+
310+
// Verify CredentialAccessBoundary and nested rules/conditions can be serialized and
311+
// deserialized
312+
// so downscoped credentials containing them do not throw NotSerializableException.
313+
CredentialAccessBoundary deserialized = serializeAndDeserialize(cab);
314+
assertEquals(cab, deserialized);
315+
assertEquals(cab.hashCode(), deserialized.hashCode());
316+
assertEquals(cab.toJson(), deserialized.toJson());
317+
}
291318
}

‎google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/DownscopedCredentialsTest.java‎

Lines changed: 50 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,10 +43,11 @@
4343
import java.io.IOException;
4444
import java.util.Date;
4545
import java.util.Map;
46+
import java.util.concurrent.TimeUnit;
4647
import org.junit.jupiter.api.Test;
4748

4849
/** Tests for {@link DownscopedCredentials}. */
49-
class DownscopedCredentialsTest {
50+
class DownscopedCredentialsTest extends BaseSerializationTest {
5051

5152
private static final String SA_PRIVATE_KEY_PKCS8 =
5253
"-----BEGIN PRIVATE KEY-----\n"
@@ -81,6 +82,54 @@ public HttpTransport create() {
8182
}
8283
}
8384

85+
public static class StatefulMockStsTransportFactory implements HttpTransportFactory {
86+
87+
private static MockStsTransport transport = new MockStsTransport();
88+
89+
public StatefulMockStsTransportFactory() {}
90+
91+
@Override
92+
public HttpTransport create() {
93+
return transport;
94+
}
95+
}
96+
97+
@Test
98+
void refreshAccessToken_reserialized_success() throws Exception {
99+
StatefulMockStsTransportFactory.transport = new MockStsTransport();
100+
StatefulMockStsTransportFactory transportFactory = new StatefulMockStsTransportFactory();
101+
102+
// Set token expiration to 1 hour (3_600_000 ms) in the future so that
103+
// sourceCredential.refreshIfExpired() considers the source token valid and
104+
// does not attempt an external network call to refresh the source credential.
105+
long oneHourInMillis = TimeUnit.HOURS.toMillis(1);
106+
AccessToken sourceAccessToken =
107+
new AccessToken(
108+
"sourceAccessToken", new Date(System.currentTimeMillis() + oneHourInMillis));
109+
GoogleCredentials sourceCredentials =
110+
((ServiceAccountCredentials) getServiceAccountSourceCredentials(/* canRefresh= */ true))
111+
.toBuilder().setAccessToken(sourceAccessToken).build();
112+
113+
DownscopedCredentials downscopedCredentials =
114+
DownscopedCredentials.newBuilder()
115+
.setSourceCredential(sourceCredentials)
116+
.setCredentialAccessBoundary(CREDENTIAL_ACCESS_BOUNDARY)
117+
.setHttpTransportFactory(transportFactory)
118+
.build();
119+
120+
// Verify deserialization succeeds and reconstructs the transient HTTP transport factory
121+
// so that subsequent token refreshes do not throw a NullPointerException.
122+
DownscopedCredentials deserialized = serializeAndDeserialize(downscopedCredentials);
123+
assertNotNull(deserialized.getTransportFactory());
124+
assertEquals(downscopedCredentials, deserialized);
125+
assertEquals(downscopedCredentials.hashCode(), deserialized.hashCode());
126+
127+
// In-memory mock transport responds with a valid token exchange without network calls.
128+
AccessToken accessToken = deserialized.refreshAccessToken();
129+
assertEquals(
130+
StatefulMockStsTransportFactory.transport.getAccessToken(), accessToken.getTokenValue());
131+
}
132+
84133
@Test
85134
void refreshAccessToken() throws IOException {
86135
MockStsTransportFactory transportFactory = new MockStsTransportFactory();

0 commit comments

Comments
 (0)