Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -346,7 +346,6 @@
<Class name="~software\.amazon\.awssdk\.auth\.credentials\.internal\.HttpCredentialsLoader" />
<Class name="~software\.amazon\.awssdk\.auth\.credentials\.ContainerCredentialsProvider" />
<Class name="~software\.amazon\.awssdk\.auth\.credentials\.InstanceProfileCredentialsProvider" />
<Class name="~software\.amazon\.awssdk\.core\.internal\.http\.pipeline\.stages\.utils\.AuthErrorInvalidationHelper" />

<Class name="~software\.amazon\.awssdk\.messagemanager\.sns\.internal\.SnsHostProvider" />

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,6 @@
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;
Expand All @@ -63,12 +62,12 @@
* (deprecated) or as a list of strings.</li>
* <li><b>StaleTime</b> - 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.</li>
* exception is raised. Default: 0, i.e. the mandatory refresh window opens at expiration.</li>
* <li><b>PrefetchTime</b> - 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.</li>
* prefetch time for backward compatibility. Default: 15 seconds.</li>
* <li><b>AsyncCredentialUpdateEnabled</b> - Whether to refresh credentials asynchronously in a background thread during
* the advisory refresh window, so that callers are less likely to block. Default: disabled.</li>
* <li><b>ProcessOutputLimit</b> - The maximum amount of data that can be returned by the external process before an
Expand All @@ -87,7 +86,8 @@ 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_STALE_TIME = Duration.ZERO;
private static final Duration DEFAULT_PREFETCH_TIME = Duration.ofSeconds(15);

private final List<String> executableCommand;
private final long processOutputLimit;
Expand Down Expand Up @@ -121,11 +121,9 @@ private ProcessCredentialsProvider(Builder builder) {
? PROVIDER_NAME
: builder.sourceChain + "," + PROVIDER_NAME;
this.staleTime = Optional.ofNullable(builder.staleTime).orElse(DEFAULT_STALE_TIME);
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.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<AwsCredentials> cacheBuilder = CachedSupplier.builder(this::refreshCredentials)
.cachedValueName(toString())
Expand Down Expand Up @@ -206,11 +204,7 @@ private Instant prefetchTime(Instant expiration) {
if (expiration == null || expiration.equals(Instant.MAX)) {
return Instant.MAX;
}
Instant now = Instant.now();
// Unlike the AWS credential services, the process decides its own expiration and may emit credentials that are
// shorter-lived than the smallest standard advisory refresh window, so the window has to adapt to the lifetime.
Duration dynamicWindow = CacheRefreshUtils.computePrefetchWindowForArbitraryLifetime(expiration, prefetchTime, now);
return expiration.minus(dynamicWindow);
return expiration.minus(prefetchTime);
}

/**
Expand Down Expand Up @@ -375,7 +369,7 @@ public Builder asyncCredentialUpdateEnabled(Boolean asyncCredentialUpdateEnabled
* <p>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).
*
* <p>By default, this is 1 minute.
* <p>By default, this is zero, so the mandatory refresh window opens when the credentials expire.
*
* @param staleTime the duration before expiration that triggers mandatory (blocking) refresh
*/
Expand All @@ -398,14 +392,7 @@ public Builder staleTime(Duration staleTime) {
* <p>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).
*
* <p>If not explicitly set, the advisory refresh window is computed dynamically based on the credential's
* remaining lifetime: half the remaining lifetime (but never less than 1 minute) for credentials with 10 minutes
* or less remaining, 5 minutes for 10-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.
*
* <p>The halved window for short-lived credentials is specific to this provider. Because the process decides its
* own expiration, it may emit credentials that are shorter-lived than the smallest standard window, which would
* otherwise place them inside their advisory refresh window as soon as they are produced.
* <p>By default, this is 15 seconds.
*
* @param prefetchTime the duration before expiration that triggers advisory (proactive) refresh
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -228,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().plus(Duration.ofMinutes(30)))))
DateUtils.formatIso8601Date(Instant.now().plusSeconds(20))))
.build();

AwsCredentials request1 = credentialsProvider.resolveCredentials();
Expand Down Expand Up @@ -279,41 +279,18 @@ void resolveCredentials_advisoryWindowIsNotJittered() {
assertThat(request1).isNotEqualTo(request2);
}

/**
* The process decides its own expiration and may produce credentials shorter-lived than the smallest standard advisory
* refresh window. This provider therefore halves the lifetime instead of using that window, so freshly produced
* credentials are served from the cache rather than re-running the process on every call. This differs from the
* AWS-service-backed providers, which can rely on a 15 minute minimum session duration.
*/
@Test
void shortLivedCredentials_areNotRefreshedOnTheCallFollowingIssuance() {
// A 3 minute lifetime gives a 90 second advisory window, which opens 90 seconds after the process runs.
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(3)))))
.build();

// The process emits a random session token on each run, so equal credentials mean it only ran once.
AwsCredentials request1 = credentialsProvider.resolveCredentials();
AwsCredentials request2 = credentialsProvider.resolveCredentials();

assertThat(request1).isEqualTo(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
void defaultPrefetchTime_credentialsWithinFifteenSecondsOfExpiry_areRefreshed() {
// Credentials that expire in 10 seconds: prefetchTime = now+10s - 15s = now-5s (in the past), so the advisory
// refresh window is already open when the credentials are produced and the next call re-runs the process.
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))))
DateUtils.formatIso8601Date(Instant.now().plusSeconds(10))))
.build();

// The process emits a random session token on each run, so unequal credentials mean it ran twice.
AwsCredentials request1 = credentialsProvider.resolveCredentials();
AwsCredentials request2 = credentialsProvider.resolveCredentials();

Expand All @@ -322,8 +299,8 @@ void defaultPrefetchTime_credentialsWithinFiveMinuteWindow_areRefreshed() {

@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
// Credentials that expire in 30 minutes: prefetchTime = now+30min - 15s (in the future), so the cache should
// NOT refresh.
ProcessCredentialsProvider credentialsProvider =
ProcessCredentialsProvider.builder()
.command(String.format("%s %s %s token=%s exp=%s",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,14 @@

package software.amazon.awssdk.core.internal.http.pipeline.stages.utils;

import java.util.concurrent.CompletableFuture;
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;

/**
Expand All @@ -32,7 +32,14 @@
* <p>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.
* {@link IdentityProvider#invalidate} so that the provider refreshes before it vends credentials again.
*
* <p>Identity is resolved once per API call, by a stage that sits outside the retry loop, and every attempt of that
* call reuses it. Invalidation therefore does not affect the attempt currently being retried; it takes effect on the
* next API call that resolves credentials.
*
* <p>Both the synchronous and asynchronous request paths must call
* {@link #invalidateIfAuthError(Throwable, RequestExecutionContext)} for the behavior to apply to both client types.
*
* <p>All exceptions from the invalidation path are caught and logged at debug level.
* Invalidation failures never disrupt the normal request/retry flow.
Expand All @@ -50,49 +57,67 @@ private AuthErrorInvalidationHelper() {
* credential invalidation. If so, retrieves the identity provider from the
* {@link SelectedAuthScheme} and calls invalidate() on it.
*
* <p>This never blocks the calling thread: it composes on the resolved identity future rather than joining it, so
* it is safe to call from the async request path, which runs on I/O threads. In practice the identity is already
* resolved by the time a response has been received, so the invalidation completes inline.
*
* <p>The returned future never completes exceptionally. Invalidation is best-effort, and any failure is logged at
* debug level instead of being propagated. Callers are not required to await it: identity is resolved outside the
* retry loop, so a pending invalidation cannot affect the attempt currently being retried.
*
* @param exception The exception from the failed request attempt
* @param context The request execution context containing auth scheme info
* @return A future completing when the invalidation attempt has finished. Never completes exceptionally.
*/
public static CompletableFuture<Void> invalidateIfAuthError(Throwable exception, RequestExecutionContext context) {
SelectedAuthScheme<?> selectedAuthScheme = authSchemeToInvalidate(exception, context);
if (selectedAuthScheme == null) {
return CompletableFuture.completedFuture(null);
}

try {
return doInvalidate(selectedAuthScheme);
} catch (Exception e) {
LOG.debug(() -> "Failed to invalidate identity provider after auth error: " + e.getMessage(), e);
return CompletableFuture.completedFuture(null);
}
}

/**
* Returns the {@link SelectedAuthScheme} whose identity provider should be invalidated in response to the given
* exception, or null if the exception is not an authentication failure or there is no provider to invalidate.
*/
public static void invalidateIfAuthError(Throwable exception, RequestExecutionContext context) {
private static SelectedAuthScheme<?> authSchemeToInvalidate(Throwable exception, RequestExecutionContext context) {
if (!(exception instanceof SdkServiceException)) {
return;
return null;
}

SdkServiceException serviceException = (SdkServiceException) exception;
if (!serviceException.isAuthenticationError()) {
return;
return null;
}

SelectedAuthScheme<?> selectedAuthScheme =
context.executionAttributes().getAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME);

if (selectedAuthScheme == null || selectedAuthScheme.identityProvider() == null) {
return;
return null;
}

try {
doInvalidate(selectedAuthScheme);
} catch (Exception e) {
LOG.debug(() -> "Failed to invalidate identity provider after auth error: " + e.getMessage(), e);
}
return selectedAuthScheme;
}

private static <T extends Identity> void doInvalidate(SelectedAuthScheme<T> selectedAuthScheme) {
T resolvedIdentity = CompletableFutureUtils.joinLikeSync(selectedAuthScheme.identity());
private static <T extends Identity> CompletableFuture<Void> doInvalidate(SelectedAuthScheme<T> selectedAuthScheme) {
IdentityProvider<T> 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);
}
// thenCompose rather than a join on the identity future: by the time a response has been received the identity
// is already resolved, so this completes inline, but it stays non-blocking if it ever is not.
// A synchronous throw from invalidate(), and a failed identity future, both complete the composed future
// exceptionally, so exceptionally() covers every failure mode.
return selectedAuthScheme.identity()
.thenCompose(provider::invalidate)
.exceptionally(e -> {
LOG.debug(() -> "Failed to invalidate identity provider: " + e.getMessage(), e);
return null;
});
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,10 @@ private RefreshRetryTokenResponse doBlockingRefreshRetryToken(Duration suggested
}

public CompletableFuture<Either<Duration, Duration>> tryRefreshTokenAsync(Duration suggestedDelay) {
// Invalidate cached credentials if this failure is an auth error, before the retry strategy evaluates.
// Not awaited: invalidation is best-effort and must not delay the retry path.
AuthErrorInvalidationHelper.invalidateIfAuthError(this.lastException, context);

CompletableFuture<Either<Duration, Duration>> cf = new CompletableFuture<>();

RetryToken retryToken = context.executionAttributes().getAttribute(RETRY_TOKEN);
Expand Down
Loading
Loading