From c8cf184302eed8009ea18f33e91c49d494f22588 Mon Sep 17 00:00:00 2001 From: Lawrence Qiu Date: Tue, 24 Mar 2026 21:15:21 +0000 Subject: [PATCH 1/6] chore: Add ObsoleteApi annotation --- .../auth/oauth2/ImpersonatedCredentials.java | 54 +++++++++++++++---- 1 file changed, 43 insertions(+), 11 deletions(-) diff --git a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 4f256fe52..7810f31f7 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -45,6 +45,7 @@ import com.google.api.client.json.JsonObjectParser; import com.google.api.client.util.GenericData; import com.google.api.core.InternalApi; +import com.google.api.core.ObsoleteApi; import com.google.auth.CredentialTypeForMetrics; import com.google.auth.ServiceAccountSigner; import com.google.auth.http.HttpCredentialsAdapter; @@ -59,9 +60,9 @@ import java.io.IOException; import java.io.InputStream; import java.io.ObjectInputStream; -import java.text.DateFormat; -import java.text.ParseException; -import java.text.SimpleDateFormat; +import java.time.DateTimeException; +import java.time.Instant; +import java.time.format.DateTimeFormatter; import java.util.ArrayList; import java.util.Calendar; import java.util.Collection; @@ -101,7 +102,6 @@ public class ImpersonatedCredentials extends GoogleCredentials implements ServiceAccountSigner, IdTokenProvider { private static final long serialVersionUID = -2133257318957488431L; - 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 GoogleCredentials sourceCredentials; @@ -510,12 +510,16 @@ public CredentialTypeForMetrics getMetricsCredentialType() { } /** - * Clones the impersonated credentials with a new calendar. + * This method is marked obsolete. There is no alternative to setting a custom calendar for the + * Credential. + * + *

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 */ + @ObsoleteApi("This method is obsolete and will be removed in a future release.") public ImpersonatedCredentials createWithCustomCalendar(Calendar calendar) { return toBuilder() .setScopes(this.scopes) @@ -660,14 +664,23 @@ public AccessToken refreshAccessToken() throws IOException { String expireTime = OAuth2Utils.validateString(responseData, "expireTime", "Expected to find an expireTime"); - DateFormat format = new SimpleDateFormat(RFC3339); - format.setCalendar(calendar); + Instant expirationInstant; try { - Date date = format.parse(expireTime); - return new AccessToken(accessToken, date); - } catch (ParseException pe) { - throw new IOException("Error parsing expireTime: " + pe.getMessage()); + if (calendar != null) { + // For backward compatibility, if a custom calendar is set, use its timezone + // and convert it to an Instant + expirationInstant = + Instant.from( + DateTimeFormatter.ISO_INSTANT + .withZone(calendar.getTimeZone().toZoneId()) + .parse(expireTime)); + } else { + expirationInstant = Instant.parse(expireTime); + } + } catch (DateTimeException e) { + throw new IOException("Unparseable date: \"" + expireTime + "\"", e); } + return new AccessToken(accessToken, Date.from(expirationInstant)); } /** @@ -883,7 +896,17 @@ public Builder setIamEndpointOverride(String iamEndpointOverride) { return this; } + /** + * This method is marked obsolete. There is no alternative to setting a custom calendar for the + * Credential. + * + *

Sets the calendar to be used for parsing the expiration time. + * + * @param calendar the calendar to use + * @return the builder + */ @CanIgnoreReturnValue + @ObsoleteApi("This method is obsolete and will be removed in a future release.") public Builder setCalendar(Calendar calendar) { this.calendar = calendar; return this; @@ -903,6 +926,15 @@ public Builder setReadTimeout(int readTimeout) { return this; } + /** + * This method is marked obsolete. There is no alternative to getting a custom calendar for the + * Credential. + * + *

Returns the calendar to be used for parsing the expiration time. + * + * @return the calendar + */ + @ObsoleteApi("This method is obsolete and will be removed in a future release.") public Calendar getCalendar() { return this.calendar; } From ca1c4f4aadbb38e5b4abd9d718746e533b29e282 Mon Sep 17 00:00:00 2001 From: Lawrence Qiu Date: Tue, 24 Mar 2026 21:39:36 +0000 Subject: [PATCH 2/6] chore: Add tests for serializing ImpersonatedCredentials --- .../auth/oauth2/ImpersonatedCredentials.java | 4 - .../oauth2/ImpersonatedCredentialsTest.java | 108 ++++++++++++++++++ 2 files changed, 108 insertions(+), 4 deletions(-) diff --git a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 7810f31f7..7afa751e1 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -436,7 +436,6 @@ public static ImpersonatedCredentials fromStream( * @return the credentials defined by the JSON * @throws IOException if the credential cannot be created from the JSON. */ - @SuppressWarnings("unchecked") static ImpersonatedCredentials fromJson( Map json, HttpTransportFactory transportFactory) throws IOException { checkNotNull(json); @@ -791,10 +790,7 @@ protected Builder() {} /** * @param sourceCredentials The source credentials to use for impersonation. * @param targetPrincipal The service account to impersonate. - * @deprecated Use {@link #Builder(ImpersonatedCredentials)} instead. This constructor will be - * removed in a future release. */ - @Deprecated protected Builder(GoogleCredentials sourceCredentials, String targetPrincipal) { this.sourceCredentials = sourceCredentials; this.targetPrincipal = targetPrincipal; diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index d85f2db80..2c332fe96 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -44,6 +44,7 @@ import static org.mockito.Mockito.when; import com.google.api.client.http.HttpStatusCodes; +import com.google.api.client.http.HttpTransport; import com.google.api.client.json.GenericJson; import com.google.api.client.json.JsonFactory; import com.google.api.client.json.JsonGenerator; @@ -54,6 +55,7 @@ import com.google.auth.Credentials; import com.google.auth.ServiceAccountSigner.SigningException; import com.google.auth.TestUtils; +import com.google.auth.http.HttpTransportFactory; import com.google.common.collect.ImmutableList; import com.google.common.collect.ImmutableSet; import java.io.ByteArrayOutputStream; @@ -1297,6 +1299,112 @@ void serialize() throws IOException, ClassNotFoundException { assertSame(deserializedCredentials.clock, Clock.SYSTEM); } + /** + * A stateful {@link HttpTransportFactory} that provides a shared {@link + * MockIAMCredentialsServiceTransport} instance. + * + *

This is necessary for serialization tests because {@link ImpersonatedCredentials} stores the + * factory's class name and re-instantiates it via reflection during deserialization. A standard + * factory would create a fresh, unconfigured transport upon re-instantiation, causing refreshed + * token requests to fail. Using a static transport ensures the mock configuration persists across + * serialization boundaries. + */ + public static class StatefulMockIAMTransportFactory implements HttpTransportFactory { + private static final MockIAMCredentialsServiceTransport TRANSPORT = + new MockIAMCredentialsServiceTransport(GoogleCredentials.GOOGLE_DEFAULT_UNIVERSE); + + @Override + public HttpTransport create() { + return TRANSPORT; + } + + public static MockIAMCredentialsServiceTransport getTransport() { + return TRANSPORT; + } + } + + @Test + void refreshAccessToken_afterSerialization_success() throws IOException, ClassNotFoundException { + // This test ensures that credentials can still refresh after being serialized. + // ImpersonatedCredentials only serializes the transport factory's class name. + // Upon deserialization, it creates a new instance of that factory via reflection. + // StatefulMockIAMTransportFactory uses a static transport instance so that the + // configuration we set here (token, expiration) is available to the new factory instance. + MockIAMCredentialsServiceTransport transport = StatefulMockIAMTransportFactory.getTransport(); + transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + transport.setAccessToken(ACCESS_TOKEN); + + transport.setExpireTime(getDefaultExpireTime()); + transport.addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, "", true); + + // Use a source credential that doesn't need refresh + AccessToken sourceToken = + new AccessToken("source-token", new Date(System.currentTimeMillis() + 3600000)); + GoogleCredentials sourceCredentials = GoogleCredentials.create(sourceToken); + + ImpersonatedCredentials targetCredentials = + ImpersonatedCredentials.create( + sourceCredentials, + IMPERSONATED_CLIENT_EMAIL, + null, + IMMUTABLE_SCOPES_LIST, + VALID_LIFETIME, + new StatefulMockIAMTransportFactory()); + + ImpersonatedCredentials deserializedCredentials = serializeAndDeserialize(targetCredentials); + + // This should not throw NPE. The transient 'calendar' field being null after + // deserialization is now handled by using java.time.Instant for parsing. + AccessToken token = deserializedCredentials.refreshAccessToken(); + assertNotNull(token); + assertEquals(ACCESS_TOKEN, token.getTokenValue()); + } + + @Test + void refreshAccessToken_withCustomCalendar_success() throws IOException { + // This test verifies behavioral parity between the new Instant-based logic and + // the legacy Calendar-based logic. It ensures that if a user provides a custom + // calendar with a specific timezone, that context is correctly respected + // during parsing, even though the primary parsing engine has changed. + MockIAMCredentialsServiceTransport transport = StatefulMockIAMTransportFactory.getTransport(); + transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); + transport.setAccessToken(ACCESS_TOKEN); + + // Create a calendar in a specific timezone (PST/PDT) + Calendar c = Calendar.getInstance(TimeZone.getTimeZone("America/Los_Angeles")); + // Set to a fixed point in time: 1:00 PM local wall-clock time + c.set(2026, Calendar.MARCH, 24, 13, 0, 0); + c.set(Calendar.MILLISECOND, 0); + Date expectedDate = c.getTime(); + + // The IAM API always returns Zulu (UTC) time strings. + // 1:00 PM PDT (UTC-7) corresponds to 8:00 PM UTC. + String expireTime = "2026-03-24T20:00:00Z"; + transport.setExpireTime(expireTime); + transport.addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, "", true); + + AccessToken sourceToken = + new AccessToken("source-token", new Date(System.currentTimeMillis() + 3600000)); + GoogleCredentials sourceCredentials = GoogleCredentials.create(sourceToken); + + ImpersonatedCredentials targetCredentials = + ImpersonatedCredentials.create( + sourceCredentials, + IMPERSONATED_CLIENT_EMAIL, + null, + IMMUTABLE_SCOPES_LIST, + VALID_LIFETIME, + new StatefulMockIAMTransportFactory()) + .createWithCustomCalendar(c); + + // This should work and correctly integrate the custom calendar's timezone configuration. + AccessToken token = targetCredentials.refreshAccessToken(); + assertNotNull(token); + assertEquals(ACCESS_TOKEN, token.getTokenValue()); + // Verify that the resulting point-in-time matches our original calendar configuration. + assertEquals(expectedDate.getTime(), token.getExpirationTime().getTime()); + } + public static String getDefaultExpireTime() { Calendar c = Calendar.getInstance(); c.add(Calendar.SECOND, VALID_LIFETIME); From 26e5e76ae2c09c5c2f95b77d11b6322be54c45a3 Mon Sep 17 00:00:00 2001 From: Lawrence Qiu Date: Wed, 25 Mar 2026 14:28:05 -0400 Subject: [PATCH 3/6] chore: Restore old code --- .../java/com/google/auth/oauth2/ImpersonatedCredentials.java | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 7afa751e1..7810f31f7 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -436,6 +436,7 @@ public static ImpersonatedCredentials fromStream( * @return the credentials defined by the JSON * @throws IOException if the credential cannot be created from the JSON. */ + @SuppressWarnings("unchecked") static ImpersonatedCredentials fromJson( Map json, HttpTransportFactory transportFactory) throws IOException { checkNotNull(json); @@ -790,7 +791,10 @@ protected Builder() {} /** * @param sourceCredentials The source credentials to use for impersonation. * @param targetPrincipal The service account to impersonate. + * @deprecated Use {@link #Builder(ImpersonatedCredentials)} instead. This constructor will be + * removed in a future release. */ + @Deprecated protected Builder(GoogleCredentials sourceCredentials, String targetPrincipal) { this.sourceCredentials = sourceCredentials; this.targetPrincipal = targetPrincipal; From 0061793bd1b091e0e35e2cf095b009b5e27e791e Mon Sep 17 00:00:00 2001 From: Lawrence Qiu Date: Wed, 25 Mar 2026 14:31:51 -0400 Subject: [PATCH 4/6] chore: Update error message --- .../java/com/google/auth/oauth2/ImpersonatedCredentials.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java index 7810f31f7..274f30ff9 100644 --- a/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java +++ b/oauth2_http/java/com/google/auth/oauth2/ImpersonatedCredentials.java @@ -678,7 +678,7 @@ public AccessToken refreshAccessToken() throws IOException { expirationInstant = Instant.parse(expireTime); } } catch (DateTimeException e) { - throw new IOException("Unparseable date: \"" + expireTime + "\"", e); + throw new IOException("Error parsing expireTime: " + expireTime, e); } return new AccessToken(accessToken, Date.from(expirationInstant)); } From e30ed90c6e09f8c38beec1f5723b84da517078e6 Mon Sep 17 00:00:00 2001 From: Lawrence Qiu Date: Wed, 25 Mar 2026 14:57:27 -0400 Subject: [PATCH 5/6] chore: Fix failing test issue --- .../com/google/auth/oauth2/ImpersonatedCredentialsTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index 2c332fe96..58ce2fef7 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -709,7 +709,7 @@ void refreshAccessToken_nonGMT_dateParsedCorrectly() throws IOException, Illegal @Test void refreshAccessToken_invalidDate() throws IllegalStateException { - String expectedMessage = "Unparseable date"; + String expectedMessage = "Error parsing expireTime: "; mockTransportFactory.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); mockTransportFactory.getTransport().setAccessToken("foo"); mockTransportFactory.getTransport().setExpireTime("1973-09-29T15:01:23"); From 31602c725604042563cff781794291a103f4dd8e Mon Sep 17 00:00:00 2001 From: Lawrence Qiu Date: Wed, 25 Mar 2026 16:47:53 -0400 Subject: [PATCH 6/6] chore: Remove old tests referecing obsolete api functionality --- .../oauth2/ImpersonatedCredentialsTest.java | 119 +----------------- 1 file changed, 2 insertions(+), 117 deletions(-) diff --git a/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java b/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java index 58ce2fef7..0a70c1ec7 100644 --- a/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java +++ b/oauth2_http/javatests/com/google/auth/oauth2/ImpersonatedCredentialsTest.java @@ -63,8 +63,7 @@ import java.io.InputStream; import java.nio.charset.Charset; import java.security.PrivateKey; -import java.text.DateFormat; -import java.text.SimpleDateFormat; +import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.Arrays; @@ -72,7 +71,6 @@ import java.util.Date; import java.util.List; import java.util.Map; -import java.util.TimeZone; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -124,8 +122,6 @@ class ImpersonatedCredentialsTest extends BaseSerializationTest { private static final int INVALID_LIFETIME = 43210; private static final JsonFactory JSON_FACTORY = GsonFactory.getDefaultInstance(); - private static final String RFC3339 = "yyyy-MM-dd'T'HH:mm:ssX"; - private static final String TEST_UNIVERSE_DOMAIN = "test.xyz"; private static final String OLD_IMPERSONATION_URL = "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/" @@ -655,58 +651,6 @@ 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.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); - mockTransportFactory.getTransport().setAccessToken(ACCESS_TOKEN); - mockTransportFactory.getTransport().setExpireTime(getFormattedTime(c.getTime())); - mockTransportFactory.getTransport().addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); - 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"))); - - assertTrue( - 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.getTransport().setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); - mockTransportFactory.getTransport().setAccessToken(ACCESS_TOKEN); - mockTransportFactory.getTransport().setExpireTime(getFormattedTime(c.getTime())); - mockTransportFactory.getTransport().addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, ""); - 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"))); - - assertTrue( - c.getTime().toInstant().truncatedTo(ChronoUnit.SECONDS).toEpochMilli() - == targetCredentials.refreshAccessToken().getExpirationTimeMillis()); - } - @Test void refreshAccessToken_invalidDate() throws IllegalStateException { String expectedMessage = "Error parsing expireTime: "; @@ -1360,67 +1304,8 @@ void refreshAccessToken_afterSerialization_success() throws IOException, ClassNo assertEquals(ACCESS_TOKEN, token.getTokenValue()); } - @Test - void refreshAccessToken_withCustomCalendar_success() throws IOException { - // This test verifies behavioral parity between the new Instant-based logic and - // the legacy Calendar-based logic. It ensures that if a user provides a custom - // calendar with a specific timezone, that context is correctly respected - // during parsing, even though the primary parsing engine has changed. - MockIAMCredentialsServiceTransport transport = StatefulMockIAMTransportFactory.getTransport(); - transport.setTargetPrincipal(IMPERSONATED_CLIENT_EMAIL); - transport.setAccessToken(ACCESS_TOKEN); - - // Create a calendar in a specific timezone (PST/PDT) - Calendar c = Calendar.getInstance(TimeZone.getTimeZone("America/Los_Angeles")); - // Set to a fixed point in time: 1:00 PM local wall-clock time - c.set(2026, Calendar.MARCH, 24, 13, 0, 0); - c.set(Calendar.MILLISECOND, 0); - Date expectedDate = c.getTime(); - - // The IAM API always returns Zulu (UTC) time strings. - // 1:00 PM PDT (UTC-7) corresponds to 8:00 PM UTC. - String expireTime = "2026-03-24T20:00:00Z"; - transport.setExpireTime(expireTime); - transport.addStatusCodeAndMessage(HttpStatusCodes.STATUS_CODE_OK, "", true); - - AccessToken sourceToken = - new AccessToken("source-token", new Date(System.currentTimeMillis() + 3600000)); - GoogleCredentials sourceCredentials = GoogleCredentials.create(sourceToken); - - ImpersonatedCredentials targetCredentials = - ImpersonatedCredentials.create( - sourceCredentials, - IMPERSONATED_CLIENT_EMAIL, - null, - IMMUTABLE_SCOPES_LIST, - VALID_LIFETIME, - new StatefulMockIAMTransportFactory()) - .createWithCustomCalendar(c); - - // This should work and correctly integrate the custom calendar's timezone configuration. - AccessToken token = targetCredentials.refreshAccessToken(); - assertNotNull(token); - assertEquals(ACCESS_TOKEN, token.getTokenValue()); - // Verify that the resulting point-in-time matches our original calendar configuration. - assertEquals(expectedDate.getTime(), token.getExpirationTime().getTime()); - } - public static String getDefaultExpireTime() { - Calendar c = Calendar.getInstance(); - c.add(Calendar.SECOND, VALID_LIFETIME); - 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); + return Instant.now().plusSeconds(VALID_LIFETIME).truncatedTo(ChronoUnit.SECONDS).toString(); } private String generateErrorJson(