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 e8828572cb23..195707ddb9e5 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 @@ -17,7 +17,11 @@ import java.time.Duration; import java.time.Instant; +import java.util.Arrays; +import java.util.Collections; +import java.util.HashSet; import java.util.Optional; +import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.function.Function; import software.amazon.awssdk.annotations.NotThreadSafe; @@ -26,6 +30,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.awscore.exception.AwsServiceException; import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.sts.StsClient; import software.amazon.awssdk.utils.Logger; @@ -53,6 +58,21 @@ @ThreadSafe @SdkPublicApi public abstract class StsCredentialsProvider implements AwsCredentialsProvider, SdkAutoCloseable { + + /** + * STS error codes that indicate a non-recoverable failure. When a credential refresh fails with one of these codes, + * the error bypasses static stability and is surfaced to the caller immediately, because retrying cannot fix the + * underlying problem. + */ + static final Set NON_RECOVERABLE_ERROR_CODES = Collections.unmodifiableSet(new HashSet<>(Arrays.asList( + "AccessDenied", + "IDPRejectedClaim", + "InvalidIdentityToken", + "MalformedPolicyDocument", + "PackedPolicyTooLarge", + "RegionDisabledException" + ))); + private static final Logger log = Logger.loggerFor(StsCredentialsProvider.class); private static final Duration DEFAULT_STALE_TIME = Duration.ofMinutes(1); @@ -86,7 +106,8 @@ public abstract class StsCredentialsProvider implements AwsCredentialsProvider, CachedSupplier.builder(this::updateSessionCredentials) .cachedValueName(toString()) .staleValueBehavior(CachedSupplier.StaleValueBehavior.ALLOW) - .prefetchJitterEnabled(false); + .prefetchJitterEnabled(false) + .nonRecoverableErrorPredicate(StsCredentialsProvider::isNonRecoverableError); if (builder.asyncCredentialUpdateEnabled) { cacheBuilder.prefetchStrategy(new NonBlocking(asyncThreadName)); } @@ -153,6 +174,25 @@ public Duration prefetchTime() { public String toString() { return ToString.create(providerName()); } + + static boolean isNonRecoverableError(RuntimeException e) { + AwsServiceException serviceException = extractServiceException(e); + if (serviceException == null || serviceException.awsErrorDetails() == null) { + return false; + } + String errorCode = serviceException.awsErrorDetails().errorCode(); + return errorCode != null && NON_RECOVERABLE_ERROR_CODES.contains(errorCode); + } + + private static AwsServiceException extractServiceException(RuntimeException e) { + if (e instanceof AwsServiceException) { + return (AwsServiceException) e; + } + if (e.getCause() instanceof AwsServiceException) { + return (AwsServiceException) e.getCause(); + } + return null; + } /** * Implemented by a child class to call STS and get a new set of credentials to be used by this provider. 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 dd4f0720f95d..59957b6b1a9d 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 @@ -23,14 +23,19 @@ import java.time.Duration; import java.time.Instant; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.MethodSource; 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.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; import software.amazon.awssdk.core.exception.SdkClientException; import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.sts.StsClient; @@ -160,6 +165,151 @@ public void initialFetchFailureThrowsException_noCachedCredentials() { } } + static Stream nonRecoverableErrorCodes() { + return StsCredentialsProvider.NON_RECOVERABLE_ERROR_CODES.stream(); + } + + /** + * Non-recoverable STS errors must bypass static stability and propagate to the caller immediately, + * even when cached credentials exist. This is because retrying the same request will never succeed + * for these error codes — the underlying problem (e.g., revoked access, invalid policy) requires + * operator intervention. + */ + @ParameterizedTest + @MethodSource("nonRecoverableErrorCodes") + public void nonRecoverableError_bypassesStaticStability_throwsImmediately(String errorCode) { + // First call returns valid but already-expired credentials (forces 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); + + AwsServiceException nonRecoverableException = AwsServiceException.builder() + .message("Access denied") + .awsErrorDetails(AwsErrorDetails.builder() + .errorCode(errorCode) + .errorMessage("Non-recoverable STS error") + .serviceName("STS") + .build()) + .statusCode(403) + .build(); + + // First call succeeds, second call fails with a non-recoverable error + when(callClient(stsClient, request)) + .thenReturn(response) + .thenThrow(nonRecoverableException); + + StsCredentialsProvider.BaseBuilder credentialsProviderBuilder = + createCredentialsProviderBuilder(request); + + try (StsCredentialsProvider credentialsProvider = credentialsProviderBuilder.stsClient(stsClient).build()) { + // First call succeeds and caches credentials + AwsCredentials firstResult = credentialsProvider.resolveCredentials(); + assertThat(((AwsSessionCredentials) firstResult).accessKeyId()).isEqualTo("a"); + + // Second call must throw because the error is non-recoverable — static stability must NOT absorb it + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(AwsServiceException.class) + .satisfies(e -> assertThat(((AwsServiceException) e).awsErrorDetails().errorCode()) + .isEqualTo(errorCode)); + } + } + + /** + * Non-recoverable errors wrapped inside an SdkClientException (as a cause) must also bypass + * static stability. The predicate extracts the AwsServiceException from the exception chain. + */ + @Test + public void nonRecoverableError_wrappedInSdkClientException_throwsImmediately() { + 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); + + AwsServiceException accessDenied = AwsServiceException.builder() + .message("Access denied") + .awsErrorDetails(AwsErrorDetails.builder() + .errorCode("AccessDenied") + .errorMessage("User is not authorized") + .serviceName("STS") + .build()) + .statusCode(403) + .build(); + // Wrap in SdkClientException as might happen in the real call path + SdkClientException wrappedException = SdkClientException.create("Failed to assume role", accessDenied); + + when(callClient(stsClient, request)) + .thenReturn(response) + .thenThrow(wrappedException); + + StsCredentialsProvider.BaseBuilder credentialsProviderBuilder = + createCredentialsProviderBuilder(request); + + try (StsCredentialsProvider credentialsProvider = credentialsProviderBuilder.stsClient(stsClient).build()) { + AwsCredentials firstResult = credentialsProvider.resolveCredentials(); + assertThat(((AwsSessionCredentials) firstResult).accessKeyId()).isEqualTo("a"); + + // Must throw — the wrapped AccessDenied is non-recoverable + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(SdkClientException.class) + .hasCauseInstanceOf(AwsServiceException.class); + } + } + + /** + * Verifies that recoverable errors (those with error codes NOT in the non-recoverable set) still + * benefit from static stability — the provider returns cached credentials instead of throwing. + * This is the complement to the non-recoverable error tests: a service unavailable or throttling + * error should not propagate immediately. + */ + @Test + public void recoverableError_staticStabilityReturnsCachedCredentials() { + 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); + + // A throttling error — recoverable, should be absorbed by static stability + AwsServiceException throttlingException = AwsServiceException.builder() + .message("Rate exceeded") + .awsErrorDetails(AwsErrorDetails.builder() + .errorCode("Throttling") + .errorMessage("Rate exceeded") + .serviceName("STS") + .build()) + .statusCode(400) + .build(); + + when(callClient(stsClient, request)) + .thenReturn(response) + .thenThrow(throttlingException); + + StsCredentialsProvider.BaseBuilder credentialsProviderBuilder = + createCredentialsProviderBuilder(request); + + try (StsCredentialsProvider credentialsProvider = credentialsProviderBuilder.stsClient(stsClient).build()) { + AwsCredentials firstResult = credentialsProvider.resolveCredentials(); + assertThat(((AwsSessionCredentials) firstResult).accessKeyId()).isEqualTo("a"); + + // Second call should return cached credentials — Throttling is recoverable + AwsCredentials secondResult = credentialsProvider.resolveCredentials(); + assertThat(secondResult).isInstanceOf(AwsSessionCredentials.class); + assertThat(((AwsSessionCredentials) secondResult).accessKeyId()).isEqualTo("a"); + } + } + /** * The advisory refresh window must be honored exactly, rather than being jittered to some later point. Here the * configured window covers the credential's entire lifetime, so the advisory window opens the moment the credentials are diff --git a/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsNonRecoverableErrorTest.java b/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsNonRecoverableErrorTest.java new file mode 100644 index 000000000000..447694fd0886 --- /dev/null +++ b/services/sts/src/test/java/software/amazon/awssdk/services/sts/auth/StsNonRecoverableErrorTest.java @@ -0,0 +1,163 @@ +/* + * 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.sts.auth; + +import static org.assertj.core.api.Assertions.assertThat; + +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 software.amazon.awssdk.awscore.exception.AwsErrorDetails; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.core.exception.SdkClientException; + +/** + * Unit tests for {@link StsCredentialsProvider#isNonRecoverableError(RuntimeException)}. + */ +class StsNonRecoverableErrorTest { + + static Stream nonRecoverableErrorCodes() { + return StsCredentialsProvider.NON_RECOVERABLE_ERROR_CODES.stream(); + } + + @ParameterizedTest + @MethodSource("nonRecoverableErrorCodes") + void isNonRecoverableError_matchingErrorCode_returnsTrue(String errorCode) { + AwsServiceException exception = AwsServiceException.builder() + .message("test") + .awsErrorDetails(AwsErrorDetails.builder() + .errorCode(errorCode) + .errorMessage("test") + .serviceName("STS") + .build()) + .statusCode(403) + .build(); + + assertThat(StsCredentialsProvider.isNonRecoverableError(exception)).isTrue(); + } + + @ParameterizedTest + @MethodSource("nonRecoverableErrorCodes") + void isNonRecoverableError_matchingErrorCodeAsCause_returnsTrue(String errorCode) { + AwsServiceException serviceException = AwsServiceException.builder() + .message("test") + .awsErrorDetails(AwsErrorDetails.builder() + .errorCode(errorCode) + .errorMessage("test") + .serviceName("STS") + .build()) + .statusCode(403) + .build(); + SdkClientException wrapper = SdkClientException.create("wrapped", serviceException); + + assertThat(StsCredentialsProvider.isNonRecoverableError(wrapper)).isTrue(); + } + + @Test + void isNonRecoverableError_recoverableErrorCode_returnsFalse() { + AwsServiceException exception = AwsServiceException.builder() + .message("Throttling") + .awsErrorDetails(AwsErrorDetails.builder() + .errorCode("Throttling") + .errorMessage("Rate exceeded") + .serviceName("STS") + .build()) + .statusCode(400) + .build(); + + assertThat(StsCredentialsProvider.isNonRecoverableError(exception)).isFalse(); + } + + @Test + void isNonRecoverableError_expiredTokenErrorCode_returnsFalse() { + AwsServiceException exception = AwsServiceException.builder() + .message("Token expired") + .awsErrorDetails(AwsErrorDetails.builder() + .errorCode("ExpiredTokenException") + .errorMessage("The security token included in the request is expired") + .serviceName("STS") + .build()) + .statusCode(403) + .build(); + + assertThat(StsCredentialsProvider.isNonRecoverableError(exception)).isFalse(); + } + + @Test + void isNonRecoverableError_sdkClientExceptionWithoutCause_returnsFalse() { + SdkClientException exception = SdkClientException.create("network timeout"); + + assertThat(StsCredentialsProvider.isNonRecoverableError(exception)).isFalse(); + } + + @Test + void isNonRecoverableError_nullErrorDetails_returnsFalse() { + AwsServiceException exception = AwsServiceException.builder() + .message("no details") + .statusCode(500) + .build(); + + assertThat(StsCredentialsProvider.isNonRecoverableError(exception)).isFalse(); + } + + @Test + void isNonRecoverableError_nullErrorCode_returnsFalse() { + AwsServiceException exception = AwsServiceException.builder() + .message("test") + .awsErrorDetails(AwsErrorDetails.builder() + .errorMessage("test") + .serviceName("STS") + .build()) + .statusCode(403) + .build(); + + assertThat(StsCredentialsProvider.isNonRecoverableError(exception)).isFalse(); + } + + @Test + void isNonRecoverableError_deeplyNestedCause_onlyChecksImmediateCause() { + // The predicate only looks one level deep for AwsServiceException — a deeply nested + // non-recoverable error should NOT be treated as non-recoverable + AwsServiceException accessDenied = AwsServiceException.builder() + .message("test") + .awsErrorDetails(AwsErrorDetails.builder() + .errorCode("AccessDenied") + .errorMessage("test") + .serviceName("STS") + .build()) + .statusCode(403) + .build(); + RuntimeException intermediate = new RuntimeException("intermediate", accessDenied); + SdkClientException wrapper = SdkClientException.create("outer", intermediate); + + // intermediate is a RuntimeException, not AwsServiceException, so the predicate should not + // find the deeply nested AccessDenied + assertThat(StsCredentialsProvider.isNonRecoverableError(wrapper)).isFalse(); + } + + @Test + void nonRecoverableErrorCodes_containsExpectedCodes() { + assertThat(StsCredentialsProvider.NON_RECOVERABLE_ERROR_CODES).containsExactlyInAnyOrder( + "AccessDenied", + "IDPRejectedClaim", + "InvalidIdentityToken", + "MalformedPolicyDocument", + "PackedPolicyTooLarge", + "RegionDisabledException" + ); + } +}