From 64d7385dca516f0f4c7f79360694f80690d53fa7 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Tue, 30 Jun 2026 10:12:15 -0700 Subject: [PATCH 1/2] feat(auth): Add dynamic advisory refresh window and fix prefetch failure stale time Implement two changes from the updated cross-SDK credential refresh spec: 1. Dynamic advisory refresh window: When the user has NOT explicitly configured a prefetchTime, compute the advisory window dynamically based on the credential's remaining lifetime: - remaining < 20 min -> 5 min window - 20 min <= remaining < 90 min -> 15 min window - remaining >= 90 min -> 60 min window This is recomputed on each successful refresh. If the user HAS explicitly configured a value, it is always honored unchanged. 2. Prefetch failure stale time preservation: When a credential refresh fails during the advisory (prefetch) window, extend the prefetch time by backoff but preserve the existing stale time if it is later than the new prefetch time. Previously both were set to the same backoff value, which could move the mandatory refresh boundary closer than intended. Affected providers: STS (all), IMDS, Container, SSO, Login, Process. New utility: CacheRefreshUtils.computeDynamicPrefetchWindow() --- .../ContainerCredentialsProvider.java | 16 ++- .../InstanceProfileCredentialsProvider.java | 21 +++- .../ProcessCredentialsProvider.java | 15 ++- ...bIdentityTokenFileCredentialsProvider.java | 5 +- ...nstanceProfileCredentialsProviderTest.java | 10 +- .../signin/auth/LoginCredentialsProvider.java | 19 +++- .../sso/auth/SsoCredentialsProvider.java | 15 ++- .../sts/auth/StsCredentialsProvider.java | 15 ++- .../awssdk/utils/cache/CacheRefreshUtils.java | 65 +++++++++++ .../awssdk/utils/cache/CachedSupplier.java | 11 +- .../utils/cache/CacheRefreshUtilsTest.java | 102 ++++++++++++++++++ .../utils/cache/CachedSupplierTest.java | 43 ++++++++ 12 files changed, 313 insertions(+), 24 deletions(-) create mode 100644 utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java create mode 100644 utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java index 89b894b0f95b..e4f4a7a8d4bc 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java @@ -50,6 +50,7 @@ import software.amazon.awssdk.utils.Validate; import software.amazon.awssdk.utils.builder.CopyableBuilder; import software.amazon.awssdk.utils.builder.ToCopyableBuilder; +import software.amazon.awssdk.utils.cache.CacheRefreshUtils; import software.amazon.awssdk.utils.cache.CachedSupplier; import software.amazon.awssdk.utils.cache.NonBlocking; import software.amazon.awssdk.utils.cache.RefreshResult; @@ -100,6 +101,7 @@ public final class ContainerCredentialsProvider private final String providerName; private final Duration staleTime; private final Duration prefetchTime; + private final boolean prefetchTimeExplicitlySet; /** * @see #builder() @@ -115,6 +117,7 @@ private ContainerCredentialsProvider(BuilderImpl builder) { this.httpCredentialsLoader = HttpCredentialsLoader.create(this.providerName); this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + this.prefetchTimeExplicitlySet = builder.prefetchTime != null; Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); @@ -172,7 +175,13 @@ private Instant prefetchTime(Instant expiration) { if (expiration == null) { return Instant.now().plus(1, ChronoUnit.HOURS); } - return expiration.minus(prefetchTime); + + Instant now = Instant.now(); + Duration effectivePrefetchWindow = prefetchTimeExplicitlySet + ? prefetchTime + : CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, now); + + return expiration.minus(effectivePrefetchWindow); } @Override @@ -356,7 +365,10 @@ public interface Builder extends HttpCredentialsProvider.BuilderThis value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to * {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking). * - *

By default, this is 5 minutes. + *

If not explicitly set, the advisory refresh window is computed dynamically based on the credential's + * remaining lifetime: 5 minutes for credentials with less than 20 minutes remaining, 15 minutes for 20-90 + * minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each + * successful refresh. * * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java index b6098427c186..eb2ef26c97d9 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java @@ -49,6 +49,7 @@ import software.amazon.awssdk.utils.Validate; import software.amazon.awssdk.utils.builder.CopyableBuilder; import software.amazon.awssdk.utils.builder.ToCopyableBuilder; +import software.amazon.awssdk.utils.cache.CacheRefreshUtils; import software.amazon.awssdk.utils.cache.CachedSupplier; import software.amazon.awssdk.utils.cache.NonBlocking; import software.amazon.awssdk.utils.cache.RefreshResult; @@ -94,6 +95,8 @@ public final class InstanceProfileCredentialsProvider private final Duration prefetchTime; + private final boolean prefetchTimeExplicitlySet; + private final String sourceChain; private final String providerName; @@ -123,6 +126,7 @@ private InstanceProfileCredentialsProvider(BuilderImpl builder) { this.staleTime = Validate.getOrDefault(builder.staleTime, () -> Duration.ofMinutes(1)); this.prefetchTime = Validate.getOrDefault(builder.prefetchTime, () -> Duration.ofMinutes(5)); + this.prefetchTimeExplicitlySet = builder.prefetchTime != null; Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); @@ -208,12 +212,16 @@ private Instant prefetchTime(Instant expiration) { return null; } - // Advisory refresh window: use configured prefetchTime before expiry. - // If remaining lifetime < prefetchTime, refresh immediately. - if (timeUntilExpiration.compareTo(prefetchTime) < 0) { + // Use dynamic window when user has not explicitly configured prefetchTime + Duration effectivePrefetchWindow = prefetchTimeExplicitlySet + ? prefetchTime + : CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, now); + + // If remaining lifetime < the advisory window, refresh immediately. + if (timeUntilExpiration.compareTo(effectivePrefetchWindow) < 0) { return now; } - return expiration.minus(prefetchTime); + return expiration.minus(effectivePrefetchWindow); } @Override @@ -391,7 +399,10 @@ public interface Builder extends HttpCredentialsProvider.BuilderThis value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to * {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking). * - *

By default, this is 5 minutes. + *

If not explicitly set, the advisory refresh window is computed dynamically based on the credential's + * remaining lifetime: 5 minutes for credentials with less than 20 minutes remaining, 15 minutes for 20-90 + * minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each + * successful refresh. * * @param duration the duration before expiration that triggers advisory (proactive) refresh */ diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java index 0b1acb2b4b28..00c85c93197b 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java @@ -38,6 +38,7 @@ import software.amazon.awssdk.utils.Validate; import software.amazon.awssdk.utils.builder.CopyableBuilder; import software.amazon.awssdk.utils.builder.ToCopyableBuilder; +import software.amazon.awssdk.utils.cache.CacheRefreshUtils; import software.amazon.awssdk.utils.cache.CachedSupplier; import software.amazon.awssdk.utils.cache.NonBlocking; import software.amazon.awssdk.utils.cache.RefreshResult; @@ -103,6 +104,7 @@ public final class ProcessCredentialsProvider private final String providerName; private final Duration staleTime; private final Duration prefetchTime; + private final boolean prefetchTimeExplicitlySet; /** * @see #builder() @@ -120,6 +122,7 @@ private ProcessCredentialsProvider(Builder builder) { : builder.sourceChain + "," + PROVIDER_NAME; this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + this.prefetchTimeExplicitlySet = builder.prefetchTime != null; Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); @@ -194,7 +197,12 @@ private Instant prefetchTime(Instant expiration) { if (expiration == null || expiration.equals(Instant.MAX)) { return Instant.MAX; } - return expiration.minus(prefetchTime); + if (prefetchTimeExplicitlySet) { + return expiration.minus(prefetchTime); + } + Instant now = Instant.now(); + Duration dynamicWindow = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, now); + return expiration.minus(dynamicWindow); } /** @@ -382,7 +390,10 @@ public Builder staleTime(Duration staleTime) { *

This value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to * {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking). * - *

By default, this is 5 minutes.

+ *

If not explicitly set, the advisory refresh window is computed dynamically based on the credential's + * remaining lifetime: 5 minutes for credentials with less than 20 minutes remaining, 15 minutes for 20-90 + * minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each + * successful refresh.

* * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/WebIdentityTokenFileCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/WebIdentityTokenFileCredentialsProvider.java index 53ecf64b53da..e108482b0490 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/WebIdentityTokenFileCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/WebIdentityTokenFileCredentialsProvider.java @@ -201,7 +201,10 @@ public interface Builder extends CopyableBuilderThis value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to * {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking). * - *

By default, this is 5 minutes. + *

If not explicitly set, the advisory refresh window is computed dynamically based on the credential's + * remaining lifetime: 5 minutes for credentials with less than 20 minutes remaining, 15 minutes for 20-90 + * minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each + * successful refresh. * * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ diff --git a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java index 46bf5faf8123..9a5ad4079e65 100644 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProviderTest.java @@ -639,13 +639,13 @@ void imdsCallFrequencyIsLimited() { stubSecureCredentialsResponse(aResponse().withBody(successfulCredentialsResponse1)); AwsCredentials credentialsAtStart = credentialsProvider.resolveCredentials(); - // Move time forward but still before the prefetch window (5 min before expiry). - // Since prefetchTime = expiration - 5min = now + 5h55m, anything before that should not trigger refresh. - clock.time = now.plus(5, HOURS); + // Move time forward but still before the prefetch window (60 min before expiry for 6h credentials). + // Since dynamic prefetchTime = expiration - 60min = now + 5h, anything before that should not trigger refresh. + clock.time = now.plus(4, HOURS); stubSecureCredentialsResponse(aResponse().withBody(successfulCredentialsResponse2)); - AwsCredentials credentials5HoursLater = credentialsProvider.resolveCredentials(); + AwsCredentials credentialsLater = credentialsProvider.resolveCredentials(); - assertThat(credentials5HoursLater).isEqualTo(credentialsAtStart); + assertThat(credentialsLater).isEqualTo(credentialsAtStart); assertThat(credentialsAtStart.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY"); } diff --git a/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java b/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java index 8c2ca8be010b..97260856b2b2 100644 --- a/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java +++ b/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java @@ -51,6 +51,7 @@ import software.amazon.awssdk.utils.Validate; import software.amazon.awssdk.utils.builder.CopyableBuilder; import software.amazon.awssdk.utils.builder.ToCopyableBuilder; +import software.amazon.awssdk.utils.cache.CacheRefreshUtils; import software.amazon.awssdk.utils.cache.CachedSupplier; import software.amazon.awssdk.utils.cache.NonBlocking; import software.amazon.awssdk.utils.cache.RefreshResult; @@ -90,6 +91,7 @@ public final class LoginCredentialsProvider implements private final SigninClient signinClient; private final Duration staleTime; private final Duration prefetchTime; + private final boolean prefetchTimeExplicitlySet; private final Path tokenCacheLocation; private final CachedSupplier credentialCache; @@ -107,6 +109,7 @@ private LoginCredentialsProvider(BuilderImpl builder) { this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + this.prefetchTimeExplicitlySet = builder.prefetchTime != null; Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); this.sourceChain = builder.sourceChain; @@ -157,9 +160,14 @@ && shouldNotRefresh(currentExpirationTime, prefetchTime)) { .providerName(this.providerName) .build(); + Instant now = Instant.now(); + Duration effectivePrefetchWindow = prefetchTimeExplicitlySet + ? prefetchTime + : CacheRefreshUtils.computeDynamicPrefetchWindow(currentExpirationTime, now); + return RefreshResult.builder(credentials) .staleTime(currentExpirationTime.minus(staleTime)) - .prefetchTime(currentExpirationTime.minus(prefetchTime)) + .prefetchTime(currentExpirationTime.minus(effectivePrefetchWindow)) .build(); } @@ -203,7 +211,9 @@ private RefreshResult refreshFromSigninService(LoginAccessToken return RefreshResult.builder((AwsCredentials) updatedCredentials) .staleTime(newExpiration.minus(staleTime)) - .prefetchTime(newExpiration.minus(prefetchTime)) + .prefetchTime(newExpiration.minus(prefetchTimeExplicitlySet + ? prefetchTime + : CacheRefreshUtils.computeDynamicPrefetchWindow(newExpiration, Instant.now()))) .build(); } catch (AccessDeniedException accessDeniedException) { if (accessDeniedException.error() == null) { @@ -355,7 +365,10 @@ public interface Builder extends CopyableBuilderThis value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to * {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking). * - *

By default, this is 5 minutes. + *

If not explicitly set, the advisory refresh window is computed dynamically based on the credential's + * remaining lifetime: 5 minutes for credentials with less than 20 minutes remaining, 15 minutes for 20-90 + * minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each + * successful refresh. * * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ diff --git a/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProvider.java b/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProvider.java index eeefefb9983c..82b64bb4e7a4 100644 --- a/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProvider.java +++ b/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProvider.java @@ -37,6 +37,7 @@ import software.amazon.awssdk.utils.StringUtils; import software.amazon.awssdk.utils.builder.CopyableBuilder; import software.amazon.awssdk.utils.builder.ToCopyableBuilder; +import software.amazon.awssdk.utils.cache.CacheRefreshUtils; import software.amazon.awssdk.utils.cache.CachedSupplier; import software.amazon.awssdk.utils.cache.NonBlocking; import software.amazon.awssdk.utils.cache.RefreshResult; @@ -70,6 +71,7 @@ public final class SsoCredentialsProvider implements AwsCredentialsProvider, Sdk private final SsoClient ssoClient; private final Duration staleTime; private final Duration prefetchTime; + private final boolean prefetchTimeExplicitlySet; private final CachedSupplier credentialCache; @@ -84,6 +86,7 @@ private SsoCredentialsProvider(BuilderImpl builder) { this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + this.prefetchTimeExplicitlySet = builder.prefetchTime != null; isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); this.sourceChain = builder.sourceChain; @@ -114,9 +117,14 @@ private RefreshResult updateSsoCredentials() { SessionCredentialsHolder credentials = getUpdatedCredentials(ssoClient); Instant actualTokenExpiration = credentials.sessionCredentialsExpiration(); + Instant now = Instant.now(); + Duration effectivePrefetchWindow = prefetchTimeExplicitlySet + ? prefetchTime + : CacheRefreshUtils.computeDynamicPrefetchWindow(actualTokenExpiration, now); + return RefreshResult.builder(credentials) .staleTime(actualTokenExpiration.minus(staleTime)) - .prefetchTime(actualTokenExpiration.minus(prefetchTime)) + .prefetchTime(actualTokenExpiration.minus(effectivePrefetchWindow)) .build(); } @@ -225,7 +233,10 @@ public interface Builder extends CopyableBuilderThis value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to * {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking). * - *

By default, this is 5 minutes.

+ *

If not explicitly set, the advisory refresh window is computed dynamically based on the credential's + * remaining lifetime: 5 minutes for credentials with less than 20 minutes remaining, 15 minutes for 20-90 + * minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each + * successful refresh.

* * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ diff --git a/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProvider.java b/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProvider.java index c878bd987970..a29e99b08a1a 100644 --- a/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProvider.java +++ b/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProvider.java @@ -32,6 +32,7 @@ import software.amazon.awssdk.utils.Validate; import software.amazon.awssdk.utils.builder.CopyableBuilder; import software.amazon.awssdk.utils.builder.ToCopyableBuilder; +import software.amazon.awssdk.utils.cache.CacheRefreshUtils; import software.amazon.awssdk.utils.cache.CachedSupplier; import software.amazon.awssdk.utils.cache.NonBlocking; import software.amazon.awssdk.utils.cache.RefreshResult; @@ -67,6 +68,7 @@ public abstract class StsCredentialsProvider implements AwsCredentialsProvider, private final Duration staleTime; private final Duration prefetchTime; + private final boolean prefetchTimeExplicitlySet; private final Boolean asyncCredentialUpdateEnabled; StsCredentialsProvider(BaseBuilder builder, String asyncThreadName) { @@ -74,6 +76,7 @@ public abstract class StsCredentialsProvider implements AwsCredentialsProvider, this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); + this.prefetchTimeExplicitlySet = builder.prefetchTime != null; Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); @@ -98,9 +101,14 @@ private RefreshResult updateSessionCredentials() { credentials.expirationTime() .orElseThrow(() -> new IllegalStateException("Sourced credentials have no expiration value")); + Instant now = Instant.now(); + Duration effectivePrefetchWindow = prefetchTimeExplicitlySet + ? prefetchTime + : CacheRefreshUtils.computeDynamicPrefetchWindow(actualTokenExpiration, now); + return RefreshResult.builder(credentials) .staleTime(actualTokenExpiration.minus(staleTime)) - .prefetchTime(actualTokenExpiration.minus(prefetchTime)) + .prefetchTime(actualTokenExpiration.minus(effectivePrefetchWindow)) .build(); } @@ -234,7 +242,10 @@ public B staleTime(Duration staleTime) { *

This value must be greater than or equal to {@link #staleTime(Duration)}. Setting this equal to * {@code staleTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking). * - *

By default, this is 5 minutes.

+ *

If not explicitly set, the advisory refresh window is computed dynamically based on the credential's + * remaining lifetime: 5 minutes for credentials with less than 20 minutes remaining, 15 minutes for 20-90 + * minutes remaining, and 60 minutes for 90+ minutes remaining. This dynamic window is recomputed on each + * successful refresh.

* * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ diff --git a/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java b/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java new file mode 100644 index 000000000000..4a7b28d7e84d --- /dev/null +++ b/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java @@ -0,0 +1,65 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.utils.cache; + +import java.time.Duration; +import java.time.Instant; +import software.amazon.awssdk.annotations.SdkProtectedApi; + +/** + * Utility methods for credential cache refresh timing computation. + */ +@SdkProtectedApi +public final class CacheRefreshUtils { + + private static final Duration WINDOW_SHORT = Duration.ofMinutes(5); + private static final Duration WINDOW_MEDIUM = Duration.ofMinutes(15); + private static final Duration WINDOW_LONG = Duration.ofMinutes(60); + + private static final long THRESHOLD_MEDIUM_MINUTES = 20; + private static final long THRESHOLD_LONG_MINUTES = 90; + + private CacheRefreshUtils() { + } + + /** + * Compute the dynamic advisory refresh window (prefetch time) based on the credential's remaining lifetime. + * The window scales with the credential's time-to-expiry so that longer-lived credentials begin refreshing + * earlier and shorter-lived credentials do not attempt a refresh the moment they are issued. + * + *
    + *
  • remaining lifetime < 20 minutes → 5 minute window
  • + *
  • 20 minutes ≤ remaining lifetime < 90 minutes → 15 minute window
  • + *
  • remaining lifetime ≥ 90 minutes → 60 minute window
  • + *
+ * + * @param expiration the credential's expiration time + * @param now the current time + * @return the Duration to use as the advisory refresh window + */ + public static Duration computeDynamicPrefetchWindow(Instant expiration, Instant now) { + Duration remainingLifetime = Duration.between(now, expiration); + long remainingMinutes = remainingLifetime.toMinutes(); + + if (remainingMinutes < THRESHOLD_MEDIUM_MINUTES) { + return WINDOW_SHORT; + } else if (remainingMinutes < THRESHOLD_LONG_MINUTES) { + return WINDOW_MEDIUM; + } else { + return WINDOW_LONG; + } + } +} diff --git a/utils/src/main/java/software/amazon/awssdk/utils/cache/CachedSupplier.java b/utils/src/main/java/software/amazon/awssdk/utils/cache/CachedSupplier.java index 38cd00033e25..ab978bb1a7dc 100644 --- a/utils/src/main/java/software/amazon/awssdk/utils/cache/CachedSupplier.java +++ b/utils/src/main/java/software/amazon/awssdk/utils/cache/CachedSupplier.java @@ -313,19 +313,26 @@ private RefreshResult handleFetchFailure(RuntimeException e) { if (cacheInvalidatingPredicate != null && cacheInvalidatingPredicate.test(e)) { throw e; } - // During prefetch window failure: extend prefetchTime to suppress further attempts + // During prefetch window failure: extend prefetchTime to suppress further attempts. + // Preserve existing staleTime if it is later than the new prefetch time. long backoffSeconds = STATIC_STABILITY_BACKOFF_MIN.getSeconds() + jitterRandom.nextInt( (int) (STATIC_STABILITY_BACKOFF_MAX.getSeconds() - STATIC_STABILITY_BACKOFF_MIN.getSeconds() + 1)); Instant extendedPrefetchTime = now.plusSeconds(backoffSeconds); + // Do not move stale time closer — keep the existing stale time if it's later than the extended prefetch time + Instant currentStaleTime = currentCachedValue.staleTime(); + Instant newStaleTime = (currentStaleTime != null && currentStaleTime.isAfter(extendedPrefetchTime)) + ? currentStaleTime + : extendedPrefetchTime; + log.warn(() -> "(" + cachedValueName + ") Credential refresh failed: " + e.getMessage() + ". Extending cached credential expiration. A refresh of these credentials" + " will be attempted again after " + backoffSeconds + " seconds.", e); return currentCachedValue.toBuilder() - .staleTime(extendedPrefetchTime) + .staleTime(newStaleTime) .prefetchTime(extendedPrefetchTime) .build(); } diff --git a/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java b/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java new file mode 100644 index 000000000000..ac91940504d8 --- /dev/null +++ b/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java @@ -0,0 +1,102 @@ +/* + * Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"). + * You may not use this file except in compliance with the License. + * A copy of the License is located at + * + * http://aws.amazon.com/apache2.0 + * + * or in the "license" file accompanying this file. This file is distributed + * on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either + * express or implied. See the License for the specific language governing + * permissions and limitations under the License. + */ + +package software.amazon.awssdk.utils.cache; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.Duration; +import java.time.Instant; +import org.junit.jupiter.api.Test; + +/** + * Tests for {@link CacheRefreshUtils}. + */ +public class CacheRefreshUtilsTest { + + private static final Instant NOW = Instant.parse("2024-01-01T00:00:00Z"); + + @Test + public void remainingLifetimeUnder20Minutes_returns5MinuteWindow() { + // 19 minutes remaining + Instant expiration = NOW.plus(Duration.ofMinutes(19)); + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + public void remainingLifetimeExactly0_returns5MinuteWindow() { + // 0 minutes remaining (already expired) + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(NOW, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + public void remainingLifetime5Minutes_returns5MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofMinutes(5)); + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + public void remainingLifetimeExactly20Minutes_returns15MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofMinutes(20)); + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(15)); + } + + @Test + public void remainingLifetime45Minutes_returns15MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofMinutes(45)); + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(15)); + } + + @Test + public void remainingLifetime89Minutes_returns15MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofMinutes(89)); + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(15)); + } + + @Test + public void remainingLifetimeExactly90Minutes_returns60MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofMinutes(90)); + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(60)); + } + + @Test + public void remainingLifetime6Hours_returns60MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofHours(6)); + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(60)); + } + + @Test + public void remainingLifetime12Hours_returns60MinuteWindow() { + Instant expiration = NOW.plus(Duration.ofHours(12)); + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(60)); + } + + @Test + public void remainingLifetimeNegative_returns5MinuteWindow() { + // Expiration is in the past + Instant expiration = NOW.minus(Duration.ofMinutes(5)); + Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(5)); + } +} diff --git a/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierTest.java b/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierTest.java index 5d0c9ec4381f..60a55265fba6 100644 --- a/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierTest.java +++ b/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierTest.java @@ -509,6 +509,49 @@ public void allowMode_prefetchWindowFailure_extendsPrefetchTime() { } } + @Test + public void allowMode_prefetchWindowFailure_preservesStaleTime() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + // Initial successful fetch: stale at +3600s (1 hour), prefetch at +60s + Instant originalStaleTime = now.plusSeconds(3600); + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(originalStaleTime) + .prefetchTime(now.plusSeconds(60)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past prefetch time but well before stale time + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("service unavailable")); + + // Trigger failure during prefetch window + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Verify the stale time was preserved (NOT moved to the backoff time). + // The original stale time (now + 3600s) should still be in effect. + // Advance to a time just before original stale time — should NOT be stale. + // The extended prefetchTime was ~now+61+[300,600] = [361,661] from epoch. + // At time 3599, we are past the extended prefetchTime, so a prefetch is triggered. + clock.time = originalStaleTime.minusSeconds(1); + supplier.set(RefreshResult.builder("refreshed-creds") + .staleTime(Instant.MAX) + .prefetchTime(Instant.MAX) + .build()); + // Since stale time is preserved at originalStaleTime (3600), we are NOT stale at 3599. + // The prefetch backoff has long elapsed, so a prefetch refresh will succeed. + assertThat(cachedSupplier.get()).isEqualTo("refreshed-creds"); + } + } + @Test public void allowMode_prefetchWindowFailure_cacheInvalidatingError_isRethrown() { AdjustableClock clock = new AdjustableClock(); From 1d2984940e0ff9900f7093b5376d256f706927de Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Thu, 2 Jul 2026 08:57:29 -0700 Subject: [PATCH 2/2] Update approach to dynamic prefetch window --- .../ContainerCredentialsProvider.java | 15 +- .../InstanceProfileCredentialsProvider.java | 15 +- .../ProcessCredentialsProvider.java | 16 +-- .../signin/auth/LoginCredentialsProvider.java | 58 ++++---- .../auth/LoginCredentialsProviderTest.java | 129 ++++++++++++++++++ .../sso/auth/SsoCredentialsProvider.java | 15 +- .../sso/auth/SsoCredentialsProviderTest.java | 2 +- .../sts/auth/StsCredentialsProvider.java | 15 +- .../auth/StsCredentialsProviderTestBase.java | 2 +- ...ebIdentityTokenCredentialProviderTest.java | 2 +- .../awssdk/utils/cache/CacheRefreshUtils.java | 13 +- .../utils/cache/CacheRefreshUtilsTest.java | 37 +++-- 12 files changed, 234 insertions(+), 85 deletions(-) diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java index e4f4a7a8d4bc..94744da5da6c 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java @@ -88,7 +88,6 @@ public final class ContainerCredentialsProvider private static final List VALID_LOOP_BACK_IPV6 = Arrays.asList(EKS_CONTAINER_HOST_IPV6); private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1); - private static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5); private final String endpoint; private final HttpCredentialsLoader httpCredentialsLoader; @@ -101,7 +100,6 @@ public final class ContainerCredentialsProvider private final String providerName; private final Duration staleTime; private final Duration prefetchTime; - private final boolean prefetchTimeExplicitlySet; /** * @see #builder() @@ -116,10 +114,11 @@ private ContainerCredentialsProvider(BuilderImpl builder) { : builder.sourceChain + "," + PROVIDER_NAME; this.httpCredentialsLoader = HttpCredentialsLoader.create(this.providerName); this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); - this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); - this.prefetchTimeExplicitlySet = builder.prefetchTime != null; - Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, - "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } if (Boolean.TRUE.equals(builder.asyncCredentialUpdateEnabled)) { Validate.paramNotBlank(builder.asyncThreadName, "asyncThreadName"); @@ -177,9 +176,7 @@ private Instant prefetchTime(Instant expiration) { } Instant now = Instant.now(); - Duration effectivePrefetchWindow = prefetchTimeExplicitlySet - ? prefetchTime - : CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, now); + Duration effectivePrefetchWindow = CacheRefreshUtils.computePrefetchWindow(expiration, prefetchTime, now); return expiration.minus(effectivePrefetchWindow); } diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java index eb2ef26c97d9..713bc6cc1cf1 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/InstanceProfileCredentialsProvider.java @@ -95,8 +95,6 @@ public final class InstanceProfileCredentialsProvider private final Duration prefetchTime; - private final boolean prefetchTimeExplicitlySet; - private final String sourceChain; private final String providerName; @@ -125,10 +123,11 @@ private InstanceProfileCredentialsProvider(BuilderImpl builder) { .build(); this.staleTime = Validate.getOrDefault(builder.staleTime, () -> Duration.ofMinutes(1)); - this.prefetchTime = Validate.getOrDefault(builder.prefetchTime, () -> Duration.ofMinutes(5)); - this.prefetchTimeExplicitlySet = builder.prefetchTime != null; - Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, - "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } if (Boolean.TRUE.equals(builder.asyncCredentialUpdateEnabled)) { Validate.paramNotBlank(builder.asyncThreadName, "asyncThreadName"); @@ -213,9 +212,7 @@ private Instant prefetchTime(Instant expiration) { } // Use dynamic window when user has not explicitly configured prefetchTime - Duration effectivePrefetchWindow = prefetchTimeExplicitlySet - ? prefetchTime - : CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, now); + Duration effectivePrefetchWindow = CacheRefreshUtils.computePrefetchWindow(expiration, prefetchTime, now); // If remaining lifetime < the advisory window, refresh immediately. if (timeUntilExpiration.compareTo(effectivePrefetchWindow) < 0) { diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java index 00c85c93197b..17be7f789d8f 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProvider.java @@ -86,7 +86,6 @@ public final class ProcessCredentialsProvider .removeErrorLocations(true) .build(); private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1); - private static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5); private final List executableCommand; private final long processOutputLimit; @@ -104,7 +103,6 @@ public final class ProcessCredentialsProvider private final String providerName; private final Duration staleTime; private final Duration prefetchTime; - private final boolean prefetchTimeExplicitlySet; /** * @see #builder() @@ -121,10 +119,11 @@ private ProcessCredentialsProvider(Builder builder) { ? PROVIDER_NAME : builder.sourceChain + "," + PROVIDER_NAME; this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); - this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); - this.prefetchTimeExplicitlySet = builder.prefetchTime != null; - Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, - "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } CachedSupplier.Builder cacheBuilder = CachedSupplier.builder(this::refreshCredentials) .cachedValueName(toString()); @@ -197,11 +196,8 @@ private Instant prefetchTime(Instant expiration) { if (expiration == null || expiration.equals(Instant.MAX)) { return Instant.MAX; } - if (prefetchTimeExplicitlySet) { - return expiration.minus(prefetchTime); - } Instant now = Instant.now(); - Duration dynamicWindow = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, now); + Duration dynamicWindow = CacheRefreshUtils.computePrefetchWindow(expiration, prefetchTime, now); return expiration.minus(dynamicWindow); } diff --git a/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java b/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java index 97260856b2b2..be2b61486860 100644 --- a/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java +++ b/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java @@ -79,7 +79,6 @@ public final class LoginCredentialsProvider implements private static final String PROVIDER_NAME = BusinessMetricFeatureId.CREDENTIALS_LOGIN.value(); private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1); - private static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5); private static final Path DEFAULT_TOKEN_LOCATION = Paths.get(userHomeDirectory(), ".aws", "login", "cache"); private static final String ASYNC_THREAD_NAME = "sdk-login-credentials-provider"; @@ -91,7 +90,6 @@ public final class LoginCredentialsProvider implements private final SigninClient signinClient; private final Duration staleTime; private final Duration prefetchTime; - private final boolean prefetchTimeExplicitlySet; private final Path tokenCacheLocation; private final CachedSupplier credentialCache; @@ -108,10 +106,11 @@ private LoginCredentialsProvider(BuilderImpl builder) { this.loginSession = paramNotBlank(builder.loginSession, "LoginSession"); this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); - this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); - this.prefetchTimeExplicitlySet = builder.prefetchTime != null; - Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, - "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } this.sourceChain = builder.sourceChain; this.providerName = StringUtils.isEmpty(builder.sourceChain) @@ -139,35 +138,31 @@ private LoginCredentialsProvider(BuilderImpl builder) { } /** - * Update the expiring session SSO credentials by calling SSO. Invoked by {@link CachedSupplier} when the credentials are - * close to expiring. + * Update the expiring session Login credentials by calling the Signin Service. Invoked by {@link CachedSupplier} when the + * credentials are close to expiring. */ private RefreshResult updateSigninCredentials() { // always re-load token from the disk in case it has been updated elsewhere LoginAccessToken tokenFromDisc = onDiskTokenManager.loadToken().orElseThrow( - () -> SdkClientException.create("Token cache file for login_session `" + loginSession + "` not found. " + () -> new InvalidTokenException("Token cache file for login_session `" + loginSession + "` not found. " + "You must re-authenticate.")); Instant currentExpirationTime = tokenFromDisc.getAccessToken().expirationTime().orElseThrow( - () -> SdkClientException.create("Invalid token expiration time. You must re-authenticate.") + () -> new InvalidTokenException("Invalid token expiration time. You must re-authenticate.") ); + Duration effectivePrefetch = CacheRefreshUtils.computePrefetchWindow(currentExpirationTime, prefetchTime, Instant.now()); if (shouldNotRefresh(currentExpirationTime, staleTime) - && shouldNotRefresh(currentExpirationTime, prefetchTime)) { + && shouldNotRefresh(currentExpirationTime, effectivePrefetch)) { log.debug(() -> "Using access token from disk, current expiration time is : " + currentExpirationTime); AwsCredentials credentials = tokenFromDisc.getAccessToken() .toBuilder() .providerName(this.providerName) .build(); - Instant now = Instant.now(); - Duration effectivePrefetchWindow = prefetchTimeExplicitlySet - ? prefetchTime - : CacheRefreshUtils.computeDynamicPrefetchWindow(currentExpirationTime, now); - return RefreshResult.builder(credentials) .staleTime(currentExpirationTime.minus(staleTime)) - .prefetchTime(currentExpirationTime.minus(effectivePrefetchWindow)) + .prefetchTime(currentExpirationTime.minus(effectivePrefetch)) .build(); } @@ -211,9 +206,8 @@ private RefreshResult refreshFromSigninService(LoginAccessToken return RefreshResult.builder((AwsCredentials) updatedCredentials) .staleTime(newExpiration.minus(staleTime)) - .prefetchTime(newExpiration.minus(prefetchTimeExplicitlySet - ? prefetchTime - : CacheRefreshUtils.computeDynamicPrefetchWindow(newExpiration, Instant.now()))) + .prefetchTime(newExpiration.minus( + CacheRefreshUtils.computePrefetchWindow(newExpiration, prefetchTime, Instant.now()))) .build(); } catch (AccessDeniedException accessDeniedException) { if (accessDeniedException.error() == null) { @@ -242,11 +236,18 @@ private RefreshResult refreshFromSigninService(LoginAccessToken /** * Determines whether a given exception represents a non-recoverable refresh failure that should bypass - * static stability. For Login, this is an {@link AccessDeniedException} with error code - * {@link OAuth2ErrorCode#TOKEN_EXPIRED}, {@link OAuth2ErrorCode#USER_CREDENTIALS_CHANGED}, - * or {@link OAuth2ErrorCode#INSUFFICIENT_PERMISSIONS}. + * static stability. For Login, this includes: + *
    + *
  • Missing or invalid token cache on disk (requires re-authentication)
  • + *
  • An {@link AccessDeniedException} with error code {@link OAuth2ErrorCode#TOKEN_EXPIRED}, + * {@link OAuth2ErrorCode#USER_CREDENTIALS_CHANGED}, or {@link OAuth2ErrorCode#INSUFFICIENT_PERMISSIONS}
  • + *
*/ private static boolean isCacheInvalidating(RuntimeException e) { + if (e instanceof InvalidTokenException) { + return true; + } + AccessDeniedException ade = extractAccessDeniedException(e); if (ade == null) { return false; @@ -399,6 +400,17 @@ public interface Builder extends CopyableBuilder p.toString().endsWith(".json")) + .forEach(p -> { + try { + Files.delete(p); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + + // Second call: the cached value is stale (handleFetchedSuccess extended it with jitter, but + // we wait for it to expire). Since the stale time was extended 1-10 minutes into the future, + // we instead rely on the prefetch window triggering a synchronous OneCallerBlocks refresh. + // With OneCallerBlocks, the refresh happens inline and the InvalidTokenException propagates. + // Give a brief pause and then call again - the prefetch time should already be in the past + // since the credentials expired in 30s and handleFetchedSuccess sets prefetch = stale time. + // Actually, with the extended stale time, the value enters prefetch window immediately. + // With OneCallerBlocks, the failing refresh throws through to the caller. + SdkClientException e = assertThrows(SdkClientException.class, + () -> syncProvider.resolveCredentials()); + assertTrue(e.getMessage().contains("not found")); + syncProvider.close(); + } + + @Test + public void resolveCredentials_tokenMalformedAfterSuccessfulCache_staticStabilityReturnsCachedCredentials() + throws Exception { + // First: store token with expired credentials so it triggers refresh from service + AwsSessionCredentials creds = buildCredentials(Instant.now().minusSeconds(600)); + LoginAccessToken token = buildAccessToken(creds); + tokenManager.storeToken(token); + + // First response: successful refresh with short-lived credentials (expires in 30s) + String shortLivedJsonBody = + "{\"accessToken\":" + + "{\"accessKeyId\":\"new-akid\"," + + "\"secretAccessKey\":\"new-skid\"," + + "\"sessionToken\":\"new-session-token\"}," + + "\"tokenType\":\"aws_sigv4\"," + + "\"expiresIn\":30," + + "\"refreshToken\":\"new-refresh-token\"}"; + + HttpExecuteResponse successResponse = HttpExecuteResponse + .builder() + .response(SdkHttpResponse.builder().statusCode(200).build()) + .responseBody(AbortableInputStream.create( + new ByteArrayInputStream(shortLivedJsonBody.getBytes(StandardCharsets.UTF_8)))) + .build(); + + mockHttpClient.stubResponses(successResponse); + + // First call: succeeds and populates the CachedSupplier cache + AwsCredentials firstResolve = loginCredentialsProvider.resolveCredentials(); + assertEquals("new-akid", firstResolve.accessKeyId()); + + // Now overwrite the token file with one that is missing the expiresAt field. + // This simulates a corrupted or malformed token file on disk. + String malformedTokenJson = + "{\"accessToken\":" + + "{\"accessKeyId\":\"akid\"," + + "\"secretAccessKey\":\"skid\"," + + "\"sessionToken\":\"sessionToken\"," + + "\"accountId\":\"123456789012\"}," + + "\"clientId\":\"client-123\"," + + "\"dpopKey\":\"" + VALID_TEST_PEM.replace("\n", "\\n") + "\"," + + "\"refreshToken\":\"refresh-token\"," + + "\"tokenType\":\"aws_sigv4\"," + + "\"identityToken\":\"id-token\"}"; + Files.list(tempDir) + .filter(p -> p.toString().endsWith(".json")) + .forEach(p -> { + try { + Files.write(p, malformedTokenJson.getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + + // Second call: the cached value is stale, CachedSupplier refreshes, but token has no expiresAt. + // OnDiskTokenManager throws a plain SdkClientException (not InvalidTokenException), so static + // stability kicks in and returns the cached credentials rather than throwing. + AwsCredentials secondResolve = loginCredentialsProvider.resolveCredentials(); + assertEquals("new-akid", secondResolve.accessKeyId()); + assertEquals("new-skid", secondResolve.secretAccessKey()); + } + private static void verifyResolvedCredentialsAreUpdated(AwsCredentials resolvedCredentials) { assertEquals("new-akid", resolvedCredentials.accessKeyId()); assertEquals("new-skid", resolvedCredentials.secretAccessKey()); diff --git a/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProvider.java b/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProvider.java index 82b64bb4e7a4..d81ae3dba549 100644 --- a/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProvider.java +++ b/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProvider.java @@ -60,7 +60,6 @@ public final class SsoCredentialsProvider implements AwsCredentialsProvider, Sdk private static final String PROVIDER_NAME = BusinessMetricFeatureId.CREDENTIALS_SSO.value(); private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1); - private static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5); private static final String ASYNC_THREAD_NAME = "sdk-sso-credentials-provider"; @@ -71,7 +70,6 @@ public final class SsoCredentialsProvider implements AwsCredentialsProvider, Sdk private final SsoClient ssoClient; private final Duration staleTime; private final Duration prefetchTime; - private final boolean prefetchTimeExplicitlySet; private final CachedSupplier credentialCache; @@ -85,10 +83,11 @@ private SsoCredentialsProvider(BuilderImpl builder) { this.getRoleCredentialsRequestSupplier = builder.getRoleCredentialsRequestSupplier; this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); - this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); - this.prefetchTimeExplicitlySet = builder.prefetchTime != null; - isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, - "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } this.sourceChain = builder.sourceChain; this.providerName = StringUtils.isEmpty(builder.sourceChain) @@ -118,9 +117,7 @@ private RefreshResult updateSsoCredentials() { Instant actualTokenExpiration = credentials.sessionCredentialsExpiration(); Instant now = Instant.now(); - Duration effectivePrefetchWindow = prefetchTimeExplicitlySet - ? prefetchTime - : CacheRefreshUtils.computeDynamicPrefetchWindow(actualTokenExpiration, now); + Duration effectivePrefetchWindow = CacheRefreshUtils.computePrefetchWindow(actualTokenExpiration, prefetchTime, now); return RefreshResult.builder(credentials) .staleTime(actualTokenExpiration.minus(staleTime)) diff --git a/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProviderTest.java b/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProviderTest.java index 73f71269b17e..fc0668a10811 100644 --- a/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProviderTest.java +++ b/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProviderTest.java @@ -248,7 +248,7 @@ private void callClientWithCredentialsProvider(Instant credentialsExpirationDate assertThat(credentialsProvider.prefetchTime()).as("prefetch time").isEqualTo(Duration.ofMinutes(4)); } else { assertThat(credentialsProvider.staleTime()).as("stale time").isEqualTo(Duration.ofMinutes(1)); - assertThat(credentialsProvider.prefetchTime()).as("prefetch time").isEqualTo(Duration.ofMinutes(5)); + assertThat(credentialsProvider.prefetchTime()).as("prefetch time").isNull(); } for (int i = 0; i < numTimesInvokeCredentialsProvider; ++i) { diff --git a/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProvider.java b/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProvider.java index a29e99b08a1a..431ffbac43bb 100644 --- a/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProvider.java +++ b/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProvider.java @@ -54,7 +54,6 @@ public abstract class StsCredentialsProvider implements AwsCredentialsProvider, private static final Logger log = Logger.loggerFor(StsCredentialsProvider.class); private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1); - private static final Duration DEFAULT_PREFETCH_TIME = Duration.ofMinutes(5); /** * The STS client that should be used for periodically updating the session credentials. @@ -68,17 +67,17 @@ public abstract class StsCredentialsProvider implements AwsCredentialsProvider, private final Duration staleTime; private final Duration prefetchTime; - private final boolean prefetchTimeExplicitlySet; private final Boolean asyncCredentialUpdateEnabled; StsCredentialsProvider(BaseBuilder builder, String asyncThreadName) { this.stsClient = Validate.notNull(builder.stsClient, "STS client must not be null."); this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME); - this.prefetchTime = Optional.ofNullable(builder.prefetchTime).orElse(DEFAULT_PREFETCH_TIME); - this.prefetchTimeExplicitlySet = builder.prefetchTime != null; - Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, - "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + this.prefetchTime = builder.prefetchTime; + if (this.prefetchTime != null) { + Validate.isTrue(this.staleTime.compareTo(this.prefetchTime) <= 0, + "staleTime (%s) must be less than or equal to prefetchTime (%s).", this.staleTime, this.prefetchTime); + } this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled; CachedSupplier.Builder cacheBuilder = @@ -102,9 +101,7 @@ private RefreshResult updateSessionCredentials() { .orElseThrow(() -> new IllegalStateException("Sourced credentials have no expiration value")); Instant now = Instant.now(); - Duration effectivePrefetchWindow = prefetchTimeExplicitlySet - ? prefetchTime - : CacheRefreshUtils.computeDynamicPrefetchWindow(actualTokenExpiration, now); + Duration effectivePrefetchWindow = CacheRefreshUtils.computePrefetchWindow(actualTokenExpiration, prefetchTime, now); return RefreshResult.builder(credentials) .staleTime(actualTokenExpiration.minus(staleTime)) diff --git a/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProviderTestBase.java b/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProviderTestBase.java index caffab32a9aa..10014ddbf7c9 100644 --- a/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProviderTestBase.java +++ b/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsCredentialsProviderTestBase.java @@ -181,7 +181,7 @@ public void callClientWithCredentialsProvider(Instant credentialsExpirationDate, } else { //validate that the default values are used assertThat(credentialsProvider.staleTime()).as("stale time").isEqualTo(Duration.ofMinutes(1)); - assertThat(credentialsProvider.prefetchTime()).as("prefetch time").isEqualTo(Duration.ofMinutes(5)); + assertThat(credentialsProvider.prefetchTime()).as("prefetch time").isNull(); } for (int i = 0; i < numTimesInvokeCredentialsProvider; ++i) { diff --git a/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenCredentialProviderTest.java b/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenCredentialProviderTest.java index 058eff163eff..82673b870363 100644 --- a/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenCredentialProviderTest.java +++ b/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenCredentialProviderTest.java @@ -183,7 +183,7 @@ void defaultTiming_usesStandardValues() { .build(); try { - assertThat(provider.prefetchTime()).isEqualTo(Duration.ofMinutes(5)); + assertThat(provider.prefetchTime()).isNull(); assertThat(provider.staleTime()).isEqualTo(Duration.ofMinutes(1)); } finally { provider.close(); diff --git a/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java b/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java index 4a7b28d7e84d..998648a6ca02 100644 --- a/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java +++ b/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java @@ -36,10 +36,12 @@ private CacheRefreshUtils() { } /** - * Compute the dynamic advisory refresh window (prefetch time) based on the credential's remaining lifetime. - * The window scales with the credential's time-to-expiry so that longer-lived credentials begin refreshing + * Compute the advisory refresh window (prefetch time) for a credential. If {@code prefetchTime} is non-null + * (i.e., explicitly configured by the user), it is returned directly. Otherwise, the window is computed + * dynamically based on the credential's remaining lifetime so that longer-lived credentials begin refreshing * earlier and shorter-lived credentials do not attempt a refresh the moment they are issued. * + *

Dynamic window selection:

*
    *
  • remaining lifetime < 20 minutes → 5 minute window
  • *
  • 20 minutes ≤ remaining lifetime < 90 minutes → 15 minute window
  • @@ -47,10 +49,15 @@ private CacheRefreshUtils() { *
* * @param expiration the credential's expiration time + * @param prefetchTime the explicitly configured prefetch window, or {@code null} to compute dynamically * @param now the current time * @return the Duration to use as the advisory refresh window */ - public static Duration computeDynamicPrefetchWindow(Instant expiration, Instant now) { + public static Duration computePrefetchWindow(Instant expiration, Duration prefetchTime, Instant now) { + if (prefetchTime != null) { + return prefetchTime; + } + Duration remainingLifetime = Duration.between(now, expiration); long remainingMinutes = remainingLifetime.toMinutes(); diff --git a/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java b/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java index ac91940504d8..adb6ae4bb98e 100644 --- a/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java +++ b/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java @@ -32,63 +32,63 @@ public class CacheRefreshUtilsTest { public void remainingLifetimeUnder20Minutes_returns5MinuteWindow() { // 19 minutes remaining Instant expiration = NOW.plus(Duration.ofMinutes(19)); - Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); assertThat(window).isEqualTo(Duration.ofMinutes(5)); } @Test public void remainingLifetimeExactly0_returns5MinuteWindow() { // 0 minutes remaining (already expired) - Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(NOW, NOW); + Duration window = CacheRefreshUtils.computePrefetchWindow(NOW, null, NOW); assertThat(window).isEqualTo(Duration.ofMinutes(5)); } @Test public void remainingLifetime5Minutes_returns5MinuteWindow() { Instant expiration = NOW.plus(Duration.ofMinutes(5)); - Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); assertThat(window).isEqualTo(Duration.ofMinutes(5)); } @Test public void remainingLifetimeExactly20Minutes_returns15MinuteWindow() { Instant expiration = NOW.plus(Duration.ofMinutes(20)); - Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); assertThat(window).isEqualTo(Duration.ofMinutes(15)); } @Test public void remainingLifetime45Minutes_returns15MinuteWindow() { Instant expiration = NOW.plus(Duration.ofMinutes(45)); - Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); assertThat(window).isEqualTo(Duration.ofMinutes(15)); } @Test public void remainingLifetime89Minutes_returns15MinuteWindow() { Instant expiration = NOW.plus(Duration.ofMinutes(89)); - Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); assertThat(window).isEqualTo(Duration.ofMinutes(15)); } @Test public void remainingLifetimeExactly90Minutes_returns60MinuteWindow() { Instant expiration = NOW.plus(Duration.ofMinutes(90)); - Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); assertThat(window).isEqualTo(Duration.ofMinutes(60)); } @Test public void remainingLifetime6Hours_returns60MinuteWindow() { Instant expiration = NOW.plus(Duration.ofHours(6)); - Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); assertThat(window).isEqualTo(Duration.ofMinutes(60)); } @Test public void remainingLifetime12Hours_returns60MinuteWindow() { Instant expiration = NOW.plus(Duration.ofHours(12)); - Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); assertThat(window).isEqualTo(Duration.ofMinutes(60)); } @@ -96,7 +96,24 @@ public void remainingLifetime12Hours_returns60MinuteWindow() { public void remainingLifetimeNegative_returns5MinuteWindow() { // Expiration is in the past Instant expiration = NOW.minus(Duration.ofMinutes(5)); - Duration window = CacheRefreshUtils.computeDynamicPrefetchWindow(expiration, NOW); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, null, NOW); assertThat(window).isEqualTo(Duration.ofMinutes(5)); } + + @Test + public void explicitPrefetchTime_returnsExplicitValue() { + Instant expiration = NOW.plus(Duration.ofHours(6)); + Duration explicitPrefetch = Duration.ofMinutes(30); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, explicitPrefetch, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(30)); + } + + @Test + public void explicitPrefetchTime_ignoresRemainingLifetime() { + // Even with short remaining lifetime, explicit value is used + Instant expiration = NOW.plus(Duration.ofMinutes(10)); + Duration explicitPrefetch = Duration.ofMinutes(60); + Duration window = CacheRefreshUtils.computePrefetchWindow(expiration, explicitPrefetch, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(60)); + } }