From b0475534fbf3a9ed57296e51355861c1c929b20c Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Thu, 11 Jun 2026 14:27:05 -0700 Subject: [PATCH 1/6] [Credential Cache Pr 1/3] CachedSupplier ALLOW static stability with uniform backoff (#7028) --- .../awssdk/utils/cache/CachedSupplier.java | 112 ++++++++-- .../utils/cache/CachedSupplierTest.java | 197 ++++++++++++++++-- 2 files changed, 270 insertions(+), 39 deletions(-) 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 e8ecc4d741d1..38cd00033e25 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 @@ -23,9 +23,9 @@ import java.util.Random; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Predicate; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkProtectedApi; import software.amazon.awssdk.annotations.SdkTestInternalApi; @@ -54,6 +54,16 @@ public class CachedSupplier implements Supplier, SdkAutoCloseable { */ private static final Duration BLOCKING_REFRESH_MAX_WAIT = Duration.ofSeconds(5); + /** + * Minimum backoff duration when a refresh fails (inclusive). + */ + private static final Duration STATIC_STABILITY_BACKOFF_MIN = Duration.ofMinutes(5); + + /** + * Maximum backoff duration when a refresh fails (inclusive). + */ + private static final Duration STATIC_STABILITY_BACKOFF_MAX = Duration.ofMinutes(10); + /** * Used as a primitive form of rate limiting for the speed of our refreshes. This will make sure that the backing supplier has @@ -83,11 +93,6 @@ public class CachedSupplier implements Supplier, SdkAutoCloseable { */ private final Clock clock; - /** - * The number of consecutive failures encountered when updating a stale value. - */ - private final AtomicInteger consecutiveStaleRetrievalFailures = new AtomicInteger(0); - /** * The name to include with each log message, to differentiate caches. */ @@ -108,6 +113,12 @@ public class CachedSupplier implements Supplier, SdkAutoCloseable { */ private final Random jitterRandom = new Random(); + /** + * Predicate that determines whether an exception represents a non-recoverable refresh failure + * that should bypass static stability (i.e., be re-thrown immediately without extending expiration). + */ + private final Predicate cacheInvalidatingPredicate; + private CachedSupplier(Builder builder) { Validate.notNull(builder.supplier, "builder.supplier"); Validate.notNull(builder.jitterEnabled, "builder.jitterEnabled"); @@ -117,6 +128,7 @@ private CachedSupplier(Builder builder) { this.staleValueBehavior = Validate.notNull(builder.staleValueBehavior, "builder.staleValueBehavior"); this.clock = Validate.notNull(builder.clock, "builder.clock"); this.cachedValueName = Validate.notNull(builder.cachedValueName, "builder.cachedValueName"); + this.cacheInvalidatingPredicate = builder.cacheInvalidatingPredicate; } /** @@ -229,8 +241,6 @@ private void refreshCache() { * Perform necessary transformations of the successfully-fetched value based on the stale value behavior of this supplier. */ private RefreshResult handleFetchedSuccess(RefreshResult fetch) { - consecutiveStaleRetrievalFailures.set(0); - Instant now = clock.instant(); if (now.isBefore(fetch.staleTime())) { @@ -269,25 +279,57 @@ private RefreshResult handleFetchFailure(RuntimeException e) { Instant now = clock.instant(); if (!now.isBefore(currentCachedValue.staleTime())) { - int numFailures = consecutiveStaleRetrievalFailures.incrementAndGet(); - switch (staleValueBehavior) { case STRICT: throw e; case ALLOW: - Instant newStaleTime = jitterTime(now, Duration.ofMillis(1), maxStaleFailureJitter(numFailures)); - log.warn(() -> "(" + cachedValueName + ") Cached value expiration has been extended to " + - newStaleTime + " because calling the downstream service failed (consecutive failures: " + - numFailures + ").", e); + // Cache-invalidating errors bypass static stability + if (cacheInvalidatingPredicate != null && cacheInvalidatingPredicate.test(e)) { + throw e; + } + + // Uniform random backoff: 5-10 minutes + long backoffSeconds = STATIC_STABILITY_BACKOFF_MIN.getSeconds() + + jitterRandom.nextInt( + (int) (STATIC_STABILITY_BACKOFF_MAX.getSeconds() + - STATIC_STABILITY_BACKOFF_MIN.getSeconds() + 1)); + Instant extendedStaleTime = now.plusSeconds(backoffSeconds); + + 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(newStaleTime) + .staleTime(extendedStaleTime) + .prefetchTime(extendedStaleTime) .build(); default: throw new IllegalStateException("Unknown stale-value-behavior: " + staleValueBehavior); } } + // Not yet stale — we're in the prefetch window. Handle failure based on mode. + if (staleValueBehavior == StaleValueBehavior.ALLOW) { + if (cacheInvalidatingPredicate != null && cacheInvalidatingPredicate.test(e)) { + throw e; + } + // During prefetch window failure: extend prefetchTime to suppress further attempts + 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); + + 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) + .prefetchTime(extendedPrefetchTime) + .build(); + } + return currentCachedValue; } @@ -333,6 +375,12 @@ private Duration maxPrefetchJitter(RefreshResult result) { return timeBetweenPrefetchAndStale; } + private Instant jitterTime(Instant time, Duration jitterStart, Duration jitterEnd) { + long jitterRange = jitterEnd.minus(jitterStart).toMillis(); + long jitterAmount = Math.abs(jitterRandom.nextLong() % jitterRange); + return time.plus(jitterStart).plusMillis(jitterAmount); + } + private Duration maxStaleFailureJitter(int numFailures) { // prevent cycling back through low values if (numFailures > 63) { @@ -350,12 +398,6 @@ protected Duration maxStaleFailureJitterTest(int numFailures) { return maxStaleFailureJitter(numFailures); } - private Instant jitterTime(Instant time, Duration jitterStart, Duration jitterEnd) { - long jitterRange = jitterEnd.minus(jitterStart).toMillis(); - long jitterAmount = Math.abs(jitterRandom.nextLong() % jitterRange); - return time.plus(jitterStart).plusMillis(jitterAmount); - } - /** * Free any resources consumed by the prefetch strategy this supplier is using. */ @@ -374,6 +416,7 @@ public static final class Builder { private StaleValueBehavior staleValueBehavior = StaleValueBehavior.STRICT; private Clock clock = Clock.systemUTC(); private String cachedValueName = "unknown"; + private Predicate cacheInvalidatingPredicate; private Builder(Supplier> supplier) { this.supplier = supplier; @@ -413,6 +456,23 @@ public Builder cachedValueName(String cachedValueName) { return this; } + /** + * Configure a predicate that determines whether an exception represents a non-recoverable refresh failure + * that should bypass static stability. When the predicate returns {@code true} for a given exception, + * the exception will be re-thrown immediately without extending the cached value's expiration. + * + *

This is used for errors where the credential source has definitively indicated that the current + * authentication state is invalid and requires user intervention (e.g., expired SSO tokens, + * changed user credentials).

+ * + *

By default, no exceptions are considered cache-invalidating (all failures trigger static stability + * backoff when {@link StaleValueBehavior#ALLOW} is configured).

+ */ + public Builder cacheInvalidatingPredicate(Predicate cacheInvalidatingPredicate) { + this.cacheInvalidatingPredicate = cacheInvalidatingPredicate; + return this; + } + /** * Configure the clock used for this cached supplier. Configurable for testing. */ @@ -488,8 +548,14 @@ public enum StaleValueBehavior { STRICT, /** - * Allow stale values to be returned from the cache. Value retrieval will never fail, as long as the cache has - * succeeded when calling the underlying supplier at least once. + * Allow stale values to be returned from the cache with static stability semantics. On refresh failure, + * extends the stale time by a uniformly random backoff between 5 and 10 minutes (300-600 seconds). + * + *

If a {@link Builder#cacheInvalidatingPredicate(Predicate)} is configured and returns {@code true} + * for the exception, it is re-thrown immediately without extending the stale time.

+ * + *

Value retrieval will never fail as long as the cache has succeeded at least once, + * unless the error is cache-invalidating.

*/ ALLOW } 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 159e2d69b6e9..5d0c9ec4381f 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 @@ -364,25 +364,190 @@ public void throwIsHiddenIfValueIsStaleInAllowMode() throws InterruptedException } @Test - public void maxStaleFailureJitter_shouldNotReturnNegativeOrCycleLowValues() { - CachedSupplier supplier = CachedSupplier.builder(() -> RefreshResult.builder("v") - .staleTime(Instant.MAX) - .build()) - .build(); - - for (int i = 1; i <= 70; i++) { - Duration jitter = supplier.maxStaleFailureJitterTest(i); - assertThat(jitter) - .as("numFailures=%d: jitter must be positive", i) - .isPositive(); - - if (i > 64) { - assertThat(jitter) - .isEqualTo(Duration.ofSeconds(10)); + public void allowMode_returnsCachedValueOnNonCacheInvalidatingFailure() throws InterruptedException { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + Instant now = Instant.now(); + clock.time = now; + + // Initial successful fetch + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(now.plusSeconds(60)) + .prefetchTime(now.plusSeconds(30)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past stale time + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("service unavailable")); + + // Should return cached value instead of throwing + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + } + } + + @Test + public void allowMode_cacheInvalidatingError_isRethrown() throws InterruptedException { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .cacheInvalidatingPredicate( + e -> e instanceof CacheInvalidatingRuntimeException) + .clock(clock) + .jitterEnabled(false) + .build()) { + Instant now = Instant.now(); + clock.time = now; + + // Initial successful fetch + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(now.plusSeconds(60)) + .prefetchTime(now.plusSeconds(30)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past stale time and throw cache-invalidating error + clock.time = now.plusSeconds(61); + CacheInvalidatingRuntimeException invalidatingError = + new CacheInvalidatingRuntimeException("token expired"); + supplier.set(invalidatingError); + + // Should re-throw even though cached value exists + assertThatThrownBy(cachedSupplier::get).isEqualTo(invalidatingError); + } + } + + @Test + public void allowMode_backoffIsInExpectedRange() throws InterruptedException { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + + // Run multiple iterations to verify backoff range + for (int i = 0; i < 50; i++) { + 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; + + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(now.plusSeconds(60)) + .prefetchTime(now.plusSeconds(30)) + .build()); + cachedSupplier.get(); + + // Advance past stale time and trigger failure + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("service unavailable")); + cachedSupplier.get(); + + // Advance well past the extended time to test that the backoff was applied + // The extended stale time should be: now(61) + [300,600]s(backoff) + // So total offset from epoch: 61 + [300,600] = [361, 661] seconds from original now + Instant minExpectedStale = now.plusSeconds(61 + 300); + Instant maxExpectedStale = now.plusSeconds(61 + 600); + + // Advance just before the minimum backoff - should still return cached (not stale yet) + clock.time = minExpectedStale.minusSeconds(1); + supplier.set(RefreshResult.builder("new-creds") + .staleTime(Instant.MAX) + .prefetchTime(Instant.MAX) + .build()); + // Value not stale yet so should return cached + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past maximum possible backoff - must be stale now and will refresh + clock.time = maxExpectedStale.plusSeconds(1); + assertThat(cachedSupplier.get()).isEqualTo("new-creds"); } } + } - supplier.close(); + @Test + public void allowMode_prefetchWindowFailure_extendsPrefetchTime() { + 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 with prefetch in the future, stale much later + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(now.plusSeconds(3600)) + .prefetchTime(now.plusSeconds(60)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past prefetch time but before stale time + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("service unavailable")); + + // Should return cached value (not throw) and extend prefetch time + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Verify that a subsequent call shortly after does NOT attempt another refresh + // (because prefetchTime was extended) + clock.time = now.plusSeconds(62); + supplier.set(RefreshResult.builder("should-not-get-this") + .staleTime(Instant.MAX) + .prefetchTime(Instant.MAX) + .build()); + // The prefetchTime was extended far into the future, so this should still return cached + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + } + } + + @Test + public void allowMode_prefetchWindowFailure_cacheInvalidatingError_isRethrown() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .cacheInvalidatingPredicate( + e -> e instanceof CacheInvalidatingRuntimeException) + .clock(clock) + .jitterEnabled(false) + .build()) { + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + // Initial successful fetch with prefetch in the future, stale much later + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(now.plusSeconds(3600)) + .prefetchTime(now.plusSeconds(60)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past prefetch time but before stale time + clock.time = now.plusSeconds(61); + CacheInvalidatingRuntimeException invalidatingError = + new CacheInvalidatingRuntimeException("token expired"); + supplier.set(invalidatingError); + + // Should re-throw cache-invalidating error even in prefetch window + assertThatThrownBy(cachedSupplier::get).isEqualTo(invalidatingError); + } + } + + /** + * A RuntimeException that represents a cache-invalidating error for testing. + */ + private static class CacheInvalidatingRuntimeException extends RuntimeException { + CacheInvalidatingRuntimeException(String message) { + super(message); + } } @Test From 6b14305a00a9c8cdf09fade4f72421a222e94095 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Thu, 18 Jun 2026 10:32:37 -0700 Subject: [PATCH 2/6] [Credential Cache Pr 2/3] Enable static stability for STS, Container, SSO, and Login credential providers (#7031) --- .../ContainerCredentialsProvider.java | 3 + .../ContainerCredentialsProviderTest.java | 50 ++++++++ .../signin/auth/LoginCredentialsProvider.java | 44 +++++-- .../auth/LoginCredentialsProviderTest.java | 57 ++++++++- .../sso/auth/SsoCredentialsProvider.java | 8 +- .../sso/auth/SsoCredentialsProviderTest.java | 119 ++++++++++++++++++ .../sts/auth/StsCredentialsProvider.java | 3 +- .../auth/StsCredentialsProviderTestBase.java | 58 +++++++++ 8 files changed, 326 insertions(+), 16 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 5fefed5e8d65..44cfdda33ae2 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 @@ -16,6 +16,7 @@ package software.amazon.awssdk.auth.credentials; import static java.nio.charset.StandardCharsets.UTF_8; +import static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW; import java.io.IOException; import java.net.InetAddress; @@ -113,10 +114,12 @@ private ContainerCredentialsProvider(BuilderImpl builder) { this.credentialsCache = CachedSupplier.builder(this::refreshCredentials) .cachedValueName(toString()) .prefetchStrategy(new NonBlocking(builder.asyncThreadName)) + .staleValueBehavior(ALLOW) .build(); } else { this.credentialsCache = CachedSupplier.builder(this::refreshCredentials) .cachedValueName(toString()) + .staleValueBehavior(ALLOW) .build(); } } diff --git a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProviderTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProviderTest.java index 29955af439ca..028e21b77143 100644 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProviderTest.java +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProviderTest.java @@ -140,4 +140,54 @@ private String getSuccessfulBody() { "\"Token\":\"TOKEN_TOKEN_TOKEN\"," + "\"Expiration\":\"3000-05-03T04:55:54Z\"}"; } + + /** + * Tests that when the cache is stale and refresh fails, the provider returns cached credentials + * instead of throwing an exception (ALLOW behavior / static stability). + */ + @Test + public void testRefreshFailureReturnsCachedCredentials_whenCacheIsStale() { + // First call succeeds with credentials that are already expired (stale immediately on next get) + String alreadyExpiredBody = "{\"AccessKeyId\":\"ACCESS_KEY_ID\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," + + "\"Token\":\"TOKEN_TOKEN_TOKEN\"," + + "\"Expiration\":\"2020-01-01T00:00:00Z\"}"; + + stubFor(get(urlPathEqualTo(CREDENTIALS_PATH)) + .willReturn(aResponse() + .withStatus(200) + .withHeader("Content-Type", "application/json") + .withBody(alreadyExpiredBody))); + + // First call succeeds (initial fetch always succeeds even if credentials are expired) + AwsCredentials firstCredentials = credentialsProvider.resolveCredentials(); + assertThat(firstCredentials.accessKeyId()).isEqualTo(ACCESS_KEY_ID); + + // Now stub the endpoint to return a 500 error (simulating container metadata endpoint failure) + stubFor(get(urlPathEqualTo(CREDENTIALS_PATH)) + .willReturn(aResponse() + .withStatus(500) + .withBody("Internal Server Error"))); + + // Second call: cache is stale (expiration is in the past), refresh fails with 500, + // but ALLOW behavior should return the cached credentials + AwsCredentials secondCredentials = credentialsProvider.resolveCredentials(); + assertThat(secondCredentials.accessKeyId()).isEqualTo(ACCESS_KEY_ID); + assertThat(secondCredentials.secretAccessKey()).isEqualTo(SECRET_ACCESS_KEY); + } + + /** + * Tests that when no credentials are cached (initial fetch) and the endpoint fails, + * an exception is thrown. + */ + @Test + public void testInitialFetchFailure_throwsException() { + stubFor(get(urlPathEqualTo(CREDENTIALS_PATH)) + .willReturn(aResponse() + .withStatus(500) + .withBody("Internal Server Error"))); + + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(SdkClientException.class); + } } 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 95f8e51bb153..c2beadf7ff4f 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 @@ -18,6 +18,7 @@ import static software.amazon.awssdk.utils.UserHomeDirectoryUtils.userHomeDirectory; import static software.amazon.awssdk.utils.Validate.notNull; import static software.amazon.awssdk.utils.Validate.paramNotBlank; +import static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW; import java.nio.file.Path; import java.nio.file.Paths; @@ -43,6 +44,7 @@ import software.amazon.awssdk.services.signin.model.AccessDeniedException; import software.amazon.awssdk.services.signin.model.CreateOAuth2TokenRequest; import software.amazon.awssdk.services.signin.model.CreateOAuth2TokenResponse; +import software.amazon.awssdk.services.signin.model.OAuth2ErrorCode; import software.amazon.awssdk.utils.Logger; import software.amazon.awssdk.utils.SdkAutoCloseable; import software.amazon.awssdk.utils.StringUtils; @@ -120,7 +122,9 @@ private LoginCredentialsProvider(BuilderImpl builder) { this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled; CachedSupplier.Builder cacheBuilder = CachedSupplier.builder(this::updateSigninCredentials) - .cachedValueName(toString()); + .cachedValueName(toString()) + .staleValueBehavior(ALLOW) + .cacheInvalidatingPredicate(LoginCredentialsProvider::isCacheInvalidating); if (builder.asyncCredentialUpdateEnabled) { cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME)); } @@ -205,16 +209,12 @@ private RefreshResult refreshFromSigninService(LoginAccessToken switch (accessDeniedException.error()) { case TOKEN_EXPIRED: - throw SdkClientException.create( - "Your session has expired. Please reauthenticate.", - accessDeniedException); case USER_CREDENTIALS_CHANGED: - throw SdkClientException.create( - "Unable to refresh credentials because of a change in your password. " - + "Please reauthenticate with your new password.", - accessDeniedException - ); + // Let the original AccessDeniedException propagate — the cacheInvalidatingPredicate + // on CachedSupplier will identify it and bypass static stability. + throw accessDeniedException; case INSUFFICIENT_PERMISSIONS: + // Wrap with a helpful message, but still cache-invalidating — the predicate checks the cause. throw SdkClientException.create( "Unable to refresh credentials due to insufficient permissions. You may be missing permission " + "for the 'CreateOAuth2Token' action.", @@ -227,6 +227,32 @@ 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}. + */ + private static boolean isCacheInvalidating(RuntimeException e) { + AccessDeniedException ade = extractAccessDeniedException(e); + if (ade == null) { + return false; + } + return ade.error() == OAuth2ErrorCode.TOKEN_EXPIRED + || ade.error() == OAuth2ErrorCode.USER_CREDENTIALS_CHANGED + || ade.error() == OAuth2ErrorCode.INSUFFICIENT_PERMISSIONS; + } + + private static AccessDeniedException extractAccessDeniedException(RuntimeException e) { + if (e instanceof AccessDeniedException) { + return (AccessDeniedException) e; + } + if (e.getCause() instanceof AccessDeniedException) { + return (AccessDeniedException) e.getCause(); + } + return null; + } + /** * The amount of time, relative to session token expiration, that the cached credentials are considered stale and should no * longer be used. All threads will block until the value is updated. diff --git a/services/signin/src/test/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProviderTest.java b/services/signin/src/test/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProviderTest.java index 4431bf6f85be..76171a1aa59b 100644 --- a/services/signin/src/test/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProviderTest.java +++ b/services/signin/src/test/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProviderTest.java @@ -54,6 +54,7 @@ import software.amazon.awssdk.services.signin.internal.LoginAccessToken; import software.amazon.awssdk.services.signin.internal.OnDiskTokenManager; import software.amazon.awssdk.services.signin.model.CreateOAuth2TokenRequest; +import software.amazon.awssdk.services.signin.model.AccessDeniedException; import software.amazon.awssdk.services.signin.model.OAuth2ErrorCode; import software.amazon.awssdk.services.signin.model.SigninException; import software.amazon.awssdk.testutils.service.http.MockSyncHttpClient; @@ -182,7 +183,7 @@ public void resolveCredentials_whenCredentialsExpired_refreshesAndUpdatesCache() @Test public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithGeneric500_raisesException() { - // expired + // expired - no cached value in CachedSupplier yet, so ALLOW still throws on first failure AwsSessionCredentials creds = buildCredentials(Instant.now().minusSeconds(60)); LoginAccessToken token = buildAccessToken(creds); tokenManager.storeToken(token); @@ -195,6 +196,50 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithGeneri assertThrows(SigninException.class, () -> loginCredentialsProvider.resolveCredentials()); } + @Test + public void resolveCredentials_transientFailureAfterSuccessfulCache_returnsCachedCredentials() { + // 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) + // staleTime will be now+30s - 1min = now-30s (already stale), so next get() will refresh again + 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(); + + // Second response: transient 500 error + HttpExecuteResponse failureResponse = HttpExecuteResponse + .builder() + .response(SdkHttpResponse.builder().statusCode(500).build()) + .build(); + + mockHttpClient.stubResponses(successResponse, failureResponse); + + // First call: succeeds and populates the CachedSupplier cache + AwsCredentials firstResolve = loginCredentialsProvider.resolveCredentials(); + assertEquals("new-akid", firstResolve.accessKeyId()); + + // Second call: the cached value is already stale (30s expiry - 1min staleTime < now), + // so CachedSupplier tries to refresh, gets 500, and with ALLOW behavior returns cached value + AwsCredentials secondResolve = loginCredentialsProvider.resolveCredentials(); + assertEquals("new-akid", secondResolve.accessKeyId()); + assertEquals("new-skid", secondResolve.secretAccessKey()); + } + @Test public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithTokenExpired_raisesException() { // expired @@ -203,8 +248,9 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithTokenE tokenManager.storeToken(token); stubAccessDeniedException(OAuth2ErrorCode.TOKEN_EXPIRED); - SdkClientException e = assertThrows(SdkClientException.class, () -> loginCredentialsProvider.resolveCredentials()); - assertTrue(e.getMessage().contains("Your session has expired")); + AccessDeniedException e = assertThrows(AccessDeniedException.class, + () -> loginCredentialsProvider.resolveCredentials()); + assertNotNull(e); } @Test @@ -215,8 +261,9 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithUserEx tokenManager.storeToken(token); stubAccessDeniedException(OAuth2ErrorCode.USER_CREDENTIALS_CHANGED); - SdkClientException e = assertThrows(SdkClientException.class, () -> loginCredentialsProvider.resolveCredentials()); - assertTrue(e.getMessage().contains("change in your password")); + AccessDeniedException e = assertThrows(AccessDeniedException.class, + () -> loginCredentialsProvider.resolveCredentials()); + assertNotNull(e); } @Test 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 42465940b7ca..75c0462b707e 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 @@ -16,6 +16,7 @@ package software.amazon.awssdk.services.sso.auth; import static software.amazon.awssdk.utils.Validate.notNull; +import static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW; import java.time.Duration; import java.time.Instant; @@ -30,6 +31,7 @@ import software.amazon.awssdk.services.sso.internal.SessionCredentialsHolder; import software.amazon.awssdk.services.sso.model.GetRoleCredentialsRequest; import software.amazon.awssdk.services.sso.model.RoleCredentials; +import software.amazon.awssdk.services.sso.model.UnauthorizedException; import software.amazon.awssdk.utils.SdkAutoCloseable; import software.amazon.awssdk.utils.StringUtils; import software.amazon.awssdk.utils.builder.CopyableBuilder; @@ -90,7 +92,10 @@ private SsoCredentialsProvider(BuilderImpl builder) { this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled; CachedSupplier.Builder cacheBuilder = CachedSupplier.builder(this::updateSsoCredentials) - .cachedValueName(toString()); + .cachedValueName(toString()) + .staleValueBehavior(ALLOW) + .cacheInvalidatingPredicate( + e -> e instanceof ExpiredTokenException || e instanceof UnauthorizedException); if (builder.asyncCredentialUpdateEnabled) { cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME)); } @@ -115,6 +120,7 @@ private RefreshResult updateSsoCredentials() { private SessionCredentialsHolder getUpdatedCredentials(SsoClient ssoClient) { GetRoleCredentialsRequest request = getRoleCredentialsRequestSupplier.get(); notNull(request, "GetRoleCredentialsRequest can't be null."); + RoleCredentials roleCredentials = ssoClient.getRoleCredentials(request).roleCredentials(); AwsSessionCredentials sessionCredentials = AwsSessionCredentials.builder() .accessKeyId(roleCredentials.accessKeyId()) 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 d7be6cdd852c..73f71269b17e 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 @@ -16,6 +16,7 @@ package software.amazon.awssdk.services.sso.auth; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -27,11 +28,13 @@ import org.junit.jupiter.api.Test; import org.mockito.Mockito; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; import software.amazon.awssdk.services.sso.SsoClient; import software.amazon.awssdk.services.sso.model.GetRoleCredentialsRequest; import software.amazon.awssdk.services.sso.model.GetRoleCredentialsResponse; import software.amazon.awssdk.services.sso.model.RoleCredentials; +import software.amazon.awssdk.services.sso.model.UnauthorizedException; /** * Validates the functionality of {@link SsoCredentialsProvider}. @@ -88,6 +91,122 @@ public void distantExpiringCredentialsUpdatedInBackground_OverridePrefetchAndSta callClient(verify(ssoClient, times(2)), Mockito.any()); } + @Test + public void refreshFailureReturnsCachedCredentials_staticStability() { + ssoClient = mock(SsoClient.class); + RoleCredentials credentials = RoleCredentials.builder() + .accessKeyId("a") + .secretAccessKey("b") + .sessionToken("c") + .expiration(Instant.now().minus(Duration.ofSeconds(5)).toEpochMilli()) + .build(); + + Supplier supplier = getRequestSupplier(); + GetRoleCredentialsResponse response = getResponse(credentials); + + // First call succeeds, second call fails with transient error + when(ssoClient.getRoleCredentials(supplier.get())) + .thenReturn(response) + .thenThrow(SdkClientException.create("SSO service unavailable")); + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .refreshRequest(supplier) + .ssoClient(ssoClient) + .build()) { + // First call succeeds and caches credentials + AwsSessionCredentials firstResult = (AwsSessionCredentials) credentialsProvider.resolveCredentials(); + assertThat(firstResult.accessKeyId()).isEqualTo("a"); + + // Second call should return cached credentials because ALLOW is set + AwsSessionCredentials secondResult = (AwsSessionCredentials) credentialsProvider.resolveCredentials(); + assertThat(secondResult.accessKeyId()).isEqualTo("a"); + assertThat(secondResult.secretAccessKey()).isEqualTo("b"); + assertThat(secondResult.sessionToken()).isEqualTo("c"); + } + } + + @Test + public void unauthorizedException_bypassesStaticStability() { + ssoClient = mock(SsoClient.class); + RoleCredentials credentials = RoleCredentials.builder() + .accessKeyId("a") + .secretAccessKey("b") + .sessionToken("c") + .expiration(Instant.now().minus(Duration.ofSeconds(5)).toEpochMilli()) + .build(); + + Supplier supplier = getRequestSupplier(); + GetRoleCredentialsResponse response = getResponse(credentials); + + UnauthorizedException unauthorizedException = (UnauthorizedException) UnauthorizedException.builder() + .message("Token is expired") + .build(); + + // First call succeeds, second call fails with UnauthorizedException + when(ssoClient.getRoleCredentials(supplier.get())) + .thenReturn(response) + .thenThrow(unauthorizedException); + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .refreshRequest(supplier) + .ssoClient(ssoClient) + .build()) { + // First call succeeds and caches credentials + AwsSessionCredentials firstResult = (AwsSessionCredentials) credentialsProvider.resolveCredentials(); + assertThat(firstResult.accessKeyId()).isEqualTo("a"); + + // Second call should throw UnauthorizedException directly (bypasses static stability) + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(UnauthorizedException.class) + .hasMessageContaining("Token is expired"); + } + } + + @Test + public void expiredTokenException_bypassesStaticStability() { + ssoClient = mock(SsoClient.class); + + ExpiredTokenException expiredTokenException = (ExpiredTokenException) ExpiredTokenException.builder() + .message("The SSO session associated with this profile has expired") + .build(); + + // Request supplier throws ExpiredTokenException (client-side token expiry) + Supplier expiredSupplier = () -> { + throw expiredTokenException; + }; + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .refreshRequest(expiredSupplier) + .ssoClient(ssoClient) + .build()) { + // Should throw ExpiredTokenException directly (bypasses static stability) + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(ExpiredTokenException.class) + .hasMessageContaining("The SSO session associated with this profile has expired"); + } + } + + @Test + public void noCachedCredentials_anyFailure_throwsImmediately() { + ssoClient = mock(SsoClient.class); + + Supplier supplier = getRequestSupplier(); + + // First call fails with a transient error — no cached credentials exist + when(ssoClient.getRoleCredentials(supplier.get())) + .thenThrow(SdkClientException.create("SSO service unavailable")); + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .refreshRequest(supplier) + .ssoClient(ssoClient) + .build()) { + // Should throw immediately since no cached credentials exist + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("SSO service unavailable"); + } + } + private GetRoleCredentialsRequestSupplier getRequestSupplier() { 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 74ebc39c664e..5ac6881c3520 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 @@ -78,7 +78,8 @@ public abstract class StsCredentialsProvider implements AwsCredentialsProvider, this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled; CachedSupplier.Builder cacheBuilder = CachedSupplier.builder(this::updateSessionCredentials) - .cachedValueName(toString()); + .cachedValueName(toString()) + .staleValueBehavior(CachedSupplier.StaleValueBehavior.ALLOW); if (builder.asyncCredentialUpdateEnabled) { cacheBuilder.prefetchStrategy(new NonBlocking(asyncThreadName)); } 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 8c054aa97e1a..caffab32a9aa 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 @@ -16,6 +16,7 @@ package software.amazon.awssdk.services.sts.auth; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -27,7 +28,9 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.services.sts.StsClient; import software.amazon.awssdk.services.sts.endpoints.internal.Arn; import software.amazon.awssdk.services.sts.model.Credentials; @@ -101,6 +104,61 @@ public void distantExpiringCredentialsUpdatedInBackground_OverridePrefetchAndSta protected abstract String providerName(); + @Test + public void refreshFailureReturnsCachedCredentials_staticStability() { + // First call returns valid credentials that are already expired (to force a refresh on next call) + Credentials validCredentials = Credentials.builder() + .accessKeyId("a") + .secretAccessKey("b") + .sessionToken("c") + .expiration(Instant.now().minus(Duration.ofSeconds(5))) + .build(); + RequestT request = getRequest(); + ResponseT response = getResponse(validCredentials); + + // First call succeeds, second call fails + when(callClient(stsClient, request)) + .thenReturn(response) + .thenThrow(SdkClientException.create("STS service unavailable")); + + StsCredentialsProvider.BaseBuilder credentialsProviderBuilder = + createCredentialsProviderBuilder(request); + + try (StsCredentialsProvider credentialsProvider = credentialsProviderBuilder.stsClient(stsClient).build()) { + // First call should succeed and cache credentials + AwsCredentials firstResult = credentialsProvider.resolveCredentials(); + assertThat(firstResult).isInstanceOf(AwsSessionCredentials.class); + assertThat(((AwsSessionCredentials) firstResult).accessKeyId()).isEqualTo("a"); + + // Second call should return cached credentials instead of throwing + // because StaleValueBehavior.ALLOW is now set + AwsCredentials secondResult = credentialsProvider.resolveCredentials(); + assertThat(secondResult).isInstanceOf(AwsSessionCredentials.class); + assertThat(((AwsSessionCredentials) secondResult).accessKeyId()).isEqualTo("a"); + assertThat(((AwsSessionCredentials) secondResult).secretAccessKey()).isEqualTo("b"); + assertThat(((AwsSessionCredentials) secondResult).sessionToken()).isEqualTo("c"); + } + } + + @Test + public void initialFetchFailureThrowsException_noCachedCredentials() { + RequestT request = getRequest(); + + // The very first call to STS fails — no credentials have ever been cached + when(callClient(stsClient, request)) + .thenThrow(SdkClientException.create("STS service unavailable")); + + StsCredentialsProvider.BaseBuilder credentialsProviderBuilder = + createCredentialsProviderBuilder(request); + + try (StsCredentialsProvider credentialsProvider = credentialsProviderBuilder.stsClient(stsClient).build()) { + // Should throw because there are no cached credentials to fall back on + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(SdkClientException.class) + .hasMessageContaining("STS service unavailable"); + } + } + public void callClientWithCredentialsProvider(Instant credentialsExpirationDate, int numTimesInvokeCredentialsProvider, boolean overrideStaleAndPrefetchTimes) { Credentials credentials = Credentials.builder().accessKeyId("a").secretAccessKey("b").sessionToken("c").expiration(credentialsExpirationDate).build(); RequestT request = getRequest(); From 74ca0f0d368226f3cdbeaa784739e0509938d307 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Wed, 24 Jun 2026 10:35:44 -0700 Subject: [PATCH 3/6] [Credential Cache Pr 3/3] Standardize refresh window configuration across all credential providers (#7053) * Unify refresh window configuration across credential providers Standardize staleTime/prefetchTime defaults and add builder configuration across all credential providers: InstanceProfileCredentialsProvider: - Change default staleTime from 1 second to 1 minute - Change prefetchTime from max(timeUntilExpiry/2, 5min) to flat 5 minutes before expiry - Add prefetchTime(Duration) builder method - Add staleTime <= prefetchTime validation ContainerCredentialsProvider: - Change prefetchTime from min(1hr, 15min-before-expiry) to flat 5 minutes before expiry - Add staleTime(Duration) and prefetchTime(Duration) builder methods - Add staleTime <= prefetchTime validation ProcessCredentialsProvider: - Add staleTime(Duration) and prefetchTime(Duration) builder methods - Change default staleTime from 0 (at expiration) to 1 minute - Change default prefetchTime from 15 seconds to 5 minutes - Deprecate credentialRefreshThreshold (now sets prefetchTime) - Add staleTime <= prefetchTime validation WebIdentityTokenFileCredentialsProvider: - Add staleTime(Duration) and prefetchTime(Duration) builder methods (pass-through to underlying STS provider) StsCredentialsProvider / SsoCredentialsProvider / LoginCredentialsProvider: - Add staleTime <= prefetchTime validation - Update Javadoc for staleTime, prefetchTime, and asyncCredentialUpdateEnabled to use consistent terminology (mandatory refresh window / advisory refresh window) HttpCredentialsProvider: - Update asyncCredentialUpdateEnabled Javadoc for clarity All providers now use consistent defaults: - staleTime: 1 minute (mandatory/blocking refresh window) - prefetchTime: 5 minutes (advisory/proactive refresh window) * Update docs to match validator --- .../ContainerCredentialsProvider.java | 80 +++++++++-- .../credentials/HttpCredentialsProvider.java | 6 +- .../InstanceProfileCredentialsProvider.java | 63 +++++++-- .../ProcessCredentialsProvider.java | 129 ++++++++++++++---- ...bIdentityTokenFileCredentialsProvider.java | 27 +++- ...nstanceProfileCredentialsProviderTest.java | 111 ++++++++++----- .../ProcessCredentialsProviderTest.java | 54 +++++++- .../signin/auth/LoginCredentialsProvider.java | 49 +++++-- .../sso/auth/SsoCredentialsProvider.java | 48 +++++-- .../sts/auth/StsCredentialsProvider.java | 47 +++++-- ...dentityCredentialsProviderFactoryTest.java | 4 +- 11 files changed, 486 insertions(+), 132 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 44cfdda33ae2..89b894b0f95b 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 @@ -25,6 +25,7 @@ import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Duration; import java.time.Instant; import java.time.temporal.ChronoUnit; import java.util.Arrays; @@ -44,7 +45,6 @@ import software.amazon.awssdk.core.util.SdkUserAgent; import software.amazon.awssdk.regions.util.ResourcesEndpointProvider; import software.amazon.awssdk.regions.util.ResourcesEndpointRetryPolicy; -import software.amazon.awssdk.utils.ComparableUtils; import software.amazon.awssdk.utils.StringUtils; import software.amazon.awssdk.utils.ToString; import software.amazon.awssdk.utils.Validate; @@ -86,6 +86,9 @@ public final class ContainerCredentialsProvider private static final List VALID_LOOP_BACK_IPV4 = Arrays.asList(ECS_CONTAINER_HOST, EKS_CONTAINER_HOST_IPV4); 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; private final CachedSupplier credentialsCache; @@ -95,6 +98,8 @@ public final class ContainerCredentialsProvider private final String asyncThreadName; private final String sourceChain; private final String providerName; + private final Duration staleTime; + private final Duration prefetchTime; /** * @see #builder() @@ -108,6 +113,10 @@ private ContainerCredentialsProvider(BuilderImpl builder) { ? PROVIDER_NAME : 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); if (Boolean.TRUE.equals(builder.asyncCredentialUpdateEnabled)) { Validate.paramNotBlank(builder.asyncThreadName, "asyncThreadName"); @@ -156,19 +165,14 @@ private Instant staleTime(Instant expiration) { return null; } - return expiration.minus(1, ChronoUnit.MINUTES); + return expiration.minus(staleTime); } private Instant prefetchTime(Instant expiration) { - Instant oneHourFromNow = Instant.now().plus(1, ChronoUnit.HOURS); - if (expiration == null) { - return oneHourFromNow; + return Instant.now().plus(1, ChronoUnit.HOURS); } - - Instant fifteenMinutesBeforeExpiration = expiration.minus(15, ChronoUnit.MINUTES); - - return ComparableUtils.minimum(oneHourFromNow, fifteenMinutesBeforeExpiration); + return expiration.minus(prefetchTime); } @Override @@ -323,6 +327,40 @@ public boolean isMetadataServiceEndpoint(String host) { */ public interface Builder extends HttpCredentialsProvider.Builder, CopyableBuilder { + + /** + * Configure the amount of time, relative to credential expiration, that defines the mandatory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will block all callers until a refresh attempt completes. If the refresh attempt fails, the provider + * returns the cached credentials and will not attempt another refresh until a backoff period has elapsed. + * + *

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

By default, this is 1 minute. + * + * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh + */ + Builder staleTime(Duration staleTime); + + /** + * Configure the amount of time, relative to credential expiration, that defines the advisory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will attempt to refresh them proactively. If the refresh fails, the provider returns the existing cached + * credentials without error and will not attempt another refresh until a backoff period has elapsed. + * + *

When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread + * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform + * the refresh while other callers receive the current cached credentials. + * + *

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. + * + * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh + */ + Builder prefetchTime(Duration prefetchTime); } private static final class BuilderImpl implements Builder { @@ -330,6 +368,8 @@ private static final class BuilderImpl implements Builder { private Boolean asyncCredentialUpdateEnabled; private String asyncThreadName; private String sourceChain; + private Duration staleTime; + private Duration prefetchTime; private BuilderImpl() { asyncThreadName("container-credentials-provider"); @@ -340,6 +380,8 @@ private BuilderImpl(ContainerCredentialsProvider credentialsProvider) { this.asyncCredentialUpdateEnabled = credentialsProvider.asyncCredentialUpdateEnabled; this.asyncThreadName = credentialsProvider.asyncThreadName; this.sourceChain = credentialsProvider.sourceChain; + this.staleTime = credentialsProvider.staleTime; + this.prefetchTime = credentialsProvider.prefetchTime; } @Override @@ -372,6 +414,26 @@ public void setAsyncThreadName(String asyncThreadName) { asyncThreadName(asyncThreadName); } + @Override + public Builder staleTime(Duration staleTime) { + this.staleTime = staleTime; + return this; + } + + public void setStaleTime(Duration staleTime) { + staleTime(staleTime); + } + + @Override + public Builder prefetchTime(Duration prefetchTime) { + this.prefetchTime = prefetchTime; + return this; + } + + public void setPrefetchTime(Duration prefetchTime) { + prefetchTime(prefetchTime); + } + /** * An optional string denoting previous credentials providers that are chained with this one. *

Note: This method is primarily intended for use by AWS SDK internal components diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/HttpCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/HttpCredentialsProvider.java index 29239b34908d..218dc977f1dd 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/HttpCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/HttpCredentialsProvider.java @@ -28,9 +28,9 @@ public interface HttpCredentialsProvider extends AwsCredentialsProvider, SdkAutoCloseable { interface Builder> { /** - * Configure whether the provider should fetch credentials asynchronously in the background. If this is true, - * threads are less likely to block when credentials are loaded, but additional resources are used to maintain - * the provider. + * Configure whether the provider should fetch credentials asynchronously in the background. When enabled, a + * dedicated thread performs credential refreshes during the advisory refresh window, so that callers are less + * likely to block waiting for credentials. Additional resources (a thread) are used to maintain the provider. * *

By default, this is disabled.

*/ 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 364cd4401529..b6098427c186 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 @@ -16,7 +16,6 @@ package software.amazon.awssdk.auth.credentials; import static java.time.temporal.ChronoUnit.MINUTES; -import static software.amazon.awssdk.utils.ComparableUtils.maximum; import static software.amazon.awssdk.utils.FunctionalUtils.invokeSafely; import static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW; @@ -93,6 +92,8 @@ public final class InstanceProfileCredentialsProvider private final Duration staleTime; + private final Duration prefetchTime; + private final String sourceChain; private final String providerName; @@ -120,7 +121,10 @@ private InstanceProfileCredentialsProvider(BuilderImpl builder) { .profileName(profileName) .build(); - this.staleTime = Validate.getOrDefault(builder.staleTime, () -> Duration.ofSeconds(1)); + 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); if (Boolean.TRUE.equals(builder.asyncCredentialUpdateEnabled)) { Validate.paramNotBlank(builder.asyncThreadName, "asyncThreadName"); @@ -204,7 +208,12 @@ private Instant prefetchTime(Instant expiration) { return null; } - return now.plus(maximum(timeUntilExpiration.dividedBy(2), Duration.ofMinutes(5))); + // Advisory refresh window: use configured prefetchTime before expiry. + // If remaining lifetime < prefetchTime, refresh immediately. + if (timeUntilExpiration.compareTo(prefetchTime) < 0) { + return now; + } + return expiration.minus(prefetchTime); } @Override @@ -355,17 +364,39 @@ public interface Builder extends HttpCredentialsProvider.BuilderIncreasing this value to a higher value (10s or more) may help with situations where a higher load on the instance - * metadata service causes it to return 503s error, for which the SDK may not be able to recover fast enough and - * returns expired credentials. + * Configure the amount of time, relative to credential expiration, that defines the mandatory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will block all callers until a refresh attempt completes. If the refresh attempt fails, the provider + * returns the cached credentials and will not attempt another refresh until a backoff period has elapsed. + * + *

This value must be less than or equal to {@link #prefetchTime(Duration)}. Setting this equal to + * {@code prefetchTime} effectively disables prefetch, causing all refreshes to be mandatory (blocking). * - * @param duration the amount of time before expiration for when to consider the credentials to be stale and need refresh + *

By default, this is 1 minute. + * + * @param duration the duration before expiration that triggers mandatory (blocking) refresh */ Builder staleTime(Duration duration); + /** + * Configure the amount of time, relative to credential expiration, that defines the advisory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will attempt to refresh them proactively. If the refresh fails, the provider returns the existing cached + * credentials without error and will not attempt another refresh until a backoff period has elapsed. + * + *

When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread + * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform + * the refresh while other callers receive the current cached credentials. + * + *

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. + * + * @param duration the duration before expiration that triggers advisory (proactive) refresh + */ + Builder prefetchTime(Duration duration); + /** * Build a {@link InstanceProfileCredentialsProvider} from the provided configuration. */ @@ -382,6 +413,7 @@ static final class BuilderImpl implements Builder { private Supplier profileFile; private String profileName; private Duration staleTime; + private Duration prefetchTime; private String sourceChain; private BuilderImpl() { @@ -396,6 +428,7 @@ private BuilderImpl(InstanceProfileCredentialsProvider provider) { this.profileFile = provider.profileFile; this.profileName = provider.profileName; this.staleTime = provider.staleTime; + this.prefetchTime = provider.prefetchTime; this.sourceChain = provider.sourceChain; } @@ -475,6 +508,16 @@ public void setStaleTime(Duration duration) { staleTime(duration); } + @Override + public Builder prefetchTime(Duration duration) { + this.prefetchTime = duration; + return this; + } + + public void setPrefetchTime(Duration duration) { + prefetchTime(duration); + } + /** * An optional string denoting previous credentials providers that are chained with this one. *

Note: This method is primarily intended for use by AWS SDK internal components 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 08d9cf373ab2..0b1acb2b4b28 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 @@ -24,6 +24,7 @@ import java.util.ArrayList; import java.util.Collections; import java.util.List; +import java.util.Optional; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; import software.amazon.awssdk.protocols.jsoncore.JsonNode; @@ -44,21 +45,33 @@ /** * A credentials provider that can load credentials from an external process. This is used to support the credential_process * setting in the profile credentials file. See - * sourcing credentials - * from external processes for more information. + * sourcing + * credentials from external processes for more information. * - *

- * This class can be initialized using {@link #builder()}. + *

This provider caches credentials returned by the external process and refreshes them before they expire. If a refresh + * attempt fails after credentials have entered the mandatory refresh window, the provider raises an exception to the caller + * (unlike other credential providers that return cached credentials on failure). * - *

- * Available settings: + *

This class can be initialized using {@link #builder()}. + * + *

Available settings:

*
    - *
  • Command - The command that should be executed to retrieve credentials.
  • - *
  • CredentialRefreshThreshold - The amount of time between when the credentials expire and when the credentials should - * start to be refreshed. This allows the credentials to be refreshed *before* they are reported to expire. Default: 15 - * seconds.
  • - *
  • ProcessOutputLimit - The maximum amount of data that can be returned by the external process before an exception is - * raised. Default: 64000 bytes (64KB).
  • + *
  • Command - The command that should be executed to retrieve credentials. Can be specified as a single string + * (deprecated) or as a list of strings.
  • + *
  • StaleTime - The amount of time before credential expiration that defines the mandatory refresh window. When + * credentials are within this window, all callers block until a refresh attempt completes. If the refresh fails, an + * exception is raised. Default: 1 minute.
  • + *
  • PrefetchTime - The amount of time before credential expiration that defines the advisory refresh window. When + * credentials are within this window, the provider proactively attempts to refresh them. If the refresh fails during the + * advisory window, the existing cached credentials are returned without error. This replaces the deprecated + * {@code credentialRefreshThreshold} setting; if that setting was explicitly configured, its value is honored as the + * prefetch time for backward compatibility. Default: 5 minutes.
  • + *
  • AsyncCredentialUpdateEnabled - Whether to refresh credentials asynchronously in a background thread during + * the advisory refresh window, so that callers are less likely to block. Default: disabled.
  • + *
  • ProcessOutputLimit - The maximum amount of data that can be returned by the external process before an + * exception is raised. Default: 64000 bytes (64KB).
  • + *
  • CredentialRefreshThreshold - Deprecated. Use {@code prefetchTime} instead. If explicitly set, the + * value is honored as the prefetch time for backward compatibility.
  • *
*/ @SdkPublicApi @@ -71,9 +84,10 @@ public final class ProcessCredentialsProvider private static final JsonNodeParser PARSER = JsonNodeParser.builder() .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 Duration credentialRefreshThreshold; private final long processOutputLimit; private final String staticAccountId; @@ -87,6 +101,8 @@ public final class ProcessCredentialsProvider private final String sourceChain; private final String providerName; + private final Duration staleTime; + private final Duration prefetchTime; /** * @see #builder() @@ -94,7 +110,6 @@ public final class ProcessCredentialsProvider private ProcessCredentialsProvider(Builder builder) { this.executableCommand = executableCommand(builder); this.processOutputLimit = Validate.isPositive(builder.processOutputLimit, "processOutputLimit"); - this.credentialRefreshThreshold = Validate.isPositive(builder.credentialRefreshThreshold, "expirationBuffer"); this.commandFromBuilder = builder.command; this.commandAsListOfStringsFromBuilder = builder.commandAsListOfStrings; this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled; @@ -103,6 +118,10 @@ private ProcessCredentialsProvider(Builder builder) { this.providerName = StringUtils.isEmpty(builder.sourceChain) ? 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); CachedSupplier.Builder cacheBuilder = CachedSupplier.builder(this::refreshCredentials) .cachedValueName(toString()); @@ -154,8 +173,8 @@ private RefreshResult refreshCredentials() { Instant credentialExpirationTime = credentialExpirationTime(credentialsJson); return RefreshResult.builder(credentials) - .staleTime(credentialExpirationTime) - .prefetchTime(credentialExpirationTime.minusMillis(credentialRefreshThreshold.toMillis())) + .staleTime(staleTime(credentialExpirationTime)) + .prefetchTime(prefetchTime(credentialExpirationTime)) .build(); } catch (InterruptedException e) { throw new IllegalStateException("Process-based credential refreshing has been interrupted.", e); @@ -164,6 +183,20 @@ private RefreshResult refreshCredentials() { } } + private Instant staleTime(Instant expiration) { + if (expiration == null || expiration.equals(Instant.MAX)) { + return Instant.MAX; + } + return expiration.minus(staleTime); + } + + private Instant prefetchTime(Instant expiration) { + if (expiration == null || expiration.equals(Instant.MAX)) { + return Instant.MAX; + } + return expiration.minus(prefetchTime); + } + /** * Parse the output from the credentials process. */ @@ -277,10 +310,11 @@ public static class Builder implements CopyableBuilder commandAsListOfStrings; - private Duration credentialRefreshThreshold = Duration.ofSeconds(15); private long processOutputLimit = 64000; private String staticAccountId; private String sourceChain; + private Duration staleTime; + private Duration prefetchTime; /** * @see #builder() @@ -292,16 +326,21 @@ private Builder(ProcessCredentialsProvider provider) { this.asyncCredentialUpdateEnabled = provider.asyncCredentialUpdateEnabled; this.command = provider.commandFromBuilder; this.commandAsListOfStrings = provider.commandAsListOfStringsFromBuilder; - this.credentialRefreshThreshold = provider.credentialRefreshThreshold; this.processOutputLimit = provider.processOutputLimit; this.staticAccountId = provider.staticAccountId; this.sourceChain = provider.sourceChain; + this.staleTime = provider.staleTime; + this.prefetchTime = provider.prefetchTime; } /** - * Configure whether the provider should fetch credentials asynchronously in the background. If this is true, - * threads are less likely to block when credentials are loaded, but additional resources are used to maintain - * the provider. + * Configure whether the provider should fetch credentials asynchronously in the background. When enabled, a + * dedicated thread performs credential refreshes during the advisory refresh window (defined by + * {@link #prefetchTime(Duration)}), so that callers are less likely to block waiting for credentials. Additional + * resources (a thread) are used to maintain the provider. + * + *

Regardless of this setting, callers will block if credentials enter the mandatory refresh window (defined by + * {@link #staleTime(Duration)}). * *

By default, this is disabled.

*/ @@ -311,6 +350,47 @@ public Builder asyncCredentialUpdateEnabled(Boolean asyncCredentialUpdateEnabled return this; } + /** + * Configure the amount of time, relative to credential expiration, that defines the mandatory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will block all callers until a refresh attempt completes. If the refresh attempt fails, the provider + * raises an exception to the caller. + * + *

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

By default, this is 1 minute.

+ * + * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh + */ + public Builder staleTime(Duration staleTime) { + this.staleTime = staleTime; + return this; + } + + /** + * Configure the amount of time, relative to credential expiration, that defines the advisory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will attempt to refresh them proactively. If the refresh fails during the advisory window, the provider + * returns the existing cached credentials. If the refresh fails after credentials have entered the mandatory refresh + * window (defined by {@link #staleTime(Duration)}), the provider raises an exception. + * + *

When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread + * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform + * the refresh while other callers receive the current cached credentials. + * + *

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.

+ * + * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh + */ + public Builder prefetchTime(Duration prefetchTime) { + this.prefetchTime = prefetchTime; + return this; + } + /** * Configure the command that should be executed to retrieve credentials. * See {@link ProcessBuilder} for details on how this command is used. @@ -338,10 +418,13 @@ public Builder command(List commandAsListOfStrings) { * Configure the amount of time between when the credentials expire and when the credentials should start to be * refreshed. This allows the credentials to be refreshed *before* they are reported to expire. * - *

Default: 15 seconds.

+ * @deprecated Use {@link #prefetchTime(Duration)} instead. This method has been deprecated for consistency + * with other credential providers. Calls to this method are equivalent to calling + * {@code prefetchTime(credentialRefreshThreshold)}. */ + @Deprecated public Builder credentialRefreshThreshold(Duration credentialRefreshThreshold) { - this.credentialRefreshThreshold = credentialRefreshThreshold; + this.prefetchTime = credentialRefreshThreshold; return this; } 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 6ce5217dc909..53ecf64b53da 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 @@ -189,21 +189,36 @@ public interface Builder extends CopyableBuilderPrefetch updates will occur between the specified time and the stale time of the provider. Prefetch - * updates may be asynchronous. See {@link #asyncCredentialUpdateEnabled}. + *

When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread + * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform + * the refresh while other callers receive the current cached credentials. + * + *

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. + * + * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ Builder prefetchTime(Duration prefetchTime); /** - * Configure the amount of time, relative to STS token expiration, that the cached credentials are considered stale and - * must be updated. All threads will block until the value is updated. + * Configure the amount of time, relative to credential expiration, that defines the mandatory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will block all callers until a refresh attempt completes. If the refresh attempt fails, the provider + * returns the cached credentials and will not attempt another refresh until a backoff period has elapsed. + * + *

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

By default, this is 1 minute. + * + * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh */ Builder staleTime(Duration staleTime); 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 055967055c25..46bf5faf8123 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 @@ -573,40 +573,80 @@ void resolveCredentials_callsImdsIfCredentialsWithin5MinutesOfExpiration() { assertThat(credentials10SecondsAgo.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY2"); } + @Test + void resolveCredentials_immediateRefreshWhenRemainingLifetimeLessThan5Minutes() { + // When IMDS returns credentials with less than 5 minutes of remaining lifetime, + // the prefetchTime should be set to 'now', triggering a refresh soon after. + // Advance the clock by 2 minutes to account for jitter on the prefetch time. + AdjustableClock clock = new AdjustableClock(); + AwsCredentialsProvider credentialsProvider = credentialsProviderWithClock(clock); + Instant now = Instant.now(); + // Credentials that expire in 3 minutes (less than the 5 minute advisory window) + Instant shortExpiration = now.plus(3, MINUTES); + String shortLivedCredentialsResponse = + "{" + + "\"AccessKeyId\":\"ACCESS_KEY_ID\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," + + "\"Expiration\":\"" + DateUtils.formatIso8601Date(shortExpiration) + '"' + + "}"; + + String refreshedCredentialsResponse = + "{" + + "\"AccessKeyId\":\"ACCESS_KEY_ID2\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY2\"," + + "\"Expiration\":\"" + DateUtils.formatIso8601Date(now.plus(6, HOURS)) + '"' + + "}"; + + // Prime cache with short-lived credentials + clock.time = now; + stubSecureCredentialsResponse(aResponse().withBody(shortLivedCredentialsResponse)); + AwsCredentials firstCredentials = credentialsProvider.resolveCredentials(); + assertThat(firstCredentials.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY"); + + // Advance past any jitter on the prefetch time (which was set to 'now'). + // The staleTime is shortExpiration - 1min = now + 2min. + // The jitter window is at most 1 minute (between prefetchTime and 1min before staleTime). + // So advancing by 2 minutes guarantees we are past the jittered prefetch time, triggering refresh. + clock.time = now.plus(2, MINUTES); + stubSecureCredentialsResponse(aResponse().withBody(refreshedCredentialsResponse)); + AwsCredentials secondCredentials = credentialsProvider.resolveCredentials(); + assertThat(secondCredentials.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY2"); + } + @Test void imdsCallFrequencyIsLimited() { - // Requires running the test multiple times to account for refresh jitter - for (int i = 0; i < 10; i++) { - AdjustableClock clock = new AdjustableClock(); - AwsCredentialsProvider credentialsProvider = credentialsProviderWithClock(clock); - Instant now = Instant.now(); - String successfulCredentialsResponse1 = - "{" - + "\"AccessKeyId\":\"ACCESS_KEY_ID\"," - + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," - + "\"Expiration\":\"" + DateUtils.formatIso8601Date(now) + '"' - + "}"; - - String successfulCredentialsResponse2 = - "{" - + "\"AccessKeyId\":\"ACCESS_KEY_ID2\"," - + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY2\"," - + "\"Expiration\":\"" + DateUtils.formatIso8601Date(now.plus(6, HOURS)) + '"' - + "}"; - - // Set the time to 5 minutes before expiration and call IMDS - clock.time = now.minus(5, MINUTES); - stubSecureCredentialsResponse(aResponse().withBody(successfulCredentialsResponse1)); - AwsCredentials credentials5MinutesAgo = credentialsProvider.resolveCredentials(); - - // Set the time to 2 seconds before expiration, and verify that do not call IMDS because it hasn't been 5 minutes yet - clock.time = now.minus(2, SECONDS); - stubSecureCredentialsResponse(aResponse().withBody(successfulCredentialsResponse2)); - AwsCredentials credentials2SecondsAgo = credentialsProvider.resolveCredentials(); - - assertThat(credentials2SecondsAgo).isEqualTo(credentials5MinutesAgo); - assertThat(credentials5MinutesAgo.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY"); - } + // Verify that IMDS is not called again if we haven't reached the prefetch window + AdjustableClock clock = new AdjustableClock(); + AwsCredentialsProvider credentialsProvider = credentialsProviderWithClock(clock); + Instant now = Instant.now(); + Instant expiration = now.plus(6, HOURS); + String successfulCredentialsResponse1 = + "{" + + "\"AccessKeyId\":\"ACCESS_KEY_ID\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," + + "\"Expiration\":\"" + DateUtils.formatIso8601Date(expiration) + '"' + + "}"; + + String successfulCredentialsResponse2 = + "{" + + "\"AccessKeyId\":\"ACCESS_KEY_ID2\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY2\"," + + "\"Expiration\":\"" + DateUtils.formatIso8601Date(expiration.plus(6, HOURS)) + '"' + + "}"; + + // Prime the cache at the current time + clock.time = now; + 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); + stubSecureCredentialsResponse(aResponse().withBody(successfulCredentialsResponse2)); + AwsCredentials credentials5HoursLater = credentialsProvider.resolveCredentials(); + + assertThat(credentials5HoursLater).isEqualTo(credentialsAtStart); + assertThat(credentialsAtStart.secretAccessKey()).isEqualTo("SECRET_ACCESS_KEY"); } @Test @@ -632,7 +672,7 @@ void testErrorWhileCacheIsStale_shouldRecover() { Duration staleTime = Duration.ofMinutes(5); - AwsCredentialsProvider provider = credentialsProviderWithClock(clock, staleTime); + AwsCredentialsProvider provider = credentialsProviderWithClock(clock, staleTime, Duration.ofMinutes(10)); // cache expiration with expiration = 6 hours clock.time = now; @@ -704,10 +744,15 @@ private AwsCredentialsProvider credentialsProviderWithClock(Clock clock) { } private AwsCredentialsProvider credentialsProviderWithClock(Clock clock, Duration staleTime) { + return credentialsProviderWithClock(clock, staleTime, Duration.ofMinutes(5)); + } + + private AwsCredentialsProvider credentialsProviderWithClock(Clock clock, Duration staleTime, Duration prefetchTime) { InstanceProfileCredentialsProvider.BuilderImpl builder = (InstanceProfileCredentialsProvider.BuilderImpl) InstanceProfileCredentialsProvider.builder(); builder.clock(clock); builder.staleTime(staleTime); + builder.prefetchTime(prefetchTime); return builder.build(); } diff --git a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProviderTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProviderTest.java index 5b77907c59fa..e4b3ba321482 100644 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProviderTest.java +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ProcessCredentialsProviderTest.java @@ -161,7 +161,8 @@ void sessionCredentialsCanBeLoaded() { .command(String.format("%s %s %s token=%s exp=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, expiration)) - .credentialRefreshThreshold(Duration.ofSeconds(1)) + .staleTime(Duration.ofMillis(500)) + .prefetchTime(Duration.ofSeconds(1)) .build(); AwsCredentials credentials = credentialsProvider.resolveCredentials(); @@ -176,7 +177,8 @@ void sessionCredentialsWithAccountIdCanBeLoaded() { ProcessCredentialsProvider.builder() .command(String.format("%s %s %s token=sessionToken exp=%s acctid=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, expiration, ACCOUNT_ID)) - .credentialRefreshThreshold(Duration.ofSeconds(1)) + .staleTime(Duration.ofMillis(500)) + .prefetchTime(Duration.ofSeconds(1)) .build(); AwsCredentials credentials = credentialsProvider.resolveCredentials(); @@ -191,7 +193,8 @@ void sessionCredentialsWithStaticAccountIdCanBeLoaded() { ProcessCredentialsProvider.builder() .command(String.format("%s %s %s token=sessionToken exp=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, expiration)) - .credentialRefreshThreshold(Duration.ofSeconds(1)) + .staleTime(Duration.ofMillis(500)) + .prefetchTime(Duration.ofSeconds(1)) .staticAccountId("staticAccountId") .sourceChain("v") .build(); @@ -225,7 +228,7 @@ void resultsAreCached() { ProcessCredentialsProvider.builder() .command(String.format("%s %s %s token=%s exp=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN, - DateUtils.formatIso8601Date(Instant.now().plusSeconds(20)))) + DateUtils.formatIso8601Date(Instant.now().plus(Duration.ofMinutes(30))))) .build(); AwsCredentials request1 = credentialsProvider.resolveCredentials(); @@ -241,7 +244,8 @@ void expirationBufferOverrideIsApplied() { .command(String.format("%s %s %s token=%s exp=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, RANDOM_SESSION_TOKEN, DateUtils.formatIso8601Date(Instant.now().plusSeconds(20)))) - .credentialRefreshThreshold(Duration.ofSeconds(20)) + .staleTime(Duration.ofSeconds(10)) + .prefetchTime(Duration.ofSeconds(20)) .build(); AwsCredentials request1 = credentialsProvider.resolveCredentials(); @@ -250,12 +254,47 @@ void expirationBufferOverrideIsApplied() { assertThat(request1).isNotEqualTo(request2); } + @Test + void defaultPrefetchTime_credentialsWithinFiveMinuteWindow_areRefreshed() { + // Credentials that expire in 30 seconds: staleTime = now+30s - 1min = now-30s (in the past, stale!) + // In STRICT mode, stale credentials force a synchronous refresh on every call + ProcessCredentialsProvider credentialsProvider = + ProcessCredentialsProvider.builder() + .command(String.format("%s %s %s token=%s exp=%s", + scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, RANDOM_SESSION_TOKEN, + DateUtils.formatIso8601Date(Instant.now().plusSeconds(30)))) + .build(); + + AwsCredentials request1 = credentialsProvider.resolveCredentials(); + AwsCredentials request2 = credentialsProvider.resolveCredentials(); + + assertThat(request1).isNotEqualTo(request2); + } + + @Test + void defaultPrefetchTime_credentialsFarFromExpiry_areCached() { + // Credentials that expire in 30 minutes: prefetchTime = now+30min - 5min = now+25min (in the future) + // So the cache should NOT refresh + ProcessCredentialsProvider credentialsProvider = + ProcessCredentialsProvider.builder() + .command(String.format("%s %s %s token=%s exp=%s", + scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, RANDOM_SESSION_TOKEN, + DateUtils.formatIso8601Date(Instant.now().plus(Duration.ofMinutes(30))))) + .build(); + + AwsCredentials request1 = credentialsProvider.resolveCredentials(); + AwsCredentials request2 = credentialsProvider.resolveCredentials(); + + assertThat(request1).isEqualTo(request2); + } + @Test void processFailed_shouldContainErrorMessage() { ProcessCredentialsProvider credentialsProvider = ProcessCredentialsProvider.builder() .command(errorScriptLocation) - .credentialRefreshThreshold(Duration.ofSeconds(20)) + .staleTime(Duration.ofSeconds(10)) + .prefetchTime(Duration.ofSeconds(20)) .build(); assertThatThrownBy(credentialsProvider::resolveCredentials) @@ -269,7 +308,8 @@ void lackOfExpirationIsCachedForever() { ProcessCredentialsProvider.builder() .command(String.format("%s %s %s token=%s", scriptLocation, ACCESS_KEY_ID, SECRET_ACCESS_KEY, SESSION_TOKEN)) - .credentialRefreshThreshold(Duration.ofSeconds(20)) + .staleTime(Duration.ofSeconds(10)) + .prefetchTime(Duration.ofSeconds(20)) .build(); AwsCredentials request1 = credentialsProvider.resolveCredentials(); 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 c2beadf7ff4f..8c2ca8be010b 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 @@ -48,6 +48,7 @@ import software.amazon.awssdk.utils.Logger; import software.amazon.awssdk.utils.SdkAutoCloseable; import software.amazon.awssdk.utils.StringUtils; +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.CachedSupplier; @@ -106,6 +107,8 @@ private LoginCredentialsProvider(BuilderImpl builder) { 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.sourceChain = builder.sourceChain; this.providerName = StringUtils.isEmpty(builder.sourceChain) @@ -254,16 +257,16 @@ private static AccessDeniedException extractAccessDeniedException(RuntimeExcepti } /** - * The amount of time, relative to session token expiration, that the cached credentials are considered stale and should no - * longer be used. All threads will block until the value is updated. + * The amount of time, relative to credential expiration, that defines the mandatory refresh window. When credentials are + * within this window, all threads will block until the credentials are updated. */ public Duration staleTime() { return staleTime; } /** - * The amount of time, relative to session token expiration, that the cached credentials are considered close to stale and - * should be updated. + * The amount of time, relative to credential expiration, that defines the advisory refresh window. When credentials are + * within this window, the provider proactively attempts to refresh them. */ public Duration prefetchTime() { return prefetchTime; @@ -312,29 +315,49 @@ public interface Builder extends CopyableBuilderRegardless of this setting, callers will block if credentials enter the mandatory refresh window (defined by + * {@link #staleTime(Duration)}). * *

By default, this is enabled. */ Builder asyncCredentialUpdateEnabled(Boolean asyncCredentialUpdateEnabled); /** - * Configure the amount of time, relative to login token expiration, that the cached credentials are considered stale and - * should no longer be used. All threads will block until the value is updated. + * Configure the amount of time, relative to credential expiration, that defines the mandatory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will block all callers until a refresh attempt completes. If the refresh attempt fails, the provider + * returns the cached credentials and will not attempt another refresh until a backoff period has elapsed. + * + *

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

By default, this is 1 minute. + * + * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh */ Builder staleTime(Duration staleTime); /** - * Configure the amount of time, relative to signin token expiration, that the cached credentials are considered close to - * stale and should be updated. - *

- * Prefetch updates will occur between the specified time and the stale time of the provider. Prefetch updates may be - * asynchronous. See {@link #asyncCredentialUpdateEnabled}. + * Configure the amount of time, relative to credential expiration, that defines the advisory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will attempt to refresh them proactively. If the refresh fails, the provider returns the existing cached + * credentials without error and will not attempt another refresh until a backoff period has elapsed. + * + *

When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread + * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform + * the refresh while other callers receive the current cached credentials. + * + *

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. + * + * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ Builder prefetchTime(Duration prefetchTime); 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 75c0462b707e..eeefefb9983c 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 @@ -15,6 +15,7 @@ package software.amazon.awssdk.services.sso.auth; +import static software.amazon.awssdk.utils.Validate.isTrue; import static software.amazon.awssdk.utils.Validate.notNull; import static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW; @@ -83,6 +84,8 @@ private SsoCredentialsProvider(BuilderImpl builder) { 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.sourceChain = builder.sourceChain; this.providerName = StringUtils.isEmpty(builder.sourceChain) @@ -133,16 +136,16 @@ private SessionCredentialsHolder getUpdatedCredentials(SsoClient ssoClient) { } /** - * The amount of time, relative to session token expiration, that the cached credentials are considered stale and - * should no longer be used. All threads will block until the value is updated. + * The amount of time, relative to credential expiration, that defines the mandatory refresh window. When credentials are + * within this window, all threads will block until the credentials are updated. */ public Duration staleTime() { return staleTime; } /** - * The amount of time, relative to session token expiration, that the cached credentials are considered close to stale - * and should be updated. + * The amount of time, relative to credential expiration, that defines the advisory refresh window. When credentials are + * within this window, the provider proactively attempts to refresh them. */ public Duration prefetchTime() { return prefetchTime; @@ -182,30 +185,49 @@ public interface Builder extends CopyableBuilderRegardless of this setting, callers will block if credentials enter the mandatory refresh window (defined by + * {@link #staleTime(Duration)}). * *

By default, this is disabled.

*/ Builder asyncCredentialUpdateEnabled(Boolean asyncCredentialUpdateEnabled); /** - * Configure the amount of time, relative to SSO session token expiration, that the cached credentials are considered - * stale and should no longer be used. All threads will block until the value is updated. + * Configure the amount of time, relative to credential expiration, that defines the mandatory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will block all callers until a refresh attempt completes. If the refresh attempt fails, the provider + * returns the cached credentials and will not attempt another refresh until a backoff period has elapsed. + * + *

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

By default, this is 1 minute.

+ * + * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh */ Builder staleTime(Duration staleTime); /** - * Configure the amount of time, relative to SSO session token expiration, that the cached credentials are considered - * close to stale and should be updated. + * Configure the amount of time, relative to credential expiration, that defines the advisory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will attempt to refresh them proactively. If the refresh fails, the provider returns the existing cached + * credentials without error and will not attempt another refresh until a backoff period has elapsed. * - * Prefetch updates will occur between the specified time and the stale time of the provider. Prefetch updates may be - * asynchronous. See {@link #asyncCredentialUpdateEnabled}. + *

When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread + * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform + * the refresh while other callers receive the current cached credentials. + * + *

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.

+ * + * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ Builder prefetchTime(Duration prefetchTime); 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 5ac6881c3520..c878bd987970 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 @@ -74,6 +74,8 @@ 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); + 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 = @@ -117,16 +119,16 @@ public void close() { } /** - * The amount of time, relative to STS token expiration, that the cached credentials are considered stale and - * should no longer be used. All threads will block until the value is updated. + * The amount of time, relative to credential expiration, that defines the mandatory refresh window. When credentials are + * within this window, all threads will block until the credentials are updated. */ public Duration staleTime() { return staleTime; } /** - * The amount of time, relative to STS token expiration, that the cached credentials are considered close to stale - * and should be updated. + * The amount of time, relative to credential expiration, that defines the advisory refresh window. When credentials are + * within this window, the provider proactively attempts to refresh them. */ public Duration prefetchTime() { return prefetchTime; @@ -184,9 +186,13 @@ public B stsClient(StsClient stsClient) { } /** - * Configure whether the provider should fetch credentials asynchronously in the background. If this is true, - * threads are less likely to block when credentials are loaded, but additional resources are used to maintain - * the provider. + * Configure whether the provider should fetch credentials asynchronously in the background. When enabled, a + * dedicated thread performs credential refreshes during the advisory refresh window (defined by + * {@link #prefetchTime(Duration)}), so that callers are less likely to block waiting for credentials. Additional + * resources (a thread) are used to maintain the provider. + * + *

Regardless of this setting, callers will block if credentials enter the mandatory refresh window (defined by + * {@link #staleTime(Duration)}). * *

By default, this is disabled.

*/ @@ -197,10 +203,17 @@ public B asyncCredentialUpdateEnabled(Boolean asyncCredentialUpdateEnabled) { } /** - * Configure the amount of time, relative to STS token expiration, that the cached credentials are considered - * stale and must be updated. All threads will block until the value is updated. + * Configure the amount of time, relative to credential expiration, that defines the mandatory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will block all callers until a refresh attempt completes. If the refresh attempt fails, the provider + * returns the cached credentials and will not attempt another refresh until a backoff period has elapsed. + * + *

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

By default, this is 1 minute.

+ * + * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh */ @SuppressWarnings("unchecked") public B staleTime(Duration staleTime) { @@ -209,13 +222,21 @@ public B staleTime(Duration staleTime) { } /** - * Configure the amount of time, relative to STS token expiration, that the cached credentials are considered - * close to stale and should be updated. + * Configure the amount of time, relative to credential expiration, that defines the advisory refresh window. When + * the cached credentials are within this window (i.e., their remaining lifetime is less than this duration), the + * provider will attempt to refresh them proactively. If the refresh fails, the provider returns the existing cached + * credentials without error and will not attempt another refresh until a backoff period has elapsed. * - * Prefetch updates will occur between the specified time and the stale time of the provider. Prefetch updates may be - * asynchronous. See {@link #asyncCredentialUpdateEnabled}. + *

When {@link #asyncCredentialUpdateEnabled(Boolean)} is true, advisory refreshes happen in a background thread + * and callers immediately receive the current cached credentials. When it is false, one caller will block to perform + * the refresh while other callers receive the current cached credentials. + * + *

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.

+ * + * @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh */ @SuppressWarnings("unchecked") public B prefetchTime(Duration prefetchTime) { diff --git a/services/sts/src/test/java/software/amazon/awssdk/services/sts/internal/StsWebIdentityCredentialsProviderFactoryTest.java b/services/sts/src/test/java/software/amazon/awssdk/services/sts/internal/StsWebIdentityCredentialsProviderFactoryTest.java index 904a2ff2c0ff..ee5174919a4b 100644 --- a/services/sts/src/test/java/software/amazon/awssdk/services/sts/internal/StsWebIdentityCredentialsProviderFactoryTest.java +++ b/services/sts/src/test/java/software/amazon/awssdk/services/sts/internal/StsWebIdentityCredentialsProviderFactoryTest.java @@ -39,8 +39,8 @@ void stsWebIdentityCredentialsProviderFactory_withWebIdentityTokenCredentialProp AwsCredentialsProvider provider = factory.create( WebIdentityTokenCredentialProperties.builder() .asyncCredentialUpdateEnabled(true) - .prefetchTime(Duration.ofMinutes(5)) - .staleTime(Duration.ofMinutes(15)) + .prefetchTime(Duration.ofMinutes(15)) + .staleTime(Duration.ofMinutes(5)) .roleArn("role-arn") .webIdentityTokenFile(Paths.get("/path/to/file")) .roleSessionName("session-name") From 25c91a15f066dd45f0949d7db475992a48ca3cbc Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Thu, 2 Jul 2026 10:02:44 -0700 Subject: [PATCH 4/6] [Credentials Cache PR4]Add dynamic advisory refresh window and update prefetch behavior (#7093) 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() * Update approach to dynamic prefetch window --- .../ContainerCredentialsProvider.java | 21 ++- .../InstanceProfileCredentialsProvider.java | 24 ++-- .../ProcessCredentialsProvider.java | 19 ++- ...bIdentityTokenFileCredentialsProvider.java | 5 +- ...nstanceProfileCredentialsProviderTest.java | 10 +- .../signin/auth/LoginCredentialsProvider.java | 55 ++++++-- .../auth/LoginCredentialsProviderTest.java | 129 ++++++++++++++++++ .../sso/auth/SsoCredentialsProvider.java | 20 ++- .../sso/auth/SsoCredentialsProviderTest.java | 2 +- .../sts/auth/StsCredentialsProvider.java | 20 ++- .../auth/StsCredentialsProviderTestBase.java | 2 +- ...ebIdentityTokenCredentialProviderTest.java | 2 +- .../awssdk/utils/cache/CacheRefreshUtils.java | 72 ++++++++++ .../awssdk/utils/cache/CachedSupplier.java | 11 +- .../utils/cache/CacheRefreshUtilsTest.java | 119 ++++++++++++++++ .../utils/cache/CachedSupplierTest.java | 43 ++++++ 16 files changed, 496 insertions(+), 58 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..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(); From ea9337b710982cf68542e9bf720f4848fa79e330 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Fri, 17 Jul 2026 07:54:57 -0700 Subject: [PATCH 5/6] [Credentials Cache PR 5] Add IdentityProvider.invalidate (#7108) --- .../amazon/awssdk/spotbugs-suppressions.xml | 1 + .../codegen/poet/client/ClientClassUtils.java | 3 +- .../poet/rules/EndpointResolverUtilsSpec.java | 12 +- ...gned-payload-trait-async-client-class.java | 3 +- ...igned-payload-trait-sync-client-class.java | 3 +- .../endpoint-resolve-interceptor-preSra.java | 4 +- ...e-interceptor-with-endpointsbasedauth.java | 7 +- ...olve-interceptor-with-multiauthsigv4a.java | 7 +- ...-resolve-interceptor-with-stringarray.java | 4 +- .../rules/endpoint-resolve-interceptor.java | 4 +- ...esolver-utils-with-endpointsbasedauth.java | 4 +- ...t-resolver-utils-with-multiauthsigv4a.java | 4 +- ...point-resolver-utils-with-stringarray.java | 4 +- .../poet/rules/endpoint-resolver-utils.java | 4 +- .../AwsCredentialsProviderChain.java | 39 +++ .../ContainerCredentialsProvider.java | 9 + .../DefaultCredentialsProvider.java | 7 + .../InstanceProfileCredentialsProvider.java | 9 + .../ProcessCredentialsProvider.java | 13 +- .../ProfileCredentialsProvider.java | 12 + ...bIdentityTokenFileCredentialsProvider.java | 10 + .../internal/LazyAwsCredentialsProvider.java | 11 + .../AwsCredentialsProviderChainTest.java | 106 +++++++ ...nstanceProfileCredentialsProviderTest.java | 55 ++++ .../LazyAwsCredentialsProviderTest.java | 25 ++ .../exception/AwsServiceException.java | 13 + .../awssdk/awscore/internal/AwsErrorCode.java | 10 + .../awssdk/identity/spi/IdentityProvider.java | 18 ++ .../awssdk/core/SelectedAuthScheme.java | 95 ++++++ .../core/exception/SdkServiceException.java | 14 + .../core/http/auth/AuthSchemeResolver.java | 38 ++- .../stages/AuthSchemeResolutionStage.java | 7 +- .../utils/AuthErrorInvalidationHelper.java | 98 ++++++ .../stages/utils/RetryableStageHelper.java | 3 + .../AuthErrorInvalidationHelperTest.java | 293 ++++++++++++++++++ .../signin/auth/LoginCredentialsProvider.java | 20 +- ...oginProfileCredentialsProviderFactory.java | 7 + .../sso/auth/SsoCredentialsProvider.java | 11 +- .../SsoProfileCredentialsProviderFactory.java | 7 + .../sso/auth/SsoCredentialsProviderTest.java | 69 +++++ ...ProfileCredentialsProviderFactoryTest.java | 6 +- .../sts/auth/StsCredentialsProvider.java | 9 + ...bIdentityTokenFileCredentialsProvider.java | 10 + .../StsProfileCredentialsProviderFactory.java | 7 + .../auth/StsCredentialsProviderTestBase.java | 51 +++ .../18fc8858-1308-4d5d-b92d-87817d2fab53 | 3 - .../4195d6e3-8849-4e5a-848d-04f810577cd3 | 6 +- .../AuthErrorInvalidationFunctionalTest.java | 225 ++++++++++++++ .../awssdk/utils/cache/CacheRefreshUtils.java | 2 +- .../awssdk/utils/cache/CachedSupplier.java | 145 ++++++--- .../utils/cache/CachedSupplierTest.java | 188 +++++++++-- 51 files changed, 1584 insertions(+), 131 deletions(-) create mode 100644 core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelper.java create mode 100644 core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelperTest.java create mode 100644 test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationFunctionalTest.java diff --git a/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml b/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml index 05606dc6d574..36cfc8b66070 100644 --- a/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml +++ b/build-tools/src/main/resources/software/amazon/awssdk/spotbugs-suppressions.xml @@ -345,6 +345,7 @@ + diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/client/ClientClassUtils.java b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/client/ClientClassUtils.java index 9684be34b02e..353593d6d4b6 100644 --- a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/client/ClientClassUtils.java +++ b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/client/ClientClassUtils.java @@ -564,8 +564,7 @@ static MethodSpec resolveEndpointMethod(AuthSchemeSpecUtils authSchemeSpecUtils, authSchemeOption.nestedClass("Builder")); b.addStatement("$1T rs = $1T.create(endpointParams.region().id())", regionSet); b.addStatement("optionBuilder.putSignerProperty($T.REGION_SET, rs)", awsV4aHttpSigner); - b.addStatement("selectedAuthScheme = new $T(selectedAuthScheme.identity(), selectedAuthScheme.signer(), " - + "optionBuilder.build())", SelectedAuthScheme.class); + b.addStatement("selectedAuthScheme = selectedAuthScheme.toBuilder().authSchemeOption(optionBuilder.build()).build()"); b.endControlFlow(); } diff --git a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules/EndpointResolverUtilsSpec.java b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules/EndpointResolverUtilsSpec.java index 84677fcae0cd..013a6a54d602 100644 --- a/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules/EndpointResolverUtilsSpec.java +++ b/codegen/src/main/java/software/amazon/awssdk/codegen/poet/rules/EndpointResolverUtilsSpec.java @@ -602,7 +602,9 @@ private static CodeBlock copyV4EndpointSignerPropertiesToAuth() { code.addStatement("option.putSignerProperty($T.SERVICE_SIGNING_NAME, v4AuthScheme.signingName())", AwsV4HttpSigner.class); code.endControlFlow(); - code.addStatement("return new $T<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build())", + code.addStatement("return $T.builder().identity(selectedAuthScheme.identity())" + + ".signer(selectedAuthScheme.signer()).authSchemeOption(option.build())" + + ".identityProvider(selectedAuthScheme.identityProvider()).build()", SelectedAuthScheme.class); code.endControlFlow(); return code.build(); @@ -631,7 +633,9 @@ private CodeBlock copyV4aEndpointSignerPropertiesToAuth() { code.addStatement("option.putSignerProperty($T.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName())", AwsV4aHttpSigner.class); code.endControlFlow(); - code.addStatement("return new $T<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build())", + code.addStatement("return $T.builder().identity(selectedAuthScheme.identity())" + + ".signer(selectedAuthScheme.signer()).authSchemeOption(option.build())" + + ".identityProvider(selectedAuthScheme.identityProvider()).build()", SelectedAuthScheme.class); code.endControlFlow(); return code.build(); @@ -656,7 +660,9 @@ private CodeBlock copyS3ExpressEndpointSignerPropertiesToAuth() { code.addStatement("option.putSignerProperty($T.SERVICE_SIGNING_NAME, s3ExpressAuthScheme.signingName())", AwsV4HttpSigner.class); code.endControlFlow(); - code.addStatement("return new $T<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build())", + code.addStatement("return $T.builder().identity(selectedAuthScheme.identity())" + + ".signer(selectedAuthScheme.signer()).authSchemeOption(option.build())" + + ".identityProvider(selectedAuthScheme.identityProvider()).build()", SelectedAuthScheme.class); code.endControlFlow(); return code.build(); diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/client/test-unsigned-payload-trait-async-client-class.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/client/test-unsigned-payload-trait-async-client-class.java index 3e76e9b15269..4d224bbe73a3 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/client/test-unsigned-payload-trait-async-client-class.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/client/test-unsigned-payload-trait-async-client-class.java @@ -1080,8 +1080,7 @@ private Endpoint resolveEndpoint(SdkRequest request, ExecutionAttributes executi AuthSchemeOption.Builder optionBuilder = selectedAuthScheme.authSchemeOption().toBuilder(); RegionSet rs = RegionSet.create(endpointParams.region().id()); optionBuilder.putSignerProperty(AwsV4aHttpSigner.REGION_SET, rs); - selectedAuthScheme = new SelectedAuthScheme(selectedAuthScheme.identity(), selectedAuthScheme.signer(), - optionBuilder.build()); + selectedAuthScheme = selectedAuthScheme.toBuilder().authSchemeOption(optionBuilder.build()).build(); } executionAttributes.putAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME, selectedAuthScheme); } diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/client/test-unsigned-payload-trait-sync-client-class.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/client/test-unsigned-payload-trait-sync-client-class.java index 4d7ce7f0f45c..1050590421f9 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/client/test-unsigned-payload-trait-sync-client-class.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/client/test-unsigned-payload-trait-sync-client-class.java @@ -965,8 +965,7 @@ private Endpoint resolveEndpoint(SdkRequest request, ExecutionAttributes executi AuthSchemeOption.Builder optionBuilder = selectedAuthScheme.authSchemeOption().toBuilder(); RegionSet rs = RegionSet.create(endpointParams.region().id()); optionBuilder.putSignerProperty(AwsV4aHttpSigner.REGION_SET, rs); - selectedAuthScheme = new SelectedAuthScheme(selectedAuthScheme.identity(), selectedAuthScheme.signer(), - optionBuilder.build()); + selectedAuthScheme = selectedAuthScheme.toBuilder().authSchemeOption(optionBuilder.build()).build(); } executionAttributes.putAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME, selectedAuthScheme); } diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-preSra.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-preSra.java index cf27b03ed0c3..792febf9c653 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-preSra.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-preSra.java @@ -177,7 +177,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -191,7 +191,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-endpointsbasedauth.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-endpointsbasedauth.java index 6d6aa1dc2138..944d03995440 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-endpointsbasedauth.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-endpointsbasedauth.java @@ -81,8 +81,7 @@ public SdkRequest modifyRequest(Context.ModifyRequest context, ExecutionAttribut AuthSchemeOption.Builder optionBuilder = selectedAuthScheme.authSchemeOption().toBuilder(); RegionSet regionSet = RegionSet.create(endpointParams.region().id()); optionBuilder.putSignerProperty(AwsV4aHttpSigner.REGION_SET, regionSet); - selectedAuthScheme = new SelectedAuthScheme(selectedAuthScheme.identity(), selectedAuthScheme.signer(), - optionBuilder.build()); + selectedAuthScheme = selectedAuthScheme.toBuilder().authSchemeOption(optionBuilder.build()).build(); } executionAttributes.putAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME, selectedAuthScheme); } @@ -172,7 +171,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -188,7 +187,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-multiauthsigv4a.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-multiauthsigv4a.java index 858d39fc7a69..5faf70df4a08 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-multiauthsigv4a.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-multiauthsigv4a.java @@ -70,8 +70,7 @@ public SdkRequest modifyRequest(Context.ModifyRequest context, ExecutionAttribut AuthSchemeOption.Builder optionBuilder = selectedAuthScheme.authSchemeOption().toBuilder(); RegionSet regionSet = RegionSet.create(endpointParams.region().id()); optionBuilder.putSignerProperty(AwsV4aHttpSigner.REGION_SET, regionSet); - selectedAuthScheme = new SelectedAuthScheme(selectedAuthScheme.identity(), selectedAuthScheme.signer(), - optionBuilder.build()); + selectedAuthScheme = selectedAuthScheme.toBuilder().authSchemeOption(optionBuilder.build()).build(); } executionAttributes.putAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME, selectedAuthScheme); } @@ -135,7 +134,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -151,7 +150,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-stringarray.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-stringarray.java index 0f5033376ffc..b86e695868ce 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-stringarray.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-stringarray.java @@ -144,7 +144,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -158,7 +158,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor.java index abdfedab0455..59e6af3b36f5 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor.java @@ -163,7 +163,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -177,7 +177,7 @@ private SelectedAuthScheme authSchemeWithEndpointSignerP if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-endpointsbasedauth.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-endpointsbasedauth.java index ad3b2e674750..5197385b7c28 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-endpointsbasedauth.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-endpointsbasedauth.java @@ -101,7 +101,7 @@ public static SelectedAuthScheme authSchemeWithEndpointS if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -117,7 +117,7 @@ public static SelectedAuthScheme authSchemeWithEndpointS if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-multiauthsigv4a.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-multiauthsigv4a.java index f4a83932a826..1030af0199ca 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-multiauthsigv4a.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-multiauthsigv4a.java @@ -64,7 +64,7 @@ public static SelectedAuthScheme authSchemeWithEndpointS if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -80,7 +80,7 @@ public static SelectedAuthScheme authSchemeWithEndpointS if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-stringarray.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-stringarray.java index aaed43624739..e85d33c4b7dc 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-stringarray.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils-with-stringarray.java @@ -83,7 +83,7 @@ public static SelectedAuthScheme authSchemeWithEndpointS if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -97,7 +97,7 @@ public static SelectedAuthScheme authSchemeWithEndpointS if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); } diff --git a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils.java index 01c7ba43831f..f213bf77f076 100644 --- a/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils.java +++ b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolver-utils.java @@ -101,7 +101,7 @@ public static SelectedAuthScheme authSchemeWithEndpointS if (v4AuthScheme.signingName() != null) { option.putSignerProperty(AwsV4HttpSigner.SERVICE_SIGNING_NAME, v4AuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } if (endpointAuthScheme instanceof SigV4aAuthScheme) { SigV4aAuthScheme v4aAuthScheme = (SigV4aAuthScheme) endpointAuthScheme; @@ -115,7 +115,7 @@ public static SelectedAuthScheme authSchemeWithEndpointS if (v4aAuthScheme.signingName() != null) { option.putSignerProperty(AwsV4aHttpSigner.SERVICE_SIGNING_NAME, v4aAuthScheme.signingName()); } - return new SelectedAuthScheme<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build()); + return SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(option.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); } throw new IllegalArgumentException("Endpoint auth scheme '" + endpointAuthScheme.name() + "' cannot be mapped to the SDK auth scheme. Was it declared in the service's model?"); diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProviderChain.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProviderChain.java index 439fcc987406..f98138cefc57 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProviderChain.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProviderChain.java @@ -20,6 +20,7 @@ import java.util.Collection; import java.util.Collections; import java.util.List; +import java.util.concurrent.CompletableFuture; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; @@ -130,6 +131,44 @@ public AwsCredentials resolveCredentials() { .build(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + if (reuseLastProviderEnabled && lastUsedProvider != null) { + return invalidateProvider(lastUsedProvider, identity) + .exceptionally(e -> { + log.debug(() -> "Failed to invalidate provider " + lastUsedProvider + ": " + e.getMessage(), e); + return null; + }); + } + + CompletableFuture[] futures = credentialsProviders.stream() + .map(provider -> { + try { + return invalidateProvider(provider, identity) + .exceptionally(e -> { + log.debug(() -> "Failed to invalidate provider " + provider + ": " + e.getMessage(), e); + return null; + }); + } catch (Exception e) { + log.debug(() -> "Failed to invalidate provider " + provider + ": " + e.getMessage(), e); + return CompletableFuture.completedFuture(null); + } + }) + .toArray(CompletableFuture[]::new); + return CompletableFuture.allOf(futures); + } + + /** + * Helper to call invalidate with proper type capture, avoiding unchecked casts. + * The identity is always an {@code AwsCredentialsIdentity}, and all providers in this chain + * are {@code IdentityProvider}, so the cast is safe. + */ + @SuppressWarnings("unchecked") + private static CompletableFuture invalidateProvider( + IdentityProvider provider, AwsCredentialsIdentity identity) { + return provider.invalidate((T) identity); + } + @Override public void close() { credentialsProviders.forEach(c -> IoUtils.closeIfCloseable(c, null)); 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 94744da5da6c..ff8d1b170467 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 @@ -34,6 +34,7 @@ import java.util.Map; import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Predicate; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.auth.credentials.internal.ContainerCredentialsRetryPolicy; @@ -43,6 +44,7 @@ import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; import software.amazon.awssdk.core.util.SdkUserAgent; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.regions.util.ResourcesEndpointProvider; import software.amazon.awssdk.regions.util.ResourcesEndpointRetryPolicy; import software.amazon.awssdk.utils.StringUtils; @@ -186,6 +188,13 @@ public AwsCredentials resolveCredentials() { return credentialsCache.get(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + String rejectedAccessKeyId = identity.accessKeyId(); + credentialsCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())); + return CompletableFuture.completedFuture(null); + } + @Override public void close() { credentialsCache.close(); diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/DefaultCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/DefaultCredentialsProvider.java index d9059aa0298e..2a2cd6a7b9fa 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/DefaultCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/DefaultCredentialsProvider.java @@ -16,9 +16,11 @@ package software.amazon.awssdk.auth.credentials; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.auth.credentials.internal.LazyAwsCredentialsProvider; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.profiles.ProfileFile; import software.amazon.awssdk.profiles.ProfileFileSupplier; import software.amazon.awssdk.utils.SdkAutoCloseable; @@ -134,6 +136,11 @@ public AwsCredentials resolveCredentials() { return providerChain.resolveCredentials(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + return providerChain.invalidate(identity); + } + @Override public void close() { providerChain.close(); 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 713bc6cc1cf1..67c09cceac1b 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 @@ -26,6 +26,7 @@ import java.util.Collections; import java.util.Map; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.annotations.SdkTestInternalApi; @@ -37,6 +38,7 @@ import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.exception.SdkServiceException; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.profiles.ProfileFile; import software.amazon.awssdk.profiles.ProfileFileSupplier; import software.amazon.awssdk.profiles.ProfileFileSystemSetting; @@ -167,6 +169,13 @@ public AwsCredentials resolveCredentials() { return credentialsCache.get(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + String rejectedAccessKeyId = identity.accessKeyId(); + credentialsCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())); + return CompletableFuture.completedFuture(null); + } + private RefreshResult refreshCredentials() { if (isLocalCredentialLoadingDisabled()) { throw SdkClientException.create("IMDS credentials have been disabled by environment variable or system property."); 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 17be7f789d8f..8d764e331834 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 @@ -25,8 +25,10 @@ import java.util.Collections; import java.util.List; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.protocols.jsoncore.JsonNode; import software.amazon.awssdk.protocols.jsoncore.JsonNodeParser; import software.amazon.awssdk.utils.DateUtils; @@ -166,6 +168,13 @@ public AwsCredentials resolveCredentials() { return processCredentialCache.get(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + String rejectedAccessKeyId = identity.accessKeyId(); + processCredentialCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())); + return CompletableFuture.completedFuture(null); + } + private RefreshResult refreshCredentials() { try { String processOutput = executeCommand(); @@ -363,7 +372,7 @@ public Builder asyncCredentialUpdateEnabled(Boolean asyncCredentialUpdateEnabled *

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

By default, this is 1 minute.

+ *

By default, this is 1 minute. * * @param staleTime the duration before expiration that triggers mandatory (blocking) refresh */ @@ -389,7 +398,7 @@ public Builder staleTime(Duration staleTime) { *

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.

+ * 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/ProfileCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProfileCredentialsProvider.java index f95dafdb36f3..24b838fb9dc1 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProfileCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ProfileCredentialsProvider.java @@ -17,12 +17,14 @@ import java.util.Objects; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.annotations.SdkTestInternalApi; import software.amazon.awssdk.auth.credentials.internal.ProfileCredentialsUtils; import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.profiles.ProfileFile; import software.amazon.awssdk.profiles.ProfileFileSupplier; import software.amazon.awssdk.profiles.ProfileFileSystemSetting; @@ -158,6 +160,16 @@ public void close() { IoUtils.closeIfCloseable(credentialsProvider, null); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + // Local copy to avoid TOCTOU race on the volatile field during concurrent profile reloads. + AwsCredentialsProvider provider = credentialsProvider; + if (provider != null) { + return provider.invalidate(identity); + } + return CompletableFuture.completedFuture(null); + } + @Override public Builder toBuilder() { return new BuilderImpl(this); 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 e108482b0490..0e507e5022db 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 @@ -20,11 +20,13 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.time.Duration; +import java.util.concurrent.CompletableFuture; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.auth.credentials.internal.WebIdentityCredentialsUtils; import software.amazon.awssdk.auth.credentials.internal.WebIdentityTokenCredentialProperties; import software.amazon.awssdk.core.SdkSystemSetting; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.utils.IoUtils; import software.amazon.awssdk.utils.SdkAutoCloseable; import software.amazon.awssdk.utils.ToString; @@ -163,6 +165,14 @@ public void close() { IoUtils.closeIfCloseable(credentialsProvider, null); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + if (credentialsProvider != null) { + return credentialsProvider.invalidate(identity); + } + return CompletableFuture.completedFuture(null); + } + /** * A builder for creating a custom {@link WebIdentityTokenFileCredentialsProvider}. */ diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProvider.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProvider.java index 6999e25af250..46068658eb7c 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProvider.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProvider.java @@ -15,10 +15,12 @@ package software.amazon.awssdk.auth.credentials.internal; +import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.utils.IoUtils; import software.amazon.awssdk.utils.Lazy; import software.amazon.awssdk.utils.SdkAutoCloseable; @@ -45,6 +47,15 @@ public AwsCredentials resolveCredentials() { return delegate.getValue().resolveCredentials(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + if (delegate.hasValue()) { + return delegate.getValue().invalidate(identity); + } + // If not yet initialized, invalidation is a no-op + return CompletableFuture.completedFuture(null); + } + @Override public void close() { IoUtils.closeIfCloseable(delegate, null); diff --git a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProviderChainTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProviderChainTest.java index 8a59ad26c15c..5af9e0dca76d 100644 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProviderChainTest.java +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/AwsCredentialsProviderChainTest.java @@ -18,8 +18,14 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import java.util.Arrays; +import java.util.concurrent.CompletableFuture; import org.junit.Test; import org.junit.jupiter.api.function.Executable; import software.amazon.awssdk.core.exception.SdkClientException; @@ -172,6 +178,106 @@ private static void assertChainResolvesCorrectly(AwsCredentialsProviderChain cha assertThat(credentials.secretAccessKey()).isEqualTo("secretKey"); } + @Test + public void invalidate_reuseEnabled_lastProviderSet_onlyInvalidatesLastProvider() { + TrackingCredentialsProvider provider1 = new TrackingCredentialsProvider("key1", "secret1"); + TrackingCredentialsProvider provider2 = new TrackingCredentialsProvider("key2", "secret2"); + + AwsCredentialsProviderChain chain = AwsCredentialsProviderChain.builder() + .credentialsProviders(provider1, provider2) + .reuseLastProviderEnabled(true) + .build(); + + // Trigger resolveCredentials so lastUsedProvider is set (provider1 succeeds first) + chain.resolveCredentials(); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); + chain.invalidate(identity).join(); + + assertThat(provider1.invalidateCallCount).isEqualTo(1); + assertThat(provider2.invalidateCallCount).isEqualTo(0); + } + + @Test + public void invalidate_reuseEnabled_lastProviderNotSet_invalidatesAllProviders() { + TrackingCredentialsProvider provider1 = new TrackingCredentialsProvider("key1", "secret1"); + TrackingCredentialsProvider provider2 = new TrackingCredentialsProvider("key2", "secret2"); + + AwsCredentialsProviderChain chain = AwsCredentialsProviderChain.builder() + .credentialsProviders(provider1, provider2) + .reuseLastProviderEnabled(true) + .build(); + + // Do NOT call resolveCredentials — lastUsedProvider is null + AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); + chain.invalidate(identity).join(); + + assertThat(provider1.invalidateCallCount).isEqualTo(1); + assertThat(provider2.invalidateCallCount).isEqualTo(1); + } + + @Test + public void invalidate_reuseDisabled_invalidatesAllProviders() { + TrackingCredentialsProvider provider1 = new TrackingCredentialsProvider("key1", "secret1"); + TrackingCredentialsProvider provider2 = new TrackingCredentialsProvider("key2", "secret2"); + + AwsCredentialsProviderChain chain = AwsCredentialsProviderChain.builder() + .credentialsProviders(provider1, provider2) + .reuseLastProviderEnabled(false) + .build(); + + // Even after resolving, all providers should be invalidated + chain.resolveCredentials(); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); + chain.invalidate(identity).join(); + + assertThat(provider1.invalidateCallCount).isEqualTo(1); + assertThat(provider2.invalidateCallCount).isEqualTo(1); + } + + @SuppressWarnings("unchecked") + @Test + public void invalidate_doesNotShortCircuit_whenChildThrows() { + AwsCredentialsProvider mockProvider1 = mock(AwsCredentialsProvider.class); + AwsCredentialsProvider mockProvider2 = mock(AwsCredentialsProvider.class); + AwsCredentialsProvider mockProvider3 = mock(AwsCredentialsProvider.class); + + doThrow(new RuntimeException("Provider 1 failed")).when(mockProvider1).invalidate(any()); + + AwsCredentialsProviderChain chain = AwsCredentialsProviderChain.builder() + .credentialsProviders(mockProvider1, mockProvider2, mockProvider3) + .reuseLastProviderEnabled(false) + .build(); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); + chain.invalidate(identity).join(); + + verify(mockProvider1, times(1)).invalidate(identity); + verify(mockProvider2, times(1)).invalidate(identity); + verify(mockProvider3, times(1)).invalidate(identity); + } + + private static final class TrackingCredentialsProvider implements AwsCredentialsProvider { + private final AwsBasicCredentials credentials; + int invalidateCallCount = 0; + + TrackingCredentialsProvider(String accessKeyId, String secretAccessKey) { + this.credentials = AwsBasicCredentials.create(accessKeyId, secretAccessKey); + } + + @Override + public AwsCredentials resolveCredentials() { + return credentials; + } + + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + invalidateCallCount++; + return CompletableFuture.completedFuture(null); + } + } + private static final class MockCredentialsProvider implements AwsCredentialsProvider { private final StaticCredentialsProvider staticCredentialsProvider; private final String exceptionMessage; 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 9a5ad4079e65..1b91407efb90 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 @@ -776,6 +776,61 @@ public Instant instant() { } } + @Test + void invalidate_matchingAccessKeyId_invalidatesCache() { + String accessKeyId = "ACCESS_KEY_ID"; + String expirationFarFuture = DateUtils.formatIso8601Date(Instant.now().plus(Duration.ofDays(1))); + String credentialsJson = "{\"AccessKeyId\":\"" + accessKeyId + "\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," + + "\"Expiration\":\"" + expirationFarFuture + "\"}"; + + stubSecureCredentialsResponse(aResponse().withBody(credentialsJson)); + + InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder().build(); + + AwsCredentials firstCredentials = provider.resolveCredentials(); + assertThat(firstCredentials.accessKeyId()).isEqualTo(accessKeyId); + + String newAccessKeyId = "NEW_ACCESS_KEY_ID"; + String newCredentialsJson = "{\"AccessKeyId\":\"" + newAccessKeyId + "\"," + + "\"SecretAccessKey\":\"NEW_SECRET_ACCESS_KEY\"," + + "\"Expiration\":\"" + expirationFarFuture + "\"}"; + stubSecureCredentialsResponse(aResponse().withBody(newCredentialsJson)); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create(accessKeyId, "SECRET_ACCESS_KEY"); + provider.invalidate(identity).join(); + + AwsCredentials refreshedCredentials = provider.resolveCredentials(); + assertThat(refreshedCredentials.accessKeyId()).isEqualTo(newAccessKeyId); + } + + @Test + void invalidate_nonMatchingAccessKeyId_doesNotInvalidateCache() { + String accessKeyId = "ACCESS_KEY_ID"; + String expirationFarFuture = DateUtils.formatIso8601Date(Instant.now().plus(Duration.ofDays(1))); + String credentialsJson = "{\"AccessKeyId\":\"" + accessKeyId + "\"," + + "\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," + + "\"Expiration\":\"" + expirationFarFuture + "\"}"; + + stubSecureCredentialsResponse(aResponse().withBody(credentialsJson)); + + InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder().build(); + + AwsCredentials firstCredentials = provider.resolveCredentials(); + assertThat(firstCredentials.accessKeyId()).isEqualTo(accessKeyId); + + String newCredentialsJson = "{\"AccessKeyId\":\"NEW_ACCESS_KEY_ID\"," + + "\"SecretAccessKey\":\"NEW_SECRET_ACCESS_KEY\"," + + "\"Expiration\":\"" + expirationFarFuture + "\"}"; + stubSecureCredentialsResponse(aResponse().withBody(newCredentialsJson)); + + AwsCredentialsIdentity differentIdentity = AwsBasicCredentials.create("DIFFERENT_KEY", "SECRET"); + provider.invalidate(differentIdentity).join(); + + AwsCredentials secondCredentials = provider.resolveCredentials(); + assertThat(secondCredentials.accessKeyId()).isEqualTo(accessKeyId); + } + private static ProfileFileSupplier supply(Iterable iterable) { return iterable.iterator()::next; } diff --git a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProviderTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProviderTest.java index 43ffcd9cd28e..6d8ed70992ad 100644 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProviderTest.java +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/internal/LazyAwsCredentialsProviderTest.java @@ -15,11 +15,13 @@ package software.amazon.awssdk.auth.credentials.internal; +import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.mockito.Mockito; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.utils.SdkAutoCloseable; public class LazyAwsCredentialsProviderTest { @@ -75,4 +77,27 @@ public void delegatesClosesInitializerEvenIfGetFails() { private interface CloseableSupplier extends Supplier, SdkAutoCloseable {} private interface CloseableCredentialsProvider extends SdkAutoCloseable, AwsCredentialsProvider {} + + @Test + public void invalidate_whenInitialized_delegatesToInner() { + LazyAwsCredentialsProvider credentialsProvider = LazyAwsCredentialsProvider.create(credentialsConstructor); + credentialsProvider.resolveCredentials(); + + AwsCredentialsIdentity identity = Mockito.mock(AwsCredentialsIdentity.class); + Mockito.when(credentials.invalidate(identity)).thenReturn(CompletableFuture.completedFuture(null)); + + credentialsProvider.invalidate(identity); + + Mockito.verify(credentials).invalidate(identity); + } + + @Test + public void invalidate_whenNotInitialized_doesNotInvokeSupplier() { + LazyAwsCredentialsProvider credentialsProvider = LazyAwsCredentialsProvider.create(credentialsConstructor); + + AwsCredentialsIdentity identity = Mockito.mock(AwsCredentialsIdentity.class); + credentialsProvider.invalidate(identity); + + Mockito.verifyNoMoreInteractions(credentialsConstructor); + } } diff --git a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/exception/AwsServiceException.java b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/exception/AwsServiceException.java index 238666a555ce..26a884d0a103 100644 --- a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/exception/AwsServiceException.java +++ b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/exception/AwsServiceException.java @@ -134,6 +134,19 @@ public boolean isThrottlingException() { .orElse(false); } + /** + * Checks if the exception indicates an authentication error where the credentials were rejected, + * based on the AWS error code (e.g., {@code ExpiredToken}, {@code InvalidToken}, {@code AuthFailure}). + * + * @return true if the AWS error code indicates an authentication error, otherwise false. + */ + @Override + public boolean isAuthenticationError() { + return Optional.ofNullable(awsErrorDetails) + .map(a -> AwsErrorCode.isAuthenticationErrorCode(a.errorCode())) + .orElse(false); + } + /** * @return {@link Builder} instance to construct a new {@link AwsServiceException}. */ diff --git a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/AwsErrorCode.java b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/AwsErrorCode.java index acdac5954713..3dabecf6289a 100644 --- a/core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/AwsErrorCode.java +++ b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/AwsErrorCode.java @@ -31,6 +31,7 @@ public final class AwsErrorCode { public static final Set THROTTLING_ERROR_CODES; public static final Set DEFINITE_CLOCK_SKEW_ERROR_CODES; public static final Set POSSIBLE_CLOCK_SKEW_ERROR_CODES; + public static final Set AUTH_ERROR_CODES; static { Set throttlingErrorCodes = new HashSet<>(9); @@ -66,6 +67,11 @@ public final class AwsErrorCode { retryableErrorCodes.add("RequestTimeoutException"); retryableErrorCodes.add("InternalError"); RETRYABLE_ERROR_CODES = unmodifiableSet(retryableErrorCodes); + + Set authErrorCodes = new HashSet<>(2); + authErrorCodes.add("ExpiredToken"); + authErrorCodes.add("InvalidToken"); + AUTH_ERROR_CODES = unmodifiableSet(authErrorCodes); } private AwsErrorCode() { @@ -86,4 +92,8 @@ public static boolean isPossibleClockSkewErrorCode(String errorCode) { public static boolean isRetryableErrorCode(String errorCode) { return RETRYABLE_ERROR_CODES.contains(errorCode); } + + public static boolean isAuthenticationErrorCode(String errorCode) { + return AUTH_ERROR_CODES.contains(errorCode); + } } diff --git a/core/identity-spi/src/main/java/software/amazon/awssdk/identity/spi/IdentityProvider.java b/core/identity-spi/src/main/java/software/amazon/awssdk/identity/spi/IdentityProvider.java index 10a9d3b3bb64..0cab117551db 100644 --- a/core/identity-spi/src/main/java/software/amazon/awssdk/identity/spi/IdentityProvider.java +++ b/core/identity-spi/src/main/java/software/amazon/awssdk/identity/spi/IdentityProvider.java @@ -157,4 +157,22 @@ default CompletableFuture resolveIdentity(Consumer resolveIdentity() { return resolveIdentity(ResolveIdentityRequest.builder().build()); } + + /** + * Invalidate cached credentials associated with the given rejected identity. + * + *

When a target service rejects credentials with an authentication error, + * the SDK calls this method so the provider can mark its cache for refresh. + * The next call to {@link #resolveIdentity} will attempt to fetch fresh credentials. + * + *

Implementations MUST only invalidate if the currently-cached identity matches + * the rejected identity (e.g., same access key ID). + * + *

The default implementation is a no-op, suitable for providers that do not cache. + * + * @param identity The identity that was rejected by the service. + */ + default CompletableFuture invalidate(IdentityT identity) { + return CompletableFuture.completedFuture(null); + } } diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/SelectedAuthScheme.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/SelectedAuthScheme.java index 8772260e3042..26f1645692d0 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/SelectedAuthScheme.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/SelectedAuthScheme.java @@ -20,6 +20,7 @@ import software.amazon.awssdk.http.auth.spi.scheme.AuthSchemeOption; import software.amazon.awssdk.http.auth.spi.signer.HttpSigner; import software.amazon.awssdk.identity.spi.Identity; +import software.amazon.awssdk.identity.spi.IdentityProvider; import software.amazon.awssdk.utils.Validate; @@ -42,19 +43,33 @@ /// - identity: CompletableFuture ← the resolved identity! /// - signer: HttpSigner /// - authSchemeOption: AuthSchemeOption +/// - identityProvider: IdentityProvider ← the provider that resolved the identity /// ``` @SdkProtectedApi public final class SelectedAuthScheme { private final CompletableFuture identity; private final HttpSigner signer; private final AuthSchemeOption authSchemeOption; + private final IdentityProvider identityProvider; + /** + * @deprecated Use {@link #builder()} instead. + */ + @Deprecated public SelectedAuthScheme(CompletableFuture identity, HttpSigner signer, AuthSchemeOption authSchemeOption) { this.identity = Validate.paramNotNull(identity, "identity"); this.signer = Validate.paramNotNull(signer, "signer"); this.authSchemeOption = Validate.paramNotNull(authSchemeOption, "authSchemeOption"); + this.identityProvider = null; + } + + private SelectedAuthScheme(BuilderImpl builder) { + this.identity = Validate.paramNotNull(builder.identity, "identity"); + this.signer = Validate.paramNotNull(builder.signer, "signer"); + this.authSchemeOption = Validate.paramNotNull(builder.authSchemeOption, "authSchemeOption"); + this.identityProvider = builder.identityProvider; } public CompletableFuture identity() { @@ -68,4 +83,84 @@ public HttpSigner signer() { public AuthSchemeOption authSchemeOption() { return authSchemeOption; } + + public IdentityProvider identityProvider() { + return identityProvider; + } + + /** + * Returns a builder initialized with the values from this instance, allowing modification + * of individual fields while preserving the rest. + */ + public Builder toBuilder() { + return new BuilderImpl() + .identity(this.identity) + .signer(this.signer) + .authSchemeOption(this.authSchemeOption) + .identityProvider(this.identityProvider); + } + + public static Builder builder() { + return new BuilderImpl<>(); + } + + public interface Builder { + /** + * The resolved identity future for this auth scheme. + */ + Builder identity(CompletableFuture identity); + + /** + * The signer to use for this auth scheme. + */ + Builder signer(HttpSigner signer); + + /** + * The auth scheme option containing signer and identity properties. + */ + Builder authSchemeOption(AuthSchemeOption authSchemeOption); + + /** + * The identity provider that resolved the identity. Used for invalidation on auth errors. + */ + Builder identityProvider(IdentityProvider identityProvider); + + SelectedAuthScheme build(); + } + + private static final class BuilderImpl implements Builder { + private CompletableFuture identity; + private HttpSigner signer; + private AuthSchemeOption authSchemeOption; + private IdentityProvider identityProvider; + + @Override + public Builder identity(CompletableFuture identity) { + this.identity = identity; + return this; + } + + @Override + public Builder signer(HttpSigner signer) { + this.signer = signer; + return this; + } + + @Override + public Builder authSchemeOption(AuthSchemeOption authSchemeOption) { + this.authSchemeOption = authSchemeOption; + return this; + } + + @Override + public Builder identityProvider(IdentityProvider identityProvider) { + this.identityProvider = identityProvider; + return this; + } + + @Override + public SelectedAuthScheme build() { + return new SelectedAuthScheme<>(this); + } + } } diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/SdkServiceException.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/SdkServiceException.java index 6403c4e74fdc..46138fa0fd79 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/SdkServiceException.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/exception/SdkServiceException.java @@ -102,6 +102,20 @@ public boolean isRetryableException() { return false; } + /** + * Specifies whether an exception indicates an authentication error where the credentials used for the request + * were rejected because they are invalid or expired. This method must return {@code false} for authorization + * errors such as {@code AccessDenied}, where the credentials are valid but lack permission for the requested action. + * + *

When this returns {@code true}, the SDK may invalidate cached credentials so that the next attempt + * resolves fresh ones. + * + * @return true if the exception is classified as an authentication error, otherwise false. + */ + public boolean isAuthenticationError() { + return false; + } + /** * @return {@link Builder} instance to construct a new {@link SdkServiceException}. */ diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/http/auth/AuthSchemeResolver.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/http/auth/AuthSchemeResolver.java index ac795a34c494..51f46a5ffb1b 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/http/auth/AuthSchemeResolver.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/http/auth/AuthSchemeResolver.java @@ -155,11 +155,12 @@ public static SelectedAuthScheme mergePreExistingAuthSch AuthSchemeOption.Builder mergedOption = selectedAuthScheme.authSchemeOption().toBuilder(); existingAuthScheme.authSchemeOption().forEachSignerProperty(mergedOption::putSignerPropertyIfAbsent); existingAuthScheme.authSchemeOption().forEachIdentityProperty(mergedOption::putIdentityPropertyIfAbsent); - return new SelectedAuthScheme<>( - selectedAuthScheme.identity(), - selectedAuthScheme.signer(), - mergedOption.build() - ); + return SelectedAuthScheme.builder() + .identity(selectedAuthScheme.identity()) + .signer(selectedAuthScheme.signer()) + .authSchemeOption(mergedOption.build()) + .identityProvider(selectedAuthScheme.identityProvider()) + .build(); } // Start with the freshly resolved auth scheme as the base. @@ -181,11 +182,12 @@ public void accept(SignerProperty key, S value) { existingAuthScheme.authSchemeOption().forEachIdentityProperty(mergedOption::putIdentityPropertyIfAbsent); - return new SelectedAuthScheme<>( - selectedAuthScheme.identity(), - selectedAuthScheme.signer(), - mergedOption.build() - ); + return SelectedAuthScheme.builder() + .identity(selectedAuthScheme.identity()) + .signer(selectedAuthScheme.signer()) + .authSchemeOption(mergedOption.build()) + .identityProvider(selectedAuthScheme.identityProvider()) + .build(); } /** @@ -241,9 +243,12 @@ public void accept(SignerProperty key, S value) { // Only update SELECTED_AUTH_SCHEME if at least one property was re-applied. if (changed[0]) { attrs.putAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME, - new SelectedAuthScheme<>(currentScheme.identity(), - currentScheme.signer(), - mergedOption.build())); + SelectedAuthScheme.builder() + .identity(currentScheme.identity()) + .signer(currentScheme.signer()) + .authSchemeOption(mergedOption.build()) + .identityProvider(currentScheme.identityProvider()) + .build()); } } @@ -281,7 +286,12 @@ private static SelectedAuthScheme trySelectAuthScheme( CompletableFuture identity = resolveIdentity( identityProvider, identityRequestBuilder.build(), metricCollector); - return new SelectedAuthScheme<>(identity, signer, authOption); + return SelectedAuthScheme.builder() + .identity(identity) + .signer(signer) + .authSchemeOption(authOption) + .identityProvider(identityProvider) + .build(); } private static CompletableFuture resolveIdentity( diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AuthSchemeResolutionStage.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AuthSchemeResolutionStage.java index 831340b29d16..ae1a2a941c74 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AuthSchemeResolutionStage.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/AuthSchemeResolutionStage.java @@ -151,7 +151,12 @@ private void updateIdentityOnExistingScheme(SelectedAuthSch } CompletableFuture identity = identityProvider.resolveIdentity(ResolveIdentityRequest.builder().build()); executionAttributes.putAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME, - new SelectedAuthScheme<>(identity, existing.signer(), existing.authSchemeOption())); + SelectedAuthScheme.builder() + .identity(identity) + .signer(existing.signer()) + .authSchemeOption(existing.authSchemeOption()) + .identityProvider(identityProvider) + .build()); } private void recordBusinessMetrics(SelectedAuthScheme selectedAuthScheme, diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelper.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelper.java new file mode 100644 index 000000000000..e27c1f7176fd --- /dev/null +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelper.java @@ -0,0 +1,98 @@ +/* + * 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.core.internal.http.pipeline.stages.utils; + +import software.amazon.awssdk.annotations.SdkInternalApi; +import software.amazon.awssdk.core.SelectedAuthScheme; +import software.amazon.awssdk.core.exception.SdkServiceException; +import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute; +import software.amazon.awssdk.core.internal.http.RequestExecutionContext; +import software.amazon.awssdk.identity.spi.Identity; +import software.amazon.awssdk.identity.spi.IdentityProvider; +import software.amazon.awssdk.utils.CompletableFutureUtils; +import software.amazon.awssdk.utils.Logger; + +/** + * Utility that detects authentication error responses and triggers credential invalidation + * on the identity provider that produced the rejected credentials. + * + *

When a service returns an authentication error (as determined by + * {@link SdkServiceException#isAuthenticationError()}), this helper retrieves the + * {@link SelectedAuthScheme} from the execution context and calls + * {@link IdentityProvider#invalidate} so the next retry attempt resolves fresh credentials. + * + *

All exceptions from the invalidation path are caught and logged at debug level. + * Invalidation failures never disrupt the normal request/retry flow. + */ +@SdkInternalApi +public final class AuthErrorInvalidationHelper { + + private static final Logger LOG = Logger.loggerFor(AuthErrorInvalidationHelper.class); + + private AuthErrorInvalidationHelper() { + } + + /** + * Checks whether the given exception is an auth error that should trigger + * credential invalidation. If so, retrieves the identity provider from the + * {@link SelectedAuthScheme} and calls invalidate() on it. + * + * @param exception The exception from the failed request attempt + * @param context The request execution context containing auth scheme info + */ + public static void invalidateIfAuthError(Throwable exception, RequestExecutionContext context) { + if (!(exception instanceof SdkServiceException)) { + return; + } + + SdkServiceException serviceException = (SdkServiceException) exception; + if (!serviceException.isAuthenticationError()) { + return; + } + + SelectedAuthScheme selectedAuthScheme = + context.executionAttributes().getAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME); + + if (selectedAuthScheme == null || selectedAuthScheme.identityProvider() == null) { + return; + } + + try { + doInvalidate(selectedAuthScheme); + } catch (Exception e) { + LOG.debug(() -> "Failed to invalidate identity provider after auth error: " + e.getMessage(), e); + } + } + + private static void doInvalidate(SelectedAuthScheme selectedAuthScheme) { + T resolvedIdentity = CompletableFutureUtils.joinLikeSync(selectedAuthScheme.identity()); + IdentityProvider provider = selectedAuthScheme.identityProvider(); + // Invalidation is best-effort and must not block the request/retry path. + // Most CredentialProvider invalidation implementations invalidate synchronously and return instantly + // but handle the future here. + try { + provider.invalidate(resolvedIdentity) + .exceptionally(e -> { + if (e != null) { + LOG.debug(() -> "Failed to invalidate identity provider: " + e.getMessage(), e); + } + return null; + }); + } catch (RuntimeException e) { + LOG.debug(() -> "Failed to invalidate identity provider: " + e.getMessage(), e); + } + } +} diff --git a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelper.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelper.java index 840df78367fd..35d4dab90b8a 100644 --- a/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelper.java +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/RetryableStageHelper.java @@ -138,6 +138,9 @@ public void recordAttemptSucceeded() { * code should not retry. */ public Either tryRefreshToken(Duration suggestedDelay) { + // Invalidate cached credentials if this failure is an auth error, before the retry strategy evaluates. + AuthErrorInvalidationHelper.invalidateIfAuthError(this.lastException, context); + RetryToken retryToken = context.executionAttributes().getAttribute(RETRY_TOKEN); RefreshRetryTokenResponse refreshResponse; try { diff --git a/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelperTest.java b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelperTest.java new file mode 100644 index 000000000000..62b8b6e4aca9 --- /dev/null +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelperTest.java @@ -0,0 +1,293 @@ +/* + * 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.core.internal.http.pipeline.stages.utils; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatNoException; +import static org.mockito.Mockito.mock; + +import java.io.IOException; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.atomic.AtomicReference; +import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import software.amazon.awssdk.core.SdkRequest; +import software.amazon.awssdk.core.SelectedAuthScheme; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.exception.SdkServiceException; +import software.amazon.awssdk.core.http.ExecutionContext; +import software.amazon.awssdk.core.interceptor.ExecutionAttributes; +import software.amazon.awssdk.core.interceptor.SdkInternalExecutionAttribute; +import software.amazon.awssdk.core.internal.http.RequestExecutionContext; +import software.amazon.awssdk.http.auth.spi.scheme.AuthSchemeOption; +import software.amazon.awssdk.http.auth.spi.signer.HttpSigner; +import software.amazon.awssdk.identity.spi.Identity; +import software.amazon.awssdk.identity.spi.IdentityProvider; +import software.amazon.awssdk.identity.spi.ResolveIdentityRequest; + +/** + * Unit tests for {@link AuthErrorInvalidationHelper}. + */ +public class AuthErrorInvalidationHelperTest { + + @ParameterizedTest + @ValueSource(strings = {"ExpiredToken", "InvalidToken"}) + void invalidateIfAuthError_whenAuthErrorCode_triggersInvalidation(String errorCode) { + TrackingIdentityProvider provider = new TrackingIdentityProvider(); + TestIdentity identity = new TestIdentity(); + RequestExecutionContext context = contextWithProvider(provider, identity); + Throwable exception = serviceExceptionWithErrorCode(errorCode); + + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context); + + assertThat(provider.invalidateCalled()).isTrue(); + assertThat(provider.lastInvalidatedIdentity()).isSameAs(identity); + } + + @Test + void invalidateIfAuthError_whenAccessDenied_doesNotTriggerInvalidation() { + TrackingIdentityProvider provider = new TrackingIdentityProvider(); + TestIdentity identity = new TestIdentity(); + RequestExecutionContext context = contextWithProvider(provider, identity); + Throwable exception = serviceExceptionWithErrorCode("AccessDenied"); + + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context); + + assertThat(provider.invalidateCalled()).isFalse(); + } + + @ParameterizedTest + @ValueSource(strings = {"ThrottlingException", "InternalServerError", "ValidationException", "ResourceNotFoundException", "AuthFailure"}) + void invalidateIfAuthError_whenUnknownErrorCode_doesNotTriggerInvalidation(String errorCode) { + TrackingIdentityProvider provider = new TrackingIdentityProvider(); + TestIdentity identity = new TestIdentity(); + RequestExecutionContext context = contextWithProvider(provider, identity); + Throwable exception = serviceExceptionWithErrorCode(errorCode); + + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context); + + assertThat(provider.invalidateCalled()).isFalse(); + } + + @ParameterizedTest + @MethodSource("nonServiceExceptions") + void invalidateIfAuthError_whenNonSdkServiceException_doesNotTriggerInvalidation(Throwable exception) { + TrackingIdentityProvider provider = new TrackingIdentityProvider(); + TestIdentity identity = new TestIdentity(); + RequestExecutionContext context = contextWithProvider(provider, identity); + + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context); + + assertThat(provider.invalidateCalled()).isFalse(); + } + + static Stream nonServiceExceptions() { + return Stream.of( + new RuntimeException("something went wrong"), + SdkClientException.create("client error"), + new IOException("io error") + ); + } + + @Test + void invalidateIfAuthError_whenSelectedAuthSchemeIsNull_doesNotThrow() { + RequestExecutionContext context = contextWithNoAuthScheme(); + Throwable exception = serviceExceptionWithErrorCode("ExpiredToken"); + + assertThatNoException().isThrownBy(() -> + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context) + ); + } + + @Test + void invalidateIfAuthError_whenIdentityProviderIsNull_doesNotThrow() { + TestIdentity identity = new TestIdentity(); + SelectedAuthScheme selectedAuthScheme = new SelectedAuthScheme<>( + CompletableFuture.completedFuture(identity), + mockSigner(), + AuthSchemeOption.builder().schemeId("test").build() + ); + + RequestExecutionContext context = contextWithSelectedAuthScheme(selectedAuthScheme); + Throwable exception = serviceExceptionWithErrorCode("ExpiredToken"); + + assertThatNoException().isThrownBy(() -> + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context) + ); + } + + @Test + void invalidateIfAuthError_whenInvalidateThrowsException_doesNotPropagate() { + ThrowingIdentityProvider provider = new ThrowingIdentityProvider(); + TestIdentity identity = new TestIdentity(); + SelectedAuthScheme selectedAuthScheme = SelectedAuthScheme.builder() + .identity(CompletableFuture.completedFuture(identity)) + .signer(mockSigner()) + .authSchemeOption(AuthSchemeOption.builder().schemeId("test").build()) + .identityProvider(provider) + .build(); + + RequestExecutionContext context = contextWithSelectedAuthScheme(selectedAuthScheme); + Throwable exception = serviceExceptionWithErrorCode("ExpiredToken"); + + assertThatNoException().isThrownBy(() -> + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context) + ); + } + + // --- Helper methods --- + + private RequestExecutionContext contextWithProvider(TrackingIdentityProvider provider, TestIdentity identity) { + SelectedAuthScheme selectedAuthScheme = SelectedAuthScheme.builder() + .identity(CompletableFuture.completedFuture(identity)) + .signer(mockSigner()) + .authSchemeOption(AuthSchemeOption.builder().schemeId("test").build()) + .identityProvider(provider) + .build(); + return contextWithSelectedAuthScheme(selectedAuthScheme); + } + + private RequestExecutionContext contextWithSelectedAuthScheme(SelectedAuthScheme selectedAuthScheme) { + ExecutionAttributes executionAttributes = ExecutionAttributes.builder() + .put(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME, selectedAuthScheme) + .build(); + + ExecutionContext executionContext = ExecutionContext.builder() + .executionAttributes(executionAttributes) + .build(); + + return RequestExecutionContext.builder() + .executionContext(executionContext) + .originalRequest(mock(SdkRequest.class)) + .build(); + } + + private RequestExecutionContext contextWithNoAuthScheme() { + ExecutionAttributes executionAttributes = ExecutionAttributes.builder().build(); + + ExecutionContext executionContext = ExecutionContext.builder() + .executionAttributes(executionAttributes) + .build(); + + return RequestExecutionContext.builder() + .executionContext(executionContext) + .originalRequest(mock(SdkRequest.class)) + .build(); + } + + /** + * Creates an SdkServiceException subclass that overrides {@code isAuthenticationError()} to + * return true for the known auth error codes. + */ + private Throwable serviceExceptionWithErrorCode(String errorCode) { + return new TestAwsServiceException(errorCode); + } + + @SuppressWarnings("unchecked") + private static HttpSigner mockSigner() { + return (HttpSigner) mock(HttpSigner.class); + } + + // --- Test doubles --- + + /** + * A simple identity implementation for testing. + */ + private static class TestIdentity implements Identity { + } + + /** + * An identity provider that tracks whether invalidate() was called. + */ + private static class TrackingIdentityProvider implements IdentityProvider { + private final AtomicBoolean invalidateCalled = new AtomicBoolean(false); + private final AtomicReference lastInvalidatedIdentity = new AtomicReference<>(); + + @Override + public Class identityType() { + return TestIdentity.class; + } + + @Override + public CompletableFuture resolveIdentity(ResolveIdentityRequest request) { + return CompletableFuture.completedFuture(new TestIdentity()); + } + + @Override + public CompletableFuture invalidate(TestIdentity identity) { + invalidateCalled.set(true); + lastInvalidatedIdentity.set(identity); + return CompletableFuture.completedFuture(null); + } + + boolean invalidateCalled() { + return invalidateCalled.get(); + } + + TestIdentity lastInvalidatedIdentity() { + return lastInvalidatedIdentity.get(); + } + } + + /** + * An identity provider that throws on invalidate() — used to test exception isolation. + */ + private static class ThrowingIdentityProvider implements IdentityProvider { + @Override + public Class identityType() { + return TestIdentity.class; + } + + @Override + public CompletableFuture resolveIdentity(ResolveIdentityRequest request) { + return CompletableFuture.completedFuture(new TestIdentity()); + } + + @Override + public CompletableFuture invalidate(TestIdentity identity) { + throw new RuntimeException("Simulated invalidation failure"); + } + } + + /** + * Simulates an AwsServiceException that reports authentication errors via the + * {@link SdkServiceException#isAuthenticationError()} virtual method. + */ + private static class TestAwsServiceException extends SdkServiceException { + private static final Set AUTH_ERROR_CODES = new HashSet<>(Arrays.asList( + "ExpiredToken", "InvalidToken" + )); + + private final String errorCode; + + TestAwsServiceException(String errorCode) { + super(SdkServiceException.builder().message("test exception").statusCode(401)); + this.errorCode = errorCode; + } + + @Override + public boolean isAuthenticationError() { + return AUTH_ERROR_CODES.contains(errorCode); + } + } +} 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 be2b61486860..c36369411ff5 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 @@ -25,6 +25,7 @@ import java.time.Duration; import java.time.Instant; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import software.amazon.awssdk.annotations.NotThreadSafe; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.annotations.SdkPublicApi; @@ -35,6 +36,7 @@ import software.amazon.awssdk.core.SdkPlugin; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.signin.SigninClient; import software.amazon.awssdk.services.signin.internal.AccessTokenManager; import software.amazon.awssdk.services.signin.internal.DpopAuthPlugin; @@ -129,7 +131,7 @@ private LoginCredentialsProvider(BuilderImpl builder) { CachedSupplier.builder(this::updateSigninCredentials) .cachedValueName(toString()) .staleValueBehavior(ALLOW) - .cacheInvalidatingPredicate(LoginCredentialsProvider::isCacheInvalidating); + .nonRecoverableErrorPredicate(LoginCredentialsProvider::isNonRecoverableError); if (builder.asyncCredentialUpdateEnabled) { cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME)); } @@ -172,8 +174,7 @@ && shouldNotRefresh(currentExpirationTime, effectivePrefetch)) { private RefreshResult refreshFromSigninService(LoginAccessToken tokenFromDisc) { log.debug(() -> "Credentials are near expiration/expired, refreshing from Signin service."); - try { - SdkPlugin dpopAuthPlugin = DpopAuthPlugin.create(tokenFromDisc.getDpopKey()); + try (SdkPlugin dpopAuthPlugin = DpopAuthPlugin.create(tokenFromDisc.getDpopKey())) { CreateOAuth2TokenRequest refreshRequest = CreateOAuth2TokenRequest .builder() @@ -217,11 +218,11 @@ private RefreshResult refreshFromSigninService(LoginAccessToken switch (accessDeniedException.error()) { case TOKEN_EXPIRED: case USER_CREDENTIALS_CHANGED: - // Let the original AccessDeniedException propagate — the cacheInvalidatingPredicate + // Let the original AccessDeniedException propagate — the nonRecoverableErrorPredicate // on CachedSupplier will identify it and bypass static stability. throw accessDeniedException; case INSUFFICIENT_PERMISSIONS: - // Wrap with a helpful message, but still cache-invalidating — the predicate checks the cause. + // Wrap with a helpful message, but still non-recoverable — the predicate checks the cause. throw SdkClientException.create( "Unable to refresh credentials due to insufficient permissions. You may be missing permission " + "for the 'CreateOAuth2Token' action.", @@ -243,7 +244,7 @@ private RefreshResult refreshFromSigninService(LoginAccessToken * {@link OAuth2ErrorCode#USER_CREDENTIALS_CHANGED}, or {@link OAuth2ErrorCode#INSUFFICIENT_PERMISSIONS} * */ - private static boolean isCacheInvalidating(RuntimeException e) { + private static boolean isNonRecoverableError(RuntimeException e) { if (e instanceof InvalidTokenException) { return true; } @@ -295,6 +296,13 @@ public AwsCredentials resolveCredentials() { return credentialCache.get(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + String rejectedAccessKeyId = identity.accessKeyId(); + credentialCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())); + return CompletableFuture.completedFuture(null); + } + @Override public void close() { credentialCache.close(); diff --git a/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginProfileCredentialsProviderFactory.java b/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginProfileCredentialsProviderFactory.java index ae5fa97e8589..b1c65e09928d 100644 --- a/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginProfileCredentialsProviderFactory.java +++ b/services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginProfileCredentialsProviderFactory.java @@ -15,11 +15,13 @@ package software.amazon.awssdk.services.signin.auth; +import java.util.concurrent.CompletableFuture; import software.amazon.awssdk.annotations.SdkProtectedApi; import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.ProfileCredentialsProviderFactory; import software.amazon.awssdk.auth.credentials.ProfileProviderCredentialsContext; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.profiles.Profile; import software.amazon.awssdk.profiles.ProfileProperty; import software.amazon.awssdk.services.signin.SigninClient; @@ -66,6 +68,11 @@ public AwsCredentials resolveCredentials() { return this.credentialsProvider.resolveCredentials(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + return this.credentialsProvider.invalidate(identity); + } + @Override public void close() { IoUtils.closeQuietly(credentialsProvider, null); 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 d81ae3dba549..7b341cc0367d 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 @@ -22,12 +22,14 @@ import java.time.Duration; import java.time.Instant; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.sso.SsoClient; import software.amazon.awssdk.services.sso.internal.SessionCredentialsHolder; import software.amazon.awssdk.services.sso.model.GetRoleCredentialsRequest; @@ -99,7 +101,7 @@ private SsoCredentialsProvider(BuilderImpl builder) { CachedSupplier.builder(this::updateSsoCredentials) .cachedValueName(toString()) .staleValueBehavior(ALLOW) - .cacheInvalidatingPredicate( + .nonRecoverableErrorPredicate( e -> e instanceof ExpiredTokenException || e instanceof UnauthorizedException); if (builder.asyncCredentialUpdateEnabled) { cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME)); @@ -168,6 +170,13 @@ public AwsCredentials resolveCredentials() { return credentialCache.get().sessionCredentials(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + String rejectedAccessKeyId = identity.accessKeyId(); + credentialCache.invalidate(holder -> rejectedAccessKeyId.equals(holder.sessionCredentials().accessKeyId())); + return CompletableFuture.completedFuture(null); + } + @Override public void close() { credentialCache.close(); diff --git a/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactory.java b/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactory.java index 0e7f93219c43..428e2a4fb3f8 100644 --- a/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactory.java +++ b/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactory.java @@ -20,6 +20,7 @@ import java.nio.file.Paths; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkProtectedApi; import software.amazon.awssdk.annotations.SdkTestInternalApi; @@ -33,6 +34,7 @@ import software.amazon.awssdk.auth.token.credentials.SdkTokenProvider; import software.amazon.awssdk.auth.token.internal.LazyTokenProvider; import software.amazon.awssdk.core.exception.SdkServiceException; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.profiles.Profile; import software.amazon.awssdk.profiles.ProfileFile; import software.amazon.awssdk.profiles.ProfileProperty; @@ -137,6 +139,11 @@ public AwsCredentials resolveCredentials() { return this.credentialsProvider.resolveCredentials(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + return this.credentialsProvider.invalidate(identity); + } + @Override public void close() { IoUtils.closeQuietly(credentialsProvider, null); 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 fc0668a10811..dc0f54193e33 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 @@ -27,9 +27,11 @@ import java.util.function.Supplier; import org.junit.jupiter.api.Test; import org.mockito.Mockito; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.sso.SsoClient; import software.amazon.awssdk.services.sso.model.GetRoleCredentialsRequest; import software.amazon.awssdk.services.sso.model.GetRoleCredentialsResponse; @@ -209,6 +211,73 @@ public void noCachedCredentials_anyFailure_throwsImmediately() { + @Test + public void invalidate_matchingAccessKeyId_causesRefresh() { + ssoClient = mock(SsoClient.class); + RoleCredentials credentials = RoleCredentials.builder() + .accessKeyId("a") + .secretAccessKey("b") + .sessionToken("c") + .expiration(Instant.now().plus(Duration.ofHours(5)).toEpochMilli()) + .build(); + RoleCredentials credentials2 = RoleCredentials.builder() + .accessKeyId("x") + .secretAccessKey("y") + .sessionToken("z") + .expiration(Instant.now().plus(Duration.ofHours(5)).toEpochMilli()) + .build(); + + Supplier supplier = getRequestSupplier(); + when(ssoClient.getRoleCredentials(supplier.get())) + .thenReturn(getResponse(credentials)) + .thenReturn(getResponse(credentials2)); + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .refreshRequest(supplier) + .ssoClient(ssoClient) + .build()) { + AwsSessionCredentials first = (AwsSessionCredentials) credentialsProvider.resolveCredentials(); + assertThat(first.accessKeyId()).isEqualTo("a"); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("a", "b"); + credentialsProvider.invalidate(identity).join(); + + AwsSessionCredentials second = (AwsSessionCredentials) credentialsProvider.resolveCredentials(); + assertThat(second.accessKeyId()).isEqualTo("x"); + } + } + + @Test + public void invalidate_nonMatchingAccessKeyId_doesNotCauseRefresh() { + ssoClient = mock(SsoClient.class); + RoleCredentials credentials = RoleCredentials.builder() + .accessKeyId("a") + .secretAccessKey("b") + .sessionToken("c") + .expiration(Instant.now().plus(Duration.ofHours(5)).toEpochMilli()) + .build(); + + Supplier supplier = getRequestSupplier(); + when(ssoClient.getRoleCredentials(supplier.get())).thenReturn(getResponse(credentials)); + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .refreshRequest(supplier) + .ssoClient(ssoClient) + .build()) { + AwsSessionCredentials first = (AwsSessionCredentials) credentialsProvider.resolveCredentials(); + assertThat(first.accessKeyId()).isEqualTo("a"); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("different-key", "b"); + credentialsProvider.invalidate(identity).join(); + + AwsSessionCredentials second = (AwsSessionCredentials) credentialsProvider.resolveCredentials(); + assertThat(second.accessKeyId()).isEqualTo("a"); + callClient(verify(ssoClient, times(1)), Mockito.any()); + } + } + + + private GetRoleCredentialsRequestSupplier getRequestSupplier() { return new GetRoleCredentialsRequestSupplier(GetRoleCredentialsRequest.builder() .accountId("123456789") diff --git a/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactoryTest.java b/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactoryTest.java index d6956ac87022..e40017020be5 100644 --- a/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactoryTest.java +++ b/services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactoryTest.java @@ -46,6 +46,7 @@ import software.amazon.awssdk.auth.credentials.ProfileProviderCredentialsContext; import software.amazon.awssdk.auth.token.credentials.SdkToken; import software.amazon.awssdk.auth.token.credentials.SdkTokenProvider; +import software.amazon.awssdk.core.exception.SdkServiceException; import software.amazon.awssdk.profiles.ProfileFile; import software.amazon.awssdk.services.sso.SsoClient; import software.amazon.awssdk.services.sso.auth.ExpiredTokenException; @@ -561,7 +562,10 @@ public void tokenProviderThrowsOnSecondCall_staleCachedCredentialsReturned() { when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class))) .thenReturn(GetRoleCredentialsResponse.builder().roleCredentials(roleCredentials).build()); - RuntimeException secondCallError = new RuntimeException("Token refresh failed on second attempt"); + RuntimeException secondCallError = SdkServiceException.builder() + .message("SSO service unavailable") + .statusCode(500) + .build(); when(sdkTokenProvider.resolveToken()) .thenReturn(SsoAccessToken.builder().accessToken("valid-token").expiresAt(Instant.now().plusSeconds(3600)).build()) .thenThrow(secondCallError); 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 431ffbac43bb..e02c125e7d39 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 @@ -18,6 +18,7 @@ import java.time.Duration; import java.time.Instant; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Function; import software.amazon.awssdk.annotations.NotThreadSafe; import software.amazon.awssdk.annotations.SdkPublicApi; @@ -25,6 +26,7 @@ import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.sts.StsClient; import software.amazon.awssdk.utils.Logger; import software.amazon.awssdk.utils.SdkAutoCloseable; @@ -123,6 +125,13 @@ public void close() { sessionCache.close(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + String rejectedAccessKeyId = identity.accessKeyId(); + sessionCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())); + return CompletableFuture.completedFuture(null); + } + /** * The amount of time, relative to credential expiration, that defines the mandatory refresh window. When credentials are * within this window, all threads will block until the credentials are updated. diff --git a/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenFileCredentialsProvider.java b/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenFileCredentialsProvider.java index 389f4415bdd9..0fbb775306d0 100644 --- a/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenFileCredentialsProvider.java +++ b/services/sts/src/main/java/software/amazon/awssdk/services/sts/auth/StsWebIdentityTokenFileCredentialsProvider.java @@ -23,6 +23,7 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.Optional; +import java.util.concurrent.CompletableFuture; import java.util.function.Consumer; import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkPublicApi; @@ -32,6 +33,7 @@ import software.amazon.awssdk.auth.credentials.internal.WebIdentityTokenCredentialProperties; import software.amazon.awssdk.core.SdkSystemSetting; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.sts.StsClient; import software.amazon.awssdk.services.sts.internal.AssumeRoleWithWebIdentityRequestSupplier; import software.amazon.awssdk.services.sts.model.AssumeRoleWithWebIdentityRequest; @@ -155,6 +157,14 @@ public AwsCredentials resolveCredentials() { return awsCredentials; } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + if (credentialsProvider != null) { + return credentialsProvider.invalidate(identity); + } + return CompletableFuture.completedFuture(null); + } + @Override protected AwsSessionCredentials getUpdatedCredentials(StsClient stsClient) { AssumeRoleWithWebIdentityRequest request = diff --git a/services/sts/src/main/java/software/amazon/awssdk/services/sts/internal/StsProfileCredentialsProviderFactory.java b/services/sts/src/main/java/software/amazon/awssdk/services/sts/internal/StsProfileCredentialsProviderFactory.java index e20236a8652d..7813739f40f9 100644 --- a/services/sts/src/main/java/software/amazon/awssdk/services/sts/internal/StsProfileCredentialsProviderFactory.java +++ b/services/sts/src/main/java/software/amazon/awssdk/services/sts/internal/StsProfileCredentialsProviderFactory.java @@ -16,10 +16,12 @@ package software.amazon.awssdk.services.sts.internal; import java.net.URI; +import java.util.concurrent.CompletableFuture; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; import software.amazon.awssdk.auth.credentials.ChildProfileCredentialsProviderFactory; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.profiles.Profile; import software.amazon.awssdk.profiles.ProfileProperty; import software.amazon.awssdk.regions.Region; @@ -117,6 +119,11 @@ public AwsCredentials resolveCredentials() { return this.credentialsProvider.resolveCredentials(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + return this.credentialsProvider.invalidate(identity); + } + @Override public void close() { IoUtils.closeIfCloseable(parentCredentialsProvider, null); 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 10014ddbf7c9..d6849d804dc4 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 @@ -28,9 +28,11 @@ import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.AwsCredentials; import software.amazon.awssdk.auth.credentials.AwsSessionCredentials; import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.sts.StsClient; import software.amazon.awssdk.services.sts.endpoints.internal.Arn; import software.amazon.awssdk.services.sts.model.Credentials; @@ -159,6 +161,55 @@ public void initialFetchFailureThrowsException_noCachedCredentials() { } } + @Test + public void invalidate_matchingAccessKeyId_causesRefresh() { + Credentials credentials = Credentials.builder() + .accessKeyId("a").secretAccessKey("b").sessionToken("c") + .expiration(Instant.now().plus(Duration.ofHours(5))) + .build(); + Credentials credentials2 = Credentials.builder() + .accessKeyId("x").secretAccessKey("y").sessionToken("z") + .expiration(Instant.now().plus(Duration.ofHours(5))) + .build(); + RequestT request = getRequest(); + when(callClient(stsClient, request)) + .thenReturn(getResponse(credentials)) + .thenReturn(getResponse(credentials2)); + + try (StsCredentialsProvider credentialsProvider = createCredentialsProviderBuilder(request).stsClient(stsClient).build()) { + AwsCredentials first = credentialsProvider.resolveCredentials(); + assertThat(first.accessKeyId()).isEqualTo("a"); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("a", "b"); + credentialsProvider.invalidate(identity).join(); + + AwsCredentials second = credentialsProvider.resolveCredentials(); + assertThat(second.accessKeyId()).isEqualTo("x"); + } + } + + @Test + public void invalidate_nonMatchingAccessKeyId_doesNotCauseRefresh() { + Credentials credentials = Credentials.builder() + .accessKeyId("a").secretAccessKey("b").sessionToken("c") + .expiration(Instant.now().plus(Duration.ofHours(5))) + .build(); + RequestT request = getRequest(); + when(callClient(stsClient, request)).thenReturn(getResponse(credentials)); + + try (StsCredentialsProvider credentialsProvider = createCredentialsProviderBuilder(request).stsClient(stsClient).build()) { + AwsCredentials first = credentialsProvider.resolveCredentials(); + assertThat(first.accessKeyId()).isEqualTo("a"); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("different", "b"); + credentialsProvider.invalidate(identity).join(); + + AwsCredentials second = credentialsProvider.resolveCredentials(); + assertThat(second.accessKeyId()).isEqualTo("a"); + callClient(verify(stsClient, times(1)), Mockito.any()); + } + } + public void callClientWithCredentialsProvider(Instant credentialsExpirationDate, int numTimesInvokeCredentialsProvider, boolean overrideStaleAndPrefetchTimes) { Credentials credentials = Credentials.builder().accessKeyId("a").secretAccessKey("b").sessionToken("c").expiration(credentialsExpirationDate).build(); RequestT request = getRequest(); diff --git a/test/architecture-tests/archunit_store/18fc8858-1308-4d5d-b92d-87817d2fab53 b/test/architecture-tests/archunit_store/18fc8858-1308-4d5d-b92d-87817d2fab53 index 36712e7a180a..054af8e9227b 100644 --- a/test/architecture-tests/archunit_store/18fc8858-1308-4d5d-b92d-87817d2fab53 +++ b/test/architecture-tests/archunit_store/18fc8858-1308-4d5d-b92d-87817d2fab53 @@ -31,7 +31,6 @@ Class depends on a Class depends on an internal API from a different module (Class ) Class depends on an internal API from a different module (Class ) Class depends on an internal API from a different module (Class ) -Class depends on an internal API from a different module (Class ) Class depends on an internal API from a different module (Class ) Class depends on an internal API from a different module (Class ) Class depends on an internal API from a different module (Class ) @@ -49,8 +48,6 @@ Class dep Class depends on an internal API from a different module (Class ) Class depends on an internal API from a different module (Class ) Class depends on an internal API from a different module (Class ) -Class depends on an internal API from a different module (Class ) -Class depends on an internal API from a different module (Class ) Class depends on an internal API from a different module (Class ) Class depends on an internal API from a different module (Class ) Class depends on an internal API from a different module (Class ) diff --git a/test/architecture-tests/archunit_store/4195d6e3-8849-4e5a-848d-04f810577cd3 b/test/architecture-tests/archunit_store/4195d6e3-8849-4e5a-848d-04f810577cd3 index abf18b7d902b..b46e11055ebd 100644 --- a/test/architecture-tests/archunit_store/4195d6e3-8849-4e5a-848d-04f810577cd3 +++ b/test/architecture-tests/archunit_store/4195d6e3-8849-4e5a-848d-04f810577cd3 @@ -11,9 +11,9 @@ Method calls method in (FileStoreTlsKeyManagersProvider.java:52) Method calls method in (SystemPropertyTlsKeyManagersProvider.java:61) Method calls method in (ApacheHttpClient.java:699) -Method calls method in (Apache5HttpClient.java:741) Method calls method in (RepeatableInputStreamRequestEntity.java:113) Method calls method in (ApacheUtils.java:162) +Method calls method in (Apache5HttpClient.java:741) Method calls method in (RepeatableInputStreamRequestEntity.java:131) Method calls method in (RepeatableInputStreamRequestEntity.java:143) Method calls method in (NettyUtils.java:289) @@ -36,16 +36,14 @@ Method calls method in (EnhancedS3ServiceMetadata.java:118) Method calls method in (UseGlobalEndpointResolver.java:97) Method calls method in (GenericMultipartHelper.java:121) -Method calls method in (KnownContentLengthAsyncRequestBodySubscriber.java:128) -Method calls method in (UploadWithUnknownContentLengthHelper.java:143) Method calls method in (UseS3ExpressAuthResolver.java:80) Method calls method in (DisableMultiRegionProviderChain.java:79) Method calls method in (UseArnRegionProviderChain.java:76) Method calls method in (ReceiveSqsMessageHelper.java:132) Method calls method in (AsyncBufferingSubscriber.java:67) Method calls method in (PauseResumeHelper.java:59) -Method calls method in (ContentRangeParser.java:71) Method calls method in (LoggingTransferListener.java:76) +Method calls method in (ContentRangeParser.java:71) Method calls method in (Logger.java:205) Method calls method in (AddingTrailingDataSubscriber.java:73) Method calls method in (BaseSubscriberAdapter.java:99) diff --git a/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationFunctionalTest.java b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationFunctionalTest.java new file mode 100644 index 000000000000..bf18fc2a5e5f --- /dev/null +++ b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationFunctionalTest.java @@ -0,0 +1,225 @@ +/* + * 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.services.retry; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.net.URI; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import org.junit.jupiter.api.Test; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentials; +import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.http.AbortableInputStream; +import software.amazon.awssdk.http.HttpExecuteResponse; +import software.amazon.awssdk.http.SdkHttpRequest; +import software.amazon.awssdk.http.SdkHttpResponse; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; +import software.amazon.awssdk.identity.spi.ResolveIdentityRequest; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.protocolrestjson.ProtocolRestJsonClient; +import software.amazon.awssdk.testutils.service.http.MockSyncHttpClient; +import software.amazon.awssdk.utils.StringInputStream; + +/** + * Integration test verifying the end-to-end flow: + * service returns ExpiredToken → credentials invalidated → next call uses fresh credentials. + */ +public class AuthErrorInvalidationFunctionalTest { + + /** + * End-to-end: ExpiredToken → invalidation is triggered → next call uses fresh credentials. + * + * ExpiredToken exceptions are not retryable, so the first API call fails immediately. + * However, invalidation is still triggered on the provider, ensuring the next API call + * resolves fresh credentials. + * + * Scenario: + * 1. First API call: signed with "old-key", service returns ExpiredToken (not retryable) + * 2. SDK calls invalidate() on the provider (switching it to return "new-key") + * 3. First call fails with ExpiredToken exception + * 4. Second API call: resolves fresh identity "new-key", succeeds + */ + @Test + public void expiredToken_invalidatesCredentials_nextCallUsesFreshCredentials() { + MockSyncHttpClient mockHttpClient = new MockSyncHttpClient(); + InvalidationTrackingCredentialsProvider credentialsProvider = + new InvalidationTrackingCredentialsProvider("old-key", "old-secret", "new-key", "new-secret"); + + try (ProtocolRestJsonClient client = ProtocolRestJsonClient.builder() + .credentialsProvider(credentialsProvider) + .region(Region.US_EAST_1) + .endpointOverride(URI.create("http://localhost")) + .httpClient(mockHttpClient) + .build()) { + + // First API call: ExpiredToken (not retryable — fails immediately) + mockHttpClient.stubResponses(expiredTokenResponse()); + + assertThatThrownBy(() -> client.allTypes()) + .isInstanceOf(AwsServiceException.class) + .satisfies(e -> assertThat(((AwsServiceException) e).awsErrorDetails().errorCode()) + .isEqualTo("ExpiredToken")); + + // Verify invalidate was called during the first API call + assertThat(credentialsProvider.invalidateCallCount()).isEqualTo(1); + + // Verify first call used "old-key" + List firstCallRequests = mockHttpClient.getRequests(); + assertThat(firstCallRequests).hasSize(1); + assertRequestUsedAccessKey(firstCallRequests.get(0), "old-key"); + + // Now make a second API call — this should resolve fresh credentials + mockHttpClient.reset(); + mockHttpClient.stubResponses(successResponse()); + + client.allTypes(); + + // Second call should use new credentials (provider was invalidated) + List secondCallRequests = mockHttpClient.getRequests(); + assertThat(secondCallRequests).hasSize(1); + assertRequestUsedAccessKey(secondCallRequests.get(0), "new-key"); + } + } + + /** + * Verify that AccessDenied does NOT trigger invalidation and the exception propagates. + */ + @Test + public void accessDenied_doesNotInvalidateCredentials() { + MockSyncHttpClient mockHttpClient = new MockSyncHttpClient(); + InvalidationTrackingCredentialsProvider credentialsProvider = + new InvalidationTrackingCredentialsProvider("my-key", "my-secret", "new-key", "new-secret"); + + try (ProtocolRestJsonClient client = ProtocolRestJsonClient.builder() + .credentialsProvider(credentialsProvider) + .region(Region.US_EAST_1) + .endpointOverride(URI.create("http://localhost")) + .httpClient(mockHttpClient) + .build()) { + + mockHttpClient.stubResponses(accessDeniedResponse()); + + assertThatThrownBy(() -> client.allTypes()) + .isInstanceOf(AwsServiceException.class) + .satisfies(e -> assertThat(((AwsServiceException) e).awsErrorDetails().errorCode()) + .isEqualTo("AccessDenied")); + + // Verify invalidate was NOT called + assertThat(credentialsProvider.invalidateCallCount()).isEqualTo(0); + } + } + + // --- Helper methods --- + + private void assertRequestUsedAccessKey(SdkHttpRequest request, String expectedAccessKeyId) { + assertThat(request.firstMatchingHeader("Authorization")) + .isPresent() + .hasValueSatisfying(authHeader -> + assertThat(authHeader).contains("Credential=" + expectedAccessKeyId + "/") + ); + } + + private HttpExecuteResponse expiredTokenResponse() { + String errorBody = "{\"__type\":\"ExpiredTokenException\",\"message\":\"The security token included in the request " + + "is expired\"}"; + return HttpExecuteResponse.builder() + .response(SdkHttpResponse.builder() + .statusCode(403) + .putHeader("x-amzn-ErrorType", "ExpiredToken") + .putHeader("content-length", + String.valueOf(errorBody.length())) + .build()) + .responseBody(AbortableInputStream.create(new StringInputStream(errorBody))) + .build(); + } + + private HttpExecuteResponse accessDeniedResponse() { + String errorBody = "{\"__type\":\"AccessDeniedException\",\"message\":\"User is not authorized\"}"; + return HttpExecuteResponse.builder() + .response(SdkHttpResponse.builder() + .statusCode(403) + .putHeader("x-amzn-ErrorType", "AccessDenied") + .putHeader("content-length", + String.valueOf(errorBody.length())) + .build()) + .responseBody(AbortableInputStream.create(new StringInputStream(errorBody))) + .build(); + } + + private HttpExecuteResponse successResponse() { + String body = "{}"; + return HttpExecuteResponse.builder() + .response(SdkHttpResponse.builder() + .statusCode(200) + .putHeader("content-length", + String.valueOf(body.length())) + .build()) + .responseBody(AbortableInputStream.create(new StringInputStream(body))) + .build(); + } + + // --- Test doubles --- + + /** + * A credentials provider that tracks invalidation calls and switches to fresh credentials + * after invalidation is triggered. This simulates the behavior of a caching provider + * (like IMDS or Container provider) that serves stale credentials until invalidated. + */ + private static class InvalidationTrackingCredentialsProvider implements AwsCredentialsProvider { + private final AwsBasicCredentials oldCredentials; + private final AwsBasicCredentials newCredentials; + private final AtomicInteger invalidateCount = new AtomicInteger(0); + private volatile boolean invalidated = false; + + InvalidationTrackingCredentialsProvider(String oldAccessKey, String oldSecretKey, + String newAccessKey, String newSecretKey) { + this.oldCredentials = AwsBasicCredentials.create(oldAccessKey, oldSecretKey); + this.newCredentials = AwsBasicCredentials.create(newAccessKey, newSecretKey); + } + + @Override + public AwsCredentials resolveCredentials() { + return invalidated ? newCredentials : oldCredentials; + } + + @Override + public CompletableFuture resolveIdentity(ResolveIdentityRequest request) { + AwsCredentials creds = resolveCredentials(); + return CompletableFuture.completedFuture(creds); + } + + @Override + public Class identityType() { + return AwsCredentialsIdentity.class; + } + + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + invalidateCount.incrementAndGet(); + invalidated = true; + return CompletableFuture.completedFuture(null); + } + + int invalidateCallCount() { + return invalidateCount.get(); + } + } +} 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 998648a6ca02..f7921f7a2ad2 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 @@ -41,7 +41,7 @@ private CacheRefreshUtils() { * 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:

+ *

Dynamic window selection: *

    *
  • remaining lifetime < 20 minutes → 5 minute window
  • *
  • 20 minutes ≤ remaining lifetime < 90 minutes → 15 minute window
  • 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 ab978bb1a7dc..3700ecbc0142 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 @@ -117,7 +117,16 @@ public class CachedSupplier implements Supplier, SdkAutoCloseable { * Predicate that determines whether an exception represents a non-recoverable refresh failure * that should bypass static stability (i.e., be re-thrown immediately without extending expiration). */ - private final Predicate cacheInvalidatingPredicate; + private final Predicate nonRecoverableErrorPredicate; + + /** + * Tracks when the next refresh attempt is allowed after a failure. This is set under {@link #refreshLock} + * and read without the lock (volatile). When non-null and in the future, {@link #get()} returns the cached + * value without contacting the source. + * + *

    This field is NEVER modified by {@code invalidate()} — backoff remains independently tracked. + */ + private volatile Instant nextAllowedRefreshTime; private CachedSupplier(Builder builder) { Validate.notNull(builder.supplier, "builder.supplier"); @@ -128,7 +137,7 @@ private CachedSupplier(Builder builder) { this.staleValueBehavior = Validate.notNull(builder.staleValueBehavior, "builder.staleValueBehavior"); this.clock = Validate.notNull(builder.clock, "builder.clock"); this.cachedValueName = Validate.notNull(builder.cachedValueName, "builder.cachedValueName"); - this.cacheInvalidatingPredicate = builder.cacheInvalidatingPredicate; + this.nonRecoverableErrorPredicate = builder.nonRecoverableErrorPredicate; } /** @@ -143,9 +152,15 @@ public static CachedSupplier.Builder builder(Supplier> v @Override public T get() { if (cacheIsStale()) { + if (refreshRateLimited()) { + return this.cachedValue.value(); + } log.debug(() -> "(" + cachedValueName + ") Cached value is stale and will be refreshed."); refreshCache(); } else if (shouldInitiateCachePrefetch()) { + if (refreshRateLimited()) { + return this.cachedValue.value(); + } log.debug(() -> "(" + cachedValueName + ") Cached value has reached prefetch time and will be refreshed."); prefetchCache(); } @@ -153,6 +168,53 @@ public T get() { return this.cachedValue.value(); } + /** + * Marks the cached value for mandatory refresh if the predicate matches. + * Sets staleTime to now() so the next get() triggers a refresh, subject to + * the refresh backoff gate ({@code nextAllowedRefreshTime}). + * + *

    This method MUST NOT discard the cached value. + * This method MUST NOT clear or modify {@code nextAllowedRefreshTime}. + * + *

    If there is no cached value, this method is a no-op — the predicate will not be called. + * + *

    If the lock is not immediately available, this method returns without invalidating. + * A held lock means either a refresh or another invalidation is already in progress. + * In either case it is safe to skip: a refresh will replace the cached value shortly, + * and a concurrent invalidation will mark it stale. + * + * @param matchesCachedValue A predicate that returns true if the cached value + * is the one that should be invalidated. The value passed + * to the predicate is guaranteed to be non-null. + */ + public void invalidate(Predicate matchesCachedValue) { + if (!refreshLock.tryLock()) { + log.debug(() -> "(" + cachedValueName + ") Refresh lock held by another thread during invalidation; " + + "skipping because either a refresh or another invalidation is already in progress."); + return; + } + try { + RefreshResult currentCachedValue = this.cachedValue; + if (currentCachedValue == null || currentCachedValue.value() == null) { + return; + } + if (!matchesCachedValue.test(currentCachedValue.value())) { + return; + } + // Set staleTime = now, routing next get() through mandatory refresh + // (subject to nextAllowedRefreshTime gate) + Instant now = clock.instant(); + this.cachedValue = currentCachedValue.toBuilder() + .staleTime(now) + .prefetchTime(now) + .build(); + log.debug(() -> "(" + cachedValueName + ") Cached value invalidated. " + + "Next get() will attempt mandatory refresh."); + } finally { + refreshLock.unlock(); + } + } + /** * Determines whether the value in this cache is stale, and all threads should block and wait for an updated value. */ @@ -189,6 +251,16 @@ private boolean shouldInitiateCachePrefetch() { return !clock.instant().isBefore(currentCachedValue.prefetchTime()); } + /** + * Checks whether a refresh backoff is currently active. When a refresh fails, a backoff gate + * ({@link #nextAllowedRefreshTime}) is set. While the gate is in the future, the cached value + * is returned without contacting the credential source. + */ + private boolean refreshRateLimited() { + Instant nextAllowed = this.nextAllowedRefreshTime; + return nextAllowed != null && clock.instant().isBefore(nextAllowed); + } + /** * Initiate a pre-fetch of the data using the configured {@link #prefetchStrategy}. */ @@ -241,6 +313,7 @@ private void refreshCache() { * Perform necessary transformations of the successfully-fetched value based on the stale value behavior of this supplier. */ private RefreshResult handleFetchedSuccess(RefreshResult fetch) { + this.nextAllowedRefreshTime = null; // Clear backoff gate on success Instant now = clock.instant(); if (now.isBefore(fetch.staleTime())) { @@ -250,12 +323,12 @@ private RefreshResult handleFetchedSuccess(RefreshResult fetch) { switch (staleValueBehavior) { case STRICT: Instant newStale = now.plusSeconds(1); - log.warn(() -> "(" + cachedValueName + ") Retrieved value expiration is in the past (" + fetch.staleTime() + + log.debug(() -> "(" + cachedValueName + ") Retrieved value expiration is in the past (" + fetch.staleTime() + "). Using expiration of " + newStale); return fetch.toBuilder().staleTime(newStale).build(); // Refresh again in 1 second case ALLOW: Instant newStaleTime = jitterTime(now, Duration.ofMinutes(1), Duration.ofMinutes(10)); - log.warn(() -> "(" + cachedValueName + ") Cached value expiration has been extended to " + newStaleTime + + log.debug(() -> "(" + cachedValueName + ") Cached value expiration has been extended to " + newStaleTime + " because the downstream service returned a time in the past: " + fetch.staleTime()); return fetch.toBuilder() @@ -283,26 +356,22 @@ private RefreshResult handleFetchFailure(RuntimeException e) { case STRICT: throw e; case ALLOW: - // Cache-invalidating errors bypass static stability - if (cacheInvalidatingPredicate != null && cacheInvalidatingPredicate.test(e)) { + // Non-recoverable errors bypass static stability + if (nonRecoverableErrorPredicate != null && nonRecoverableErrorPredicate.test(e)) { throw e; } - // Uniform random backoff: 5-10 minutes + // Set backoff gate — do NOT modify staleTime/prefetchTime long backoffSeconds = STATIC_STABILITY_BACKOFF_MIN.getSeconds() + jitterRandom.nextInt( (int) (STATIC_STABILITY_BACKOFF_MAX.getSeconds() - STATIC_STABILITY_BACKOFF_MIN.getSeconds() + 1)); - Instant extendedStaleTime = now.plusSeconds(backoffSeconds); + this.nextAllowedRefreshTime = now.plusSeconds(backoffSeconds); - 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); + log.debug(() -> "(" + cachedValueName + ") Credential refresh failed: " + e.getMessage() + + ". Will retry after " + backoffSeconds + " seconds.", e); - return currentCachedValue.toBuilder() - .staleTime(extendedStaleTime) - .prefetchTime(extendedStaleTime) - .build(); + return currentCachedValue; // Return unchanged — staleTime/prefetchTime untouched default: throw new IllegalStateException("Unknown stale-value-behavior: " + staleValueBehavior); } @@ -310,31 +379,21 @@ private RefreshResult handleFetchFailure(RuntimeException e) { // Not yet stale — we're in the prefetch window. Handle failure based on mode. if (staleValueBehavior == StaleValueBehavior.ALLOW) { - if (cacheInvalidatingPredicate != null && cacheInvalidatingPredicate.test(e)) { + if (nonRecoverableErrorPredicate != null && nonRecoverableErrorPredicate.test(e)) { throw e; } - // During prefetch window failure: extend prefetchTime to suppress further attempts. - // Preserve existing staleTime if it is later than the new prefetch time. + + // Set backoff gate — do NOT modify staleTime/prefetchTime 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(newStaleTime) - .prefetchTime(extendedPrefetchTime) - .build(); + this.nextAllowedRefreshTime = now.plusSeconds(backoffSeconds); + + log.debug(() -> "(" + cachedValueName + ") Credential refresh failed: " + e.getMessage() + + ". Will retry after " + backoffSeconds + " seconds.", e); + + return currentCachedValue; // Return unchanged — staleTime/prefetchTime untouched } return currentCachedValue; @@ -423,7 +482,7 @@ public static final class Builder { private StaleValueBehavior staleValueBehavior = StaleValueBehavior.STRICT; private Clock clock = Clock.systemUTC(); private String cachedValueName = "unknown"; - private Predicate cacheInvalidatingPredicate; + private Predicate nonRecoverableErrorPredicate; private Builder(Supplier> supplier) { this.supplier = supplier; @@ -470,13 +529,13 @@ public Builder cachedValueName(String cachedValueName) { * *

    This is used for errors where the credential source has definitively indicated that the current * authentication state is invalid and requires user intervention (e.g., expired SSO tokens, - * changed user credentials).

    + * changed user credentials). * - *

    By default, no exceptions are considered cache-invalidating (all failures trigger static stability - * backoff when {@link StaleValueBehavior#ALLOW} is configured).

    + *

    By default, no exceptions are considered non-recoverable (all failures trigger static stability + * backoff when {@link StaleValueBehavior#ALLOW} is configured). */ - public Builder cacheInvalidatingPredicate(Predicate cacheInvalidatingPredicate) { - this.cacheInvalidatingPredicate = cacheInvalidatingPredicate; + public Builder nonRecoverableErrorPredicate(Predicate nonRecoverableErrorPredicate) { + this.nonRecoverableErrorPredicate = nonRecoverableErrorPredicate; return this; } @@ -558,11 +617,11 @@ public enum StaleValueBehavior { * Allow stale values to be returned from the cache with static stability semantics. On refresh failure, * extends the stale time by a uniformly random backoff between 5 and 10 minutes (300-600 seconds). * - *

    If a {@link Builder#cacheInvalidatingPredicate(Predicate)} is configured and returns {@code true} - * for the exception, it is re-thrown immediately without extending the stale time.

    + *

    If a {@link Builder#nonRecoverableErrorPredicate(Predicate)} is configured and returns {@code true} + * for the exception, it is re-thrown immediately without extending the stale time. * *

    Value retrieval will never fail as long as the cache has succeeded at least once, - * unless the error is cache-invalidating.

    + * unless the error is non-recoverable. */ ALLOW } 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 60a55265fba6..e6bb3bbf1bdc 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 @@ -35,11 +35,13 @@ import java.util.ArrayList; import java.util.Collection; import java.util.List; +import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.Semaphore; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; import java.util.function.Supplier; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -397,7 +399,7 @@ public void allowMode_cacheInvalidatingError_isRethrown() throws InterruptedExce MutableSupplier supplier = new MutableSupplier(); try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) .staleValueBehavior(ALLOW) - .cacheInvalidatingPredicate( + .nonRecoverableErrorPredicate( e -> e instanceof CacheInvalidatingRuntimeException) .clock(clock) .jitterEnabled(false) @@ -449,30 +451,29 @@ public void allowMode_backoffIsInExpectedRange() throws InterruptedException { supplier.set(new RuntimeException("service unavailable")); cachedSupplier.get(); - // Advance well past the extended time to test that the backoff was applied - // The extended stale time should be: now(61) + [300,600]s(backoff) - // So total offset from epoch: 61 + [300,600] = [361, 661] seconds from original now - Instant minExpectedStale = now.plusSeconds(61 + 300); - Instant maxExpectedStale = now.plusSeconds(61 + 600); + // Now nextAllowedRefreshTime is set to now(61) + [300,600]s + // The cached value should be returned while rate limited + Instant minBackoffEnd = now.plusSeconds(61 + 300); + Instant maxBackoffEnd = now.plusSeconds(61 + 600); - // Advance just before the minimum backoff - should still return cached (not stale yet) - clock.time = minExpectedStale.minusSeconds(1); + // Advance just before the minimum backoff end - should still be rate limited + clock.time = minBackoffEnd.minusSeconds(1); supplier.set(RefreshResult.builder("new-creds") .staleTime(Instant.MAX) .prefetchTime(Instant.MAX) .build()); - // Value not stale yet so should return cached + // Rate limited: returns cached value without contacting source assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); - // Advance past maximum possible backoff - must be stale now and will refresh - clock.time = maxExpectedStale.plusSeconds(1); + // Advance past maximum possible backoff - rate limit expired, will refresh + clock.time = maxBackoffEnd.plusSeconds(1); assertThat(cachedSupplier.get()).isEqualTo("new-creds"); } } } @Test - public void allowMode_prefetchWindowFailure_extendsPrefetchTime() { + public void allowMode_prefetchWindowFailure_setsBackoffGate() { AdjustableClock clock = new AdjustableClock(); MutableSupplier supplier = new MutableSupplier(); try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) @@ -494,17 +495,17 @@ public void allowMode_prefetchWindowFailure_extendsPrefetchTime() { clock.time = now.plusSeconds(61); supplier.set(new RuntimeException("service unavailable")); - // Should return cached value (not throw) and extend prefetch time + // Should return cached value (not throw) and set nextAllowedRefreshTime assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); // Verify that a subsequent call shortly after does NOT attempt another refresh - // (because prefetchTime was extended) + // (because nextAllowedRefreshTime was set as a backoff gate) clock.time = now.plusSeconds(62); supplier.set(RefreshResult.builder("should-not-get-this") .staleTime(Instant.MAX) .prefetchTime(Instant.MAX) .build()); - // The prefetchTime was extended far into the future, so this should still return cached + // The rate limit is active, so this should still return cached assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); } } @@ -536,18 +537,14 @@ public void allowMode_prefetchWindowFailure_preservesStaleTime() { // 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); + // Advance past the maximum possible backoff (61 + 600 = 661s from now) but still before stale time (3600s). + // The nextAllowedRefreshTime backoff will have elapsed, so a prefetch refresh will be attempted. + clock.time = now.plusSeconds(700); 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. + // Backoff elapsed, prefetchTime (60s) is in the past, so prefetch is triggered and succeeds assertThat(cachedSupplier.get()).isEqualTo("refreshed-creds"); } } @@ -558,7 +555,7 @@ public void allowMode_prefetchWindowFailure_cacheInvalidatingError_isRethrown() MutableSupplier supplier = new MutableSupplier(); try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) .staleValueBehavior(ALLOW) - .cacheInvalidatingPredicate( + .nonRecoverableErrorPredicate( e -> e instanceof CacheInvalidatingRuntimeException) .clock(clock) .jitterEnabled(false) @@ -873,4 +870,147 @@ public Instant instant() { return time; } } + + // --- invalidate() tests --- + + @Test + public void invalidate_predicateMatches_triggersRefresh() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + try (CachedSupplier cache = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + supplier.set(RefreshResult.builder("value-1").staleTime(now.plusSeconds(3600)).prefetchTime(now.plusSeconds(1800)).build()); + assertThat(cache.get()).isEqualTo("value-1"); + + clock.time = now.plusSeconds(10); + cache.invalidate(v -> v.equals("value-1")); + + supplier.set(RefreshResult.builder("value-2").staleTime(now.plusSeconds(7200)).prefetchTime(now.plusSeconds(5400)).build()); + assertThat(cache.get()).isEqualTo("value-2"); + } + } + + @Test + public void invalidate_predicateDoesNotMatch_doesNotTriggerRefresh() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + try (CachedSupplier cache = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + supplier.set(RefreshResult.builder("value-1").staleTime(now.plusSeconds(3600)).prefetchTime(now.plusSeconds(1800)).build()); + assertThat(cache.get()).isEqualTo("value-1"); + + cache.invalidate(v -> v.equals("different-value")); + + supplier.set(RefreshResult.builder("value-2").staleTime(now.plusSeconds(7200)).prefetchTime(now.plusSeconds(5400)).build()); + assertThat(cache.get()).isEqualTo("value-1"); + } + } + + @Test + public void invalidate_beforeFirstGet_isNoOp() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + clock.time = Instant.parse("2024-01-01T00:00:00Z"); + + try (CachedSupplier cache = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + cache.invalidate(v -> true); // should not throw + + supplier.set(RefreshResult.builder("value-1").staleTime(Instant.MAX).prefetchTime(Instant.MAX).build()); + assertThat(cache.get()).isEqualTo("value-1"); + } + } + + @Test + public void invalidate_doesNotBypassRefreshBackoff() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + try (CachedSupplier cache = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + supplier.set(RefreshResult.builder("old").staleTime(now.plusSeconds(60)).prefetchTime(now.plusSeconds(30)).build()); + assertThat(cache.get()).isEqualTo("old"); + + // Trigger failure to set backoff + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("unavailable")); + assertThat(cache.get()).isEqualTo("old"); + + // Invalidate — marks stale but doesn't clear backoff + clock.time = now.plusSeconds(62); + cache.invalidate(v -> v.equals("old")); + supplier.set(RefreshResult.builder("new").staleTime(Instant.MAX).prefetchTime(Instant.MAX).build()); + + // Still within backoff — returns stale + assertThat(cache.get()).isEqualTo("old"); + + // Past backoff — returns fresh + clock.time = now.plusSeconds(700); + assertThat(cache.get()).isEqualTo("new"); + } + } + + @Test + public void invalidate_concurrentWithGet_doesNotCorrupt() throws Exception { + AdjustableClock clock = new AdjustableClock(); + clock.time = Instant.parse("2024-01-01T00:00:00Z"); + AtomicInteger counter = new AtomicInteger(0); + + try (CachedSupplier cache = CachedSupplier.builder(() -> + RefreshResult.builder("v-" + counter.incrementAndGet()) + .staleTime(Instant.MAX) + .prefetchTime(Instant.MAX) + .build()) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + + cache.get(); // prime + + ExecutorService executor = Executors.newFixedThreadPool(10); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + for (int i = 0; i < 10; i++) { + int idx = i; + futures.add(executor.submit(() -> { + try { start.await(); } catch (InterruptedException e) { return; } + for (int j = 0; j < 50; j++) { + if (idx % 2 == 0) { + cache.invalidate(v -> true); + } else { + assertThat(cache.get()).isNotNull(); + } + } + })); + } + + start.countDown(); + for (Future f : futures) { f.get(30, TimeUnit.SECONDS); } + executor.shutdown(); + + assertThat(cache.get()).isNotNull(); + } + } } From 528426c442d919791411004a1856e71aafcb2f78 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Fri, 17 Jul 2026 10:39:03 -0700 Subject: [PATCH 6/6] Fix mixed version compat + add changelog --- .../next-release/feature-AWSSDKforJavav2-55b76bf.json | 6 ++++++ .github/workflows/mixed-version-compatibility-review.yml | 9 ++++----- 2 files changed, 10 insertions(+), 5 deletions(-) create mode 100644 .changes/next-release/feature-AWSSDKforJavav2-55b76bf.json diff --git a/.changes/next-release/feature-AWSSDKforJavav2-55b76bf.json b/.changes/next-release/feature-AWSSDKforJavav2-55b76bf.json new file mode 100644 index 000000000000..afbf9065e31c --- /dev/null +++ b/.changes/next-release/feature-AWSSDKforJavav2-55b76bf.json @@ -0,0 +1,6 @@ +{ + "type": "feature", + "category": "AWS SDK for Java v2", + "contributor": "", + "description": "Standardize cached credential stale/prefetch windows, improve credential refresh resilliancy (static-stability) and add automatic credential invalidation/refresh." +} diff --git a/.github/workflows/mixed-version-compatibility-review.yml b/.github/workflows/mixed-version-compatibility-review.yml index 75728e32a3ab..39db4290a279 100644 --- a/.github/workflows/mixed-version-compatibility-review.yml +++ b/.github/workflows/mixed-version-compatibility-review.yml @@ -46,11 +46,10 @@ jobs: # Look for new public methods in the changed base class files # Filter out obvious false positives: comments, string literals, javadoc NEW_METHODS=$(git diff remotes/origin/${{ github.base_ref }} -- $BASE_CLASS_FILES | \ - - grep '^+.*public.*(' | \ # Find lines with new public methods - grep -v '^+[[:space:]]*//.*' | \ # Line comments - grep -v '^+[[:space:]]*\*.*' | \ # Javadoc lines - grep -v '^+[[:space:]]*/\*.*' || true # Block comments + grep '^+.*public.*(' | \ + grep -v '^+[[:space:]]*//.*' | \ + grep -v '^+[[:space:]]*\*.*' | \ + grep -v '^+[[:space:]]*/\*.*' || true) if [ -n "$NEW_METHODS" ]; then echo "::warning::New public methods detected in base classes:"