Skip to content

Commit 0847925

Browse files
committed
fix: ImpersonatedCredentials does not use Calendar for expiration (#1908)
* chore: Add ObsoleteApi annotation * chore: Add tests for serializing ImpersonatedCredentials * chore: Restore old code * chore: Update error message * chore: Fix failing test issue * chore: Remove old tests referecing obsolete api functionality Original-PR: googleapis/google-auth-library-java#1908
1 parent 1994525 commit 0847925

File tree

2 files changed

+108
-83
lines changed

2 files changed

+108
-83
lines changed

google-auth-library-java/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java

Lines changed: 43 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@
4545
import com.google.api.client.json.JsonObjectParser;
4646
import com.google.api.client.util.GenericData;
4747
import com.google.api.core.InternalApi;
48+
import com.google.api.core.ObsoleteApi;
4849
import com.google.auth.CredentialTypeForMetrics;
4950
import com.google.auth.ServiceAccountSigner;
5051
import com.google.auth.http.HttpCredentialsAdapter;
@@ -59,9 +60,9 @@
5960
import java.io.IOException;
6061
import java.io.InputStream;
6162
import java.io.ObjectInputStream;
62-
import java.text.DateFormat;
63-
import java.text.ParseException;
64-
import java.text.SimpleDateFormat;
63+
import java.time.DateTimeException;
64+
import java.time.Instant;
65+
import java.time.format.DateTimeFormatter;
6566
import java.util.ArrayList;
6667
import java.util.Calendar;
6768
import java.util.Collection;
@@ -101,7 +102,6 @@ public class ImpersonatedCredentials extends GoogleCredentials
101102
implements ServiceAccountSigner, IdTokenProvider {
102103

103104
private static final long serialVersionUID = -2133257318957488431L;
104-
private static final String RFC3339 = "yyyy-MM-dd'T'HH:mm:ssX";
105105
private static final int TWELVE_HOURS_IN_SECONDS = 43200;
106106
private static final int DEFAULT_LIFETIME_IN_SECONDS = 3600;
107107
private GoogleCredentials sourceCredentials;
@@ -510,12 +510,16 @@ public CredentialTypeForMetrics getMetricsCredentialType() {
510510
}
511511

512512
/**
513-
* Clones the impersonated credentials with a new calendar.
513+
* This method is marked obsolete. There is no alternative to setting a custom calendar for the
514+
* Credential.
515+
*
516+
* <p>Clones the impersonated credentials with a new calendar.
514517
*
515518
* @param calendar the calendar that will be used by the new ImpersonatedCredentials instance when
516519
* parsing the received expiration time of the refreshed access token
517520
* @return the cloned impersonated credentials with the given custom calendar
518521
*/
522+
@ObsoleteApi("This method is obsolete and will be removed in a future release.")
519523
public ImpersonatedCredentials createWithCustomCalendar(Calendar calendar) {
520524
return toBuilder()
521525
.setScopes(this.scopes)
@@ -660,14 +664,23 @@ public AccessToken refreshAccessToken() throws IOException {
660664
String expireTime =
661665
OAuth2Utils.validateString(responseData, "expireTime", "Expected to find an expireTime");
662666

663-
DateFormat format = new SimpleDateFormat(RFC3339);
664-
format.setCalendar(calendar);
667+
Instant expirationInstant;
665668
try {
666-
Date date = format.parse(expireTime);
667-
return new AccessToken(accessToken, date);
668-
} catch (ParseException pe) {
669-
throw new IOException("Error parsing expireTime: " + pe.getMessage());
669+
if (calendar != null) {
670+
// For backward compatibility, if a custom calendar is set, use its timezone
671+
// and convert it to an Instant
672+
expirationInstant =
673+
Instant.from(
674+
DateTimeFormatter.ISO_INSTANT
675+
.withZone(calendar.getTimeZone().toZoneId())
676+
.parse(expireTime));
677+
} else {
678+
expirationInstant = Instant.parse(expireTime);
679+
}
680+
} catch (DateTimeException e) {
681+
throw new IOException("Error parsing expireTime: " + expireTime, e);
670682
}
683+
return new AccessToken(accessToken, Date.from(expirationInstant));
671684
}
672685

673686
/**
@@ -883,7 +896,17 @@ public Builder setIamEndpointOverride(String iamEndpointOverride) {
883896
return this;
884897
}
885898

899+
/**
900+
* This method is marked obsolete. There is no alternative to setting a custom calendar for the
901+
* Credential.
902+
*
903+
* <p>Sets the calendar to be used for parsing the expiration time.
904+
*
905+
* @param calendar the calendar to use
906+
* @return the builder
907+
*/
886908
@CanIgnoreReturnValue
909+
@ObsoleteApi("This method is obsolete and will be removed in a future release.")
887910
public Builder setCalendar(Calendar calendar) {
888911
this.calendar = calendar;
889912
return this;
@@ -903,6 +926,15 @@ public Builder setReadTimeout(int readTimeout) {
903926
return this;
904927
}
905928

929+
/**
930+
* This method is marked obsolete. There is no alternative to getting a custom calendar for the
931+
* Credential.
932+
*
933+
* <p>Returns the calendar to be used for parsing the expiration time.
934+
*
935+
* @return the calendar
936+
*/
937+
@ObsoleteApi("This method is obsolete and will be removed in a future release.")
906938
public Calendar getCalendar() {
907939
return this.calendar;
908940
}

google-auth-library-java/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java

Lines changed: 65 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
import static org.mockito.Mockito.when;
4545

4646
import com.google.api.client.http.HttpStatusCodes;
47+
import com.google.api.client.http.HttpTransport;
4748
import com.google.api.client.json.GenericJson;
4849
import com.google.api.client.json.JsonFactory;
4950
import com.google.api.client.json.JsonGenerator;
@@ -54,23 +55,22 @@
5455
import com.google.auth.Credentials;
5556
import com.google.auth.ServiceAccountSigner.SigningException;
5657
import com.google.auth.TestUtils;
58+
import com.google.auth.http.HttpTransportFactory;
5759
import com.google.common.collect.ImmutableList;
5860
import com.google.common.collect.ImmutableSet;
5961
import java.io.ByteArrayOutputStream;
6062
import java.io.IOException;
6163
import java.io.InputStream;
6264
import java.nio.charset.Charset;
6365
import java.security.PrivateKey;
64-
import java.text.DateFormat;
65-
import java.text.SimpleDateFormat;
66+
import java.time.Instant;
6667
import java.time.temporal.ChronoUnit;
6768
import java.util.ArrayList;
6869
import java.util.Arrays;
6970
import java.util.Calendar;
7071
import java.util.Date;
7172
import java.util.List;
7273
import java.util.Map;
73-
import java.util.TimeZone;
7474
import org.junit.jupiter.api.BeforeEach;
7575
import org.junit.jupiter.api.Test;
7676

@@ -122,8 +122,6 @@ class ImpersonatedCredentialsTest extends BaseSerializationTest {
122122
private static final int INVALID_LIFETIME = 43210;
123123
private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance();
124124

125-
private static final String RFC3339 = "yyyy-MM-dd'T'HH:mm:ssX";
126-
127125
private static final String TEST_UNIVERSE_DOMAIN = "test.xyz";
128126
private static final String OLD_IMPERSONATION_URL =
129127
"https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/"
@@ -653,61 +651,9 @@ void refreshAccessToken_delegates_success() throws IOException, IllegalStateExce
653651
assertEquals(ACCESS_TOKEN, targetCredentials.refreshAccessToken().getTokenValue());
654652
}
655653

656-
@Test
657-
void refreshAccessToken_GMT_dateParsedCorrectly() throws IOException, IllegalStateException {
658-
Calendar c = Calendar.getInstance();
659-
c.add(Calendar.SECOND, VALID_LIFETIME);
660-
661-
mockTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL);
662-
mockTransportFactory.getTransport().setAccessToken(ACCESS_TOKEN);
663-
mockTransportFactory.getTransport().setExpireTime(getFormattedTime(c.getTime()));
664-
mockTransportFactory.getTransport().addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, "");
665-
ImpersonatedCredentials targetCredentials =
666-
ImpersonatedCredentials.create(
667-
sourceCredentials,
668-
IMPERSONATED_CLIENT_EMAIL,
669-
null,
670-
IMMUTABLE_SCOPES_LIST,
671-
VALID_LIFETIME,
672-
mockTransportFactory)
673-
.createWithCustomCalendar(
674-
// Set system timezone to GMT
675-
Calendar.getInstance(TimeZone.getTimeZone("GMT")));
676-
677-
assertTrue(
678-
c.getTime().toInstant().truncatedTo(ChronoUnit.SECONDS).toEpochMilli()
679-
== targetCredentials.refreshAccessToken().getExpirationTimeMillis());
680-
}
681-
682-
@Test
683-
void refreshAccessToken_nonGMT_dateParsedCorrectly() throws IOException, IllegalStateException {
684-
Calendar c = Calendar.getInstance();
685-
c.add(Calendar.SECOND, VALID_LIFETIME);
686-
687-
mockTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL);
688-
mockTransportFactory.getTransport().setAccessToken(ACCESS_TOKEN);
689-
mockTransportFactory.getTransport().setExpireTime(getFormattedTime(c.getTime()));
690-
mockTransportFactory.getTransport().addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, "");
691-
ImpersonatedCredentials targetCredentials =
692-
ImpersonatedCredentials.create(
693-
sourceCredentials,
694-
IMPERSONATED_CLIENT_EMAIL,
695-
null,
696-
IMMUTABLE_SCOPES_LIST,
697-
VALID_LIFETIME,
698-
mockTransportFactory)
699-
.createWithCustomCalendar(
700-
// Set system timezone to one different than GMT
701-
Calendar.getInstance(TimeZone.getTimeZone("America/Los_Angeles")));
702-
703-
assertTrue(
704-
c.getTime().toInstant().truncatedTo(ChronoUnit.SECONDS).toEpochMilli()
705-
== targetCredentials.refreshAccessToken().getExpirationTimeMillis());
706-
}
707-
708654
@Test
709655
void refreshAccessToken_invalidDate() throws IllegalStateException {
710-
String expectedMessage = "Unparseable date";
656+
String expectedMessage = "Error parsing expireTime: ";
711657
mockTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL);
712658
mockTransportFactory.getTransport().setAccessToken("foo");
713659
mockTransportFactory.getTransport().setExpireTime("1973-09-29T15:01:23");
@@ -1297,22 +1243,69 @@ void serialize() throws IOException, ClassNotFoundException {
12971243
assertSame(deserializedCredentials.clock, Clock.SYSTEM);
12981244
}
12991245

1300-
public static String getDefaultExpireTime() {
1301-
Calendar c = Calendar.getInstance();
1302-
c.add(Calendar.SECOND, VALID_LIFETIME);
1303-
return getFormattedTime(c.getTime());
1304-
}
1305-
13061246
/**
1307-
* Given a {@link Date}, it will return a string of the date formatted like
1308-
* <b>yyyy-MM-dd'T'HH:mm:ss'Z'</b>
1247+
* A stateful {@link HttpTransportFactory} that provides a shared {@link
1248+
* MockIAMCredentialsServiceTransport} instance.
1249+
*
1250+
* <p>This is necessary for serialization tests because {@link ImpersonatedCredentials} stores the
1251+
* factory's class name and re-instantiates it via reflection during deserialization. A standard
1252+
* factory would create a fresh, unconfigured transport upon re-instantiation, causing refreshed
1253+
* token requests to fail. Using a static transport ensures the mock configuration persists across
1254+
* serialization boundaries.
13091255
*/
1310-
private static String getFormattedTime(final Date date) {
1311-
// Set timezone to GMT since that's the TZ used in the response from the service impersonation
1312-
// token exchange
1313-
final DateFormat formatter = new SimpleDateFormat(RFC3339);
1314-
formatter.setTimeZone(TimeZone.getTimeZone("GMT"));
1315-
return formatter.format(date);
1256+
public static class StatefulMockIAMTransportFactory implements HttpTransportFactory {
1257+
private static final MockIAMCredentialsServiceTransport TRANSPORT =
1258+
new MockIAMCredentialsServiceTransport(GoogleCredentials.GOOGLE_DEFAULT_UNIVERSE);
1259+
1260+
@Override
1261+
public HttpTransport create() {
1262+
return TRANSPORT;
1263+
}
1264+
1265+
public static MockIAMCredentialsServiceTransport getTransport() {
1266+
return TRANSPORT;
1267+
}
1268+
}
1269+
1270+
@Test
1271+
void refreshAccessToken_afterSerialization_success() throws IOException, ClassNotFoundException {
1272+
// This test ensures that credentials can still refresh after being serialized.
1273+
// ImpersonatedCredentials only serializes the transport factory's class name.
1274+
// Upon deserialization, it creates a new instance of that factory via reflection.
1275+
// StatefulMockIAMTransportFactory uses a static transport instance so that the
1276+
// configuration we set here (token, expiration) is available to the new factory instance.
1277+
MockIAMCredentialsServiceTransport transport = StatefulMockIAMTransportFactory.getTransport();
1278+
transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL);
1279+
transport.setAccessToken(ACCESS_TOKEN);
1280+
1281+
transport.setExpireTime(getDefaultExpireTime());
1282+
transport.addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, "", true);
1283+
1284+
// Use a source credential that doesn't need refresh
1285+
AccessToken sourceToken =
1286+
new AccessToken("source-token", new Date(System.currentTimeMillis() + 3600000));
1287+
GoogleCredentials sourceCredentials = GoogleCredentials.create(sourceToken);
1288+
1289+
ImpersonatedCredentials targetCredentials =
1290+
ImpersonatedCredentials.create(
1291+
sourceCredentials,
1292+
IMPERSONATED_CLIENT_EMAIL,
1293+
null,
1294+
IMMUTABLE_SCOPES_LIST,
1295+
VALID_LIFETIME,
1296+
new StatefulMockIAMTransportFactory());
1297+
1298+
ImpersonatedCredentials deserializedCredentials = serializeAndDeserialize(targetCredentials);
1299+
1300+
// This should not throw NPE. The transient 'calendar' field being null after
1301+
// deserialization is now handled by using java.time.Instant for parsing.
1302+
AccessToken token = deserializedCredentials.refreshAccessToken();
1303+
assertNotNull(token);
1304+
assertEquals(ACCESS_TOKEN, token.getTokenValue());
1305+
}
1306+
1307+
public static String getDefaultExpireTime() {
1308+
return Instant.now().plusSeconds(VALID_LIFETIME).truncatedTo(ChronoUnit.SECONDS).toString();
13161309
}
13171310

13181311
private String generateErrorJson(

0 commit comments

Comments
 (0)