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..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 @@ -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; @@ -87,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; @@ -114,9 +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); - 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"); @@ -172,7 +174,11 @@ 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 = CacheRefreshUtils.computePrefetchWindow(expiration, prefetchTime, now); + + return expiration.minus(effectivePrefetchWindow); } @Override @@ -356,7 +362,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..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 @@ -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; @@ -122,9 +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)); - 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"); @@ -208,12 +211,14 @@ 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 = CacheRefreshUtils.computePrefetchWindow(expiration, prefetchTime, 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 +396,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..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 @@ -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; @@ -85,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; @@ -119,9 +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); - 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()); @@ -194,7 +196,9 @@ private Instant prefetchTime(Instant expiration) { if (expiration == null || expiration.equals(Instant.MAX)) { return Instant.MAX; } - return expiration.minus(prefetchTime); + Instant now = Instant.now(); + Duration dynamicWindow = CacheRefreshUtils.computePrefetchWindow(expiration, prefetchTime, now); + return expiration.minus(dynamicWindow); } /** @@ -382,7 +386,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..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 @@ -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; @@ -78,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"; @@ -106,9 +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); - 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) @@ -136,21 +138,22 @@ 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() @@ -159,7 +162,7 @@ && shouldNotRefresh(currentExpirationTime, prefetchTime)) { return RefreshResult.builder(credentials) .staleTime(currentExpirationTime.minus(staleTime)) - .prefetchTime(currentExpirationTime.minus(prefetchTime)) + .prefetchTime(currentExpirationTime.minus(effectivePrefetch)) .build(); } @@ -203,7 +206,8 @@ private RefreshResult refreshFromSigninService(LoginAccessToken return RefreshResult.builder((AwsCredentials) updatedCredentials) .staleTime(newExpiration.minus(staleTime)) - .prefetchTime(newExpiration.minus(prefetchTime)) + .prefetchTime(newExpiration.minus( + CacheRefreshUtils.computePrefetchWindow(newExpiration, prefetchTime, Instant.now()))) .build(); } catch (AccessDeniedException accessDeniedException) { if (accessDeniedException.error() == null) { @@ -232,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; @@ -355,7 +366,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 */ @@ -386,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 eeefefb9983c..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 @@ -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; @@ -59,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"; @@ -83,9 +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); - 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) @@ -114,9 +116,12 @@ private RefreshResult updateSsoCredentials() { SessionCredentialsHolder credentials = getUpdatedCredentials(ssoClient); Instant actualTokenExpiration = credentials.sessionCredentialsExpiration(); + Instant now = Instant.now(); + Duration effectivePrefetchWindow = CacheRefreshUtils.computePrefetchWindow(actualTokenExpiration, prefetchTime, now); + return RefreshResult.builder(credentials) .staleTime(actualTokenExpiration.minus(staleTime)) - .prefetchTime(actualTokenExpiration.minus(prefetchTime)) + .prefetchTime(actualTokenExpiration.minus(effectivePrefetchWindow)) .build(); } @@ -225,7 +230,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/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 c878bd987970..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 @@ -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; @@ -53,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. @@ -73,9 +73,11 @@ public abstract class StsCredentialsProvider implements AwsCredentialsProvider, 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); - 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 = @@ -98,9 +100,12 @@ private RefreshResult updateSessionCredentials() { credentials.expirationTime() .orElseThrow(() -> new IllegalStateException("Sourced credentials have no expiration value")); + Instant now = Instant.now(); + Duration effectivePrefetchWindow = CacheRefreshUtils.computePrefetchWindow(actualTokenExpiration, prefetchTime, now); + return RefreshResult.builder(credentials) .staleTime(actualTokenExpiration.minus(staleTime)) - .prefetchTime(actualTokenExpiration.minus(prefetchTime)) + .prefetchTime(actualTokenExpiration.minus(effectivePrefetchWindow)) .build(); } @@ -234,7 +239,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/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 new file mode 100644 index 000000000000..998648a6ca02 --- /dev/null +++ b/utils/src/main/java/software/amazon/awssdk/utils/cache/CacheRefreshUtils.java @@ -0,0 +1,72 @@ +/* + * 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 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
  • + *
  • remaining lifetime ≥ 90 minutes → 60 minute window
  • + *
+ * + * @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 computePrefetchWindow(Instant expiration, Duration prefetchTime, Instant now) { + if (prefetchTime != null) { + return prefetchTime; + } + + 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..adb6ae4bb98e --- /dev/null +++ b/utils/src/test/java/software/amazon/awssdk/utils/cache/CacheRefreshUtilsTest.java @@ -0,0 +1,119 @@ +/* + * 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.computePrefetchWindow(expiration, null, NOW); + assertThat(window).isEqualTo(Duration.ofMinutes(5)); + } + + @Test + public void remainingLifetimeExactly0_returns5MinuteWindow() { + // 0 minutes remaining (already expired) + 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.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.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.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.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.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.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.computePrefetchWindow(expiration, null, 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.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)); + } +} 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();