diff --git a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 9694619df..375d957a4 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -55,6 +55,7 @@ import java.text.SimpleDateFormat; import java.util.ArrayList; import java.util.Arrays; +import java.util.Calendar; import java.util.Collection; import java.util.Date; import java.util.List; @@ -91,7 +92,7 @@ public class ImpersonatedCredentials extends GoogleCredentials implements ServiceAccountSigner, IdTokenProvider, QuotaProjectIdProvider { private static final long serialVersionUID = -2133257318957488431L; - private static final String RFC3339 = "yyyy-MM-dd'T'HH:mm:ss'Z'"; + private static final String RFC3339 = "yyyy-MM-dd'T'HH:mm:ssX"; private static final int TWELVE_HOURS_IN_SECONDS = 43200; private static final int DEFAULT_LIFETIME_IN_SECONDS = 3600; private static final String CLOUD_PLATFORM_SCOPE = @@ -110,6 +111,8 @@ public class ImpersonatedCredentials extends GoogleCredentials private transient HttpTransportFactory transportFactory; + private transient Calendar calendar; + /** * @param sourceCredentials the source credential used to acquire the impersonated credentials. It * should be either a user account credential or a service account credential. @@ -429,6 +432,25 @@ public GoogleCredentials createScoped(Collection scopes) { .build(); } + /** + * Clones the impersonated credentials with a new calendar. + * + * @param calendar the calendar that will be used by the new ImpersonatedCredentials instance when + * parsing the received expiration time of the refreshed access token + * @return the cloned impersonated credentials with the given custom calendar + */ + public ImpersonatedCredentials createWithCustomCalendar(Calendar calendar) { + return toBuilder() + .setScopes(this.scopes) + .setLifetime(this.lifetime) + .setDelegates(this.delegates) + .setHttpTransportFactory(this.transportFactory) + .setQuotaProjectId(this.quotaProjectId) + .setIamEndpointOverride(this.iamEndpointOverride) + .setCalendar(calendar) + .build(); + } + @Override protected Map> getAdditionalHeaders() { Map> headers = super.getAdditionalHeaders(); @@ -451,6 +473,7 @@ private ImpersonatedCredentials(Builder builder) { this.quotaProjectId = builder.quotaProjectId; this.iamEndpointOverride = builder.iamEndpointOverride; this.transportFactoryClassName = this.transportFactory.getClass().getName(); + this.calendar = builder.getCalendar(); if (this.delegates == null) { this.delegates = new ArrayList(); } @@ -512,6 +535,7 @@ public AccessToken refreshAccessToken() throws IOException { OAuth2Utils.validateString(responseData, "expireTime", "Expected to find an expireTime"); DateFormat format = new SimpleDateFormat(RFC3339); + format.setCalendar(calendar); try { Date date = format.parse(expireTime); return new AccessToken(accessToken, date); @@ -606,6 +630,7 @@ public static class Builder extends GoogleCredentials.Builder { private HttpTransportFactory transportFactory; private String quotaProjectId; private String iamEndpointOverride; + private Calendar calendar = Calendar.getInstance(); protected Builder() {} @@ -678,6 +703,15 @@ public Builder setIamEndpointOverride(String iamEndpointOverride) { return this; } + public Builder setCalendar(Calendar calendar) { + this.calendar = calendar; + return this; + } + + public Calendar getCalendar() { + return this.calendar; + } + public ImpersonatedCredentials build() { return new ImpersonatedCredentials(this); } diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index ec718d898..4cbacb12d 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -60,7 +60,9 @@ import java.io.InputStream; import java.nio.charset.Charset; import java.security.PrivateKey; +import java.text.DateFormat; import java.text.SimpleDateFormat; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; import java.util.Calendar; @@ -68,6 +70,7 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.TimeZone; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -118,7 +121,7 @@ class ImpersonatedCredentialsTest extends BaseSerializationTest { private static final int INVALID_LIFETIME = 43210; private static JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); - private static final String RFC3339 = "yyyy-MM-dd'T'HH:mm:ss'Z'"; + private static final String RFC3339 = "yyyy-MM-dd'T'HH:mm:ssX"; public static final String DEFAULT_IMPERSONATION_URL = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/" + IMPERSONATED_CLIENT_EMAIL @@ -562,6 +565,56 @@ void refreshAccessToken_delegates_success() throws IOException, IllegalStateExce assertEquals(ACCESS_TOKEN, targetCredentials.refreshAccessToken().getTokenValue()); } + @Test + void refreshAccessToken_GMT_dateParsedCorrectly() throws IOException, IllegalStateException { + Calendar c = Calendar.getInstance(); + c.add(Calendar.SECOND, VALID_LIFETIME); + + mockTransportFactory.transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + mockTransportFactory.transport.setAccessToken(ACCESS_TOKEN); + mockTransportFactory.transport.setExpireTime(getFormattedTime(c.getTime())); + ImpersonatedCredentials targetCredentials = + ImpersonatedCredentials.create( + sourceCredentials, + IMPERSONATED_CLIENT_EMAIL, + null, + IMMUTABLE_SCOPES_LIST, + VALID_LIFETIME, + mockTransportFactory) + .createWithCustomCalendar( + // Set system timezone to GMT + Calendar.getInstance(TimeZone.getTimeZone("GMT"))); + + assertEquals( + c.getTime().toInstant().truncatedTo(ChronoUnit.SECONDS).toEpochMilli(), + targetCredentials.refreshAccessToken().getExpirationTimeMillis()); + } + + @Test + void refreshAccessToken_nonGMT_dateParsedCorrectly() throws IOException, IllegalStateException { + Calendar c = Calendar.getInstance(); + c.add(Calendar.SECOND, VALID_LIFETIME); + + mockTransportFactory.transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + mockTransportFactory.transport.setAccessToken(ACCESS_TOKEN); + mockTransportFactory.transport.setExpireTime(getFormattedTime(c.getTime())); + ImpersonatedCredentials targetCredentials = + ImpersonatedCredentials.create( + sourceCredentials, + IMPERSONATED_CLIENT_EMAIL, + null, + IMMUTABLE_SCOPES_LIST, + VALID_LIFETIME, + mockTransportFactory) + .createWithCustomCalendar( + // Set system timezone to one different than GMT + Calendar.getInstance(TimeZone.getTimeZone("America/Los_Angeles"))); + + assertEquals( + c.getTime().toInstant().truncatedTo(ChronoUnit.SECONDS).toEpochMilli(), + targetCredentials.refreshAccessToken().getExpirationTimeMillis()); + } + @Test void refreshAccessToken_invalidDate() throws IllegalStateException { @@ -926,7 +979,19 @@ void serialize() throws IOException, ClassNotFoundException { public static String getDefaultExpireTime() { Calendar c = Calendar.getInstance(); c.add(Calendar.SECOND, VALID_LIFETIME); - return new SimpleDateFormat(RFC3339).format(c.getTime()); + return getFormattedTime(c.getTime()); + } + + /** + * Given a {@link Date}, it will return a string of the date formatted like + * yyyy-MM-dd'T'HH:mm:ss'Z' + */ + private static String getFormattedTime(final Date date) { + // Set timezone to GMT since that's the TZ used in the response from the service impersonation + // token exchange + final DateFormat formatter = new SimpleDateFormat(RFC3339); + formatter.setTimeZone(TimeZone.getTimeZone("GMT")); + return formatter.format(date); } private String generateErrorJson(