From f356108c797fa10a35f438552ab1aacf49ae988b Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Tue, 7 Jul 2026 11:52:39 -0700 Subject: [PATCH 1/9] feat(auth): Add IdentityProvider.invalidate() for credential cache invalidation Add invalidate() method to IdentityProvider interface returning CompletableFuture to allow the SDK to signal providers to mark cached credentials for refresh when target services reject them with ExpiredToken, InvalidToken, or AuthFailure errors. Key changes: - IdentityProvider: async invalidate(IdentityT) default method - SelectedAuthScheme: carry identityProvider reference (4-arg constructor) - AuthSchemeResolver: pass identityProvider when constructing SelectedAuthScheme - AuthErrorInvalidationHelper: detect auth errors and trigger invalidation - CachedSupplier: invalidate(Predicate) + nextAllowedRefreshTime backoff - All caching providers: invalidate with accessKeyId matching - All delegating providers: forward invalidate to delegate - EndpointResolverUtilsSpec: propagate identityProvider in codegen --- .../poet/rules/EndpointResolverUtilsSpec.java | 9 +- .../AwsCredentialsProviderChain.java | 16 + .../ContainerCredentialsProvider.java | 16 + .../DefaultCredentialsProvider.java | 7 + .../InstanceProfileCredentialsProvider.java | 16 + .../ProcessCredentialsProvider.java | 16 + .../ProfileCredentialsProvider.java | 11 + ...bIdentityTokenFileCredentialsProvider.java | 14 + .../internal/LazyAwsCredentialsProvider.java | 11 + .../IdentityProviderInvalidateTest.java | 301 ++++++++++ .../awssdk/identity/spi/IdentityProvider.java | 19 + .../awssdk/core/SelectedAuthScheme.java | 14 + .../core/http/auth/AuthSchemeResolver.java | 11 +- .../utils/AuthErrorInvalidationHelper.java | 121 ++++ .../stages/utils/RetryableStageHelper.java | 3 + .../AuthErrorInvalidationHelperTest.java | 321 ++++++++++ .../signin/auth/LoginCredentialsProvider.java | 24 +- ...oginProfileCredentialsProviderFactory.java | 7 + .../sso/auth/SsoCredentialsProvider.java | 16 +- .../SsoProfileCredentialsProviderFactory.java | 7 + .../sts/auth/StsCredentialsProvider.java | 16 + ...bIdentityTokenFileCredentialsProvider.java | 10 + .../StsProfileCredentialsProviderFactory.java | 7 + .../AuthErrorInvalidationIntegrationTest.java | 402 +++++++++++++ .../awssdk/utils/cache/CachedSupplier.java | 122 ++-- .../cache/CachedSupplierInvalidateTest.java | 559 ++++++++++++++++++ .../utils/cache/CachedSupplierTest.java | 43 +- 27 files changed, 2049 insertions(+), 70 deletions(-) create mode 100644 core/auth/src/test/java/software/amazon/awssdk/auth/credentials/IdentityProviderInvalidateTest.java 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/AuthErrorInvalidationIntegrationTest.java create mode 100644 utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierInvalidateTest.java 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..09eb138689b1 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,8 @@ 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 new $T<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build(), " + + "selectedAuthScheme.identityProvider())", SelectedAuthScheme.class); code.endControlFlow(); return code.build(); @@ -631,7 +632,8 @@ 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 new $T<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build(), " + + "selectedAuthScheme.identityProvider())", SelectedAuthScheme.class); code.endControlFlow(); return code.build(); @@ -656,7 +658,8 @@ 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 new $T<>(selectedAuthScheme.identity(), selectedAuthScheme.signer(), option.build(), " + + "selectedAuthScheme.identityProvider())", SelectedAuthScheme.class); code.endControlFlow(); return code.build(); 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..9fd7d8d057b2 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,21 @@ public AwsCredentials resolveCredentials() { .build(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + for (IdentityProvider provider : credentialsProviders) { + try { + @SuppressWarnings("unchecked") + IdentityProvider typedProvider = + (IdentityProvider) provider; + typedProvider.invalidate(identity); + } catch (Exception e) { + log.debug(() -> "Failed to invalidate provider " + provider + ": " + e.getMessage(), e); + } + } + return CompletableFuture.completedFuture(null); + } + @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..d19dc132edac 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,20 @@ public AwsCredentials resolveCredentials() { return credentialsCache.get(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + if (identity instanceof AwsCredentialsIdentity) { + String rejectedAccessKeyId = identity.accessKeyId(); + credentialsCache.invalidate(cachedCreds -> { + if (cachedCreds instanceof AwsCredentialsIdentity) { + return rejectedAccessKeyId.equals(((AwsCredentialsIdentity) cachedCreds).accessKeyId()); + } + return false; + }); + } + 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..3a38af7b47c1 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,20 @@ public AwsCredentials resolveCredentials() { return credentialsCache.get(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + if (identity instanceof AwsCredentialsIdentity) { + String rejectedAccessKeyId = identity.accessKeyId(); + credentialsCache.invalidate(cachedCreds -> { + if (cachedCreds instanceof AwsCredentialsIdentity) { + return rejectedAccessKeyId.equals(((AwsCredentialsIdentity) cachedCreds).accessKeyId()); + } + return false; + }); + } + 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..4f0035a4edfd 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,20 @@ public AwsCredentials resolveCredentials() { return processCredentialCache.get(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + if (identity instanceof AwsCredentialsIdentity) { + String rejectedAccessKeyId = identity.accessKeyId(); + processCredentialCache.invalidate(cachedCreds -> { + if (cachedCreds instanceof AwsCredentialsIdentity) { + return rejectedAccessKeyId.equals(((AwsCredentialsIdentity) cachedCreds).accessKeyId()); + } + return false; + }); + } + return CompletableFuture.completedFuture(null); + } + private RefreshResult refreshCredentials() { try { String processOutput = executeCommand(); 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..b8063f0ca735 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,15 @@ public void close() { IoUtils.closeIfCloseable(credentialsProvider, null); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + 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..385420294cc8 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,14 @@ 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.identity.spi.IdentityProvider; import software.amazon.awssdk.utils.IoUtils; import software.amazon.awssdk.utils.SdkAutoCloseable; import software.amazon.awssdk.utils.ToString; @@ -163,6 +166,17 @@ public void close() { IoUtils.closeIfCloseable(credentialsProvider, null); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + if (credentialsProvider instanceof IdentityProvider) { + @SuppressWarnings("unchecked") + IdentityProvider provider = + (IdentityProvider) credentialsProvider; + return provider.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/IdentityProviderInvalidateTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/IdentityProviderInvalidateTest.java new file mode 100644 index 000000000000..c2927d50d600 --- /dev/null +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/IdentityProviderInvalidateTest.java @@ -0,0 +1,301 @@ +/* + * 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.auth.credentials; + +import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; +import static com.github.tomakehurst.wiremock.client.WireMock.get; +import static com.github.tomakehurst.wiremock.client.WireMock.put; +import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; +import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; +import static org.assertj.core.api.Assertions.assertThat; +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 com.github.tomakehurst.wiremock.junit5.WireMockExtension; +import com.github.tomakehurst.wiremock.junit5.WireMockTest; +import java.time.Duration; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.RegisterExtension; +import software.amazon.awssdk.auth.credentials.internal.LazyAwsCredentialsProvider; +import software.amazon.awssdk.core.SdkSystemSetting; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; +import software.amazon.awssdk.utils.DateUtils; +import software.amazon.awssdk.testutils.EnvironmentVariableHelper; + +/** + * Unit tests for identity provider invalidation: chain propagation, LazyAwsCredentialsProvider delegation, + * and individual provider accessKeyId matching. + */ +@WireMockTest +class IdentityProviderInvalidateTest { + + private static final String TOKEN_RESOURCE_PATH = "/latest/api/token"; + private static final String CREDENTIALS_RESOURCE_PATH = "/latest/meta-data/iam/security-credentials/"; + private static final String PROFILE_NAME = "some-profile"; + private static final String TOKEN_STUB = "some-token"; + private static final EnvironmentVariableHelper environmentVariableHelper = new EnvironmentVariableHelper(); + + @RegisterExtension + static WireMockExtension wireMockServer = WireMockExtension.newInstance() + .options(wireMockConfig().dynamicPort().dynamicPort()) + .configureStaticDsl(true) + .build(); + + @BeforeEach + void setup() { + environmentVariableHelper.reset(); + System.setProperty(SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT.property(), + "http://localhost:" + wireMockServer.getPort()); + } + + @AfterAll + static void teardown() { + System.clearProperty(SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT.property()); + environmentVariableHelper.reset(); + } + + // ==================== Chain Propagation Tests ==================== + + @Test + void invalidate_chainPropagatesTo_allChildren() { + TrackingCredentialsProvider provider1 = new TrackingCredentialsProvider("key1", "secret1"); + TrackingCredentialsProvider provider2 = new TrackingCredentialsProvider("key2", "secret2"); + TrackingCredentialsProvider provider3 = new TrackingCredentialsProvider("key3", "secret3"); + + AwsCredentialsProviderChain chain = AwsCredentialsProviderChain.builder() + .credentialsProviders(provider1, provider2, provider3) + .build(); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); + chain.invalidate(identity); + + assertThat(provider1.invalidateCallCount).isEqualTo(1); + assertThat(provider2.invalidateCallCount).isEqualTo(1); + assertThat(provider3.invalidateCallCount).isEqualTo(1); + } + + @SuppressWarnings("unchecked") + @Test + void invalidate_chainDoesNotShortCircuit_whenChildThrows() { + AwsCredentialsProvider mockProvider1 = mock(AwsCredentialsProvider.class); + AwsCredentialsProvider mockProvider2 = mock(AwsCredentialsProvider.class); + AwsCredentialsProvider mockProvider3 = mock(AwsCredentialsProvider.class); + + // First provider throws on invalidate + doThrow(new RuntimeException("Provider 1 failed")).when(mockProvider1).invalidate(any()); + + AwsCredentialsProviderChain chain = AwsCredentialsProviderChain.builder() + .credentialsProviders(mockProvider1, mockProvider2, mockProvider3) + .build(); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); + chain.invalidate(identity); + + // All providers should still be called despite the first one throwing + verify(mockProvider1, times(1)).invalidate(identity); + verify(mockProvider2, times(1)).invalidate(identity); + verify(mockProvider3, times(1)).invalidate(identity); + } + + @Test + void invalidate_chainWithStaticProvider_isNoOpWithoutError() { + StaticCredentialsProvider staticProvider = StaticCredentialsProvider.create( + AwsBasicCredentials.create("staticKey", "staticSecret")); + TrackingCredentialsProvider trackingProvider = new TrackingCredentialsProvider("key1", "secret1"); + + AwsCredentialsProviderChain chain = AwsCredentialsProviderChain.builder() + .credentialsProviders(staticProvider, trackingProvider) + .build(); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); + + // Should not throw - StaticCredentialsProvider uses default no-op + chain.invalidate(identity); + + // The tracking provider should still receive the call + assertThat(trackingProvider.invalidateCallCount).isEqualTo(1); + } + + // ==================== LazyAwsCredentialsProvider Delegation Tests ==================== + + @Test + void invalidate_lazyProvider_initialized_delegatesToInner() { + TrackingCredentialsProvider innerProvider = new TrackingCredentialsProvider("key1", "secret1"); + LazyAwsCredentialsProvider lazyProvider = LazyAwsCredentialsProvider.create(() -> innerProvider); + + // Force initialization by calling resolveCredentials + lazyProvider.resolveCredentials(); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); + lazyProvider.invalidate(identity); + + assertThat(innerProvider.invalidateCallCount).isEqualTo(1); + } + + @Test + void invalidate_lazyProvider_notInitialized_isNoOp() { + List constructorCalled = new ArrayList<>(); + LazyAwsCredentialsProvider lazyProvider = LazyAwsCredentialsProvider.create(() -> { + constructorCalled.add(true); + return new TrackingCredentialsProvider("key1", "secret1"); + }); + + // Do NOT call resolveCredentials — provider should not be initialized + AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); + lazyProvider.invalidate(identity); + + // The supplier should never have been called - lazy not initialized + assertThat(constructorCalled).isEmpty(); + } + + // ==================== DefaultCredentialsProvider Delegation Test ==================== + + @Test + void invalidate_defaultCredentialsProvider_delegatesToLazyChain() { + // DefaultCredentialsProvider delegates to LazyAwsCredentialsProvider which delegates to chain. + // We verify end-to-end by using profile credentials and checking delegation completes without error. + DefaultCredentialsProvider provider = DefaultCredentialsProvider.builder() + .profileFile(profileWithCredentials("testKey", "testSecret")) + .profileName("test") + .build(); + + // Trigger initialization + AwsCredentials credentials = provider.resolveCredentials(); + assertThat(credentials.accessKeyId()).isEqualTo("testKey"); + + // Calling invalidate should not throw — it propagates through lazy -> chain -> children + AwsCredentialsIdentity identity = AwsBasicCredentials.create("testKey", "testSecret"); + provider.invalidate(identity); + } + + // ==================== Individual Provider AccessKeyId Matching Tests ==================== + + @Test + void invalidate_instanceProfileProvider_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 + "\"}"; + + stubImdsCredentials(credentialsJson); + + InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder().build(); + + // First call: fetches and caches credentials + AwsCredentials firstCredentials = provider.resolveCredentials(); + assertThat(firstCredentials.accessKeyId()).isEqualTo(accessKeyId); + + // Set up a different credential response for second fetch + String newAccessKeyId = "NEW_ACCESS_KEY_ID"; + String newCredentialsJson = "{\"AccessKeyId\":\"" + newAccessKeyId + "\"," + + "\"SecretAccessKey\":\"NEW_SECRET_ACCESS_KEY\"," + + "\"Expiration\":\"" + expirationFarFuture + "\"}"; + stubImdsCredentials(newCredentialsJson); + + // Invalidate with matching accessKeyId — should mark cache as stale + AwsCredentialsIdentity identity = AwsBasicCredentials.create(accessKeyId, "SECRET_ACCESS_KEY"); + provider.invalidate(identity); + + // Next resolveCredentials() should attempt a fresh fetch + AwsCredentials refreshedCredentials = provider.resolveCredentials(); + assertThat(refreshedCredentials.accessKeyId()).isEqualTo(newAccessKeyId); + } + + @Test + void invalidate_instanceProfileProvider_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 + "\"}"; + + stubImdsCredentials(credentialsJson); + + InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder().build(); + + // First call: fetches and caches credentials + AwsCredentials firstCredentials = provider.resolveCredentials(); + assertThat(firstCredentials.accessKeyId()).isEqualTo(accessKeyId); + + // Set up different credentials on the server (would be returned if cache is invalidated) + String newCredentialsJson = "{\"AccessKeyId\":\"NEW_ACCESS_KEY_ID\"," + + "\"SecretAccessKey\":\"NEW_SECRET_ACCESS_KEY\"," + + "\"Expiration\":\"" + expirationFarFuture + "\"}"; + stubImdsCredentials(newCredentialsJson); + + // Invalidate with a DIFFERENT accessKeyId — should NOT invalidate cache + AwsCredentialsIdentity differentIdentity = AwsBasicCredentials.create("DIFFERENT_KEY", "SECRET"); + provider.invalidate(differentIdentity); + + // Cache should still return the original cached credential + AwsCredentials secondCredentials = provider.resolveCredentials(); + assertThat(secondCredentials.accessKeyId()).isEqualTo(accessKeyId); + } + + // ==================== Helper Methods ==================== + + private void stubImdsCredentials(String credentialsJson) { + wireMockServer.stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)) + .willReturn(aResponse().withBody(TOKEN_STUB))); + wireMockServer.stubFor(get(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH)) + .willReturn(aResponse().withBody(PROFILE_NAME))); + wireMockServer.stubFor(get(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + PROFILE_NAME)) + .willReturn(aResponse().withBody(credentialsJson))); + } + + private java.util.function.Supplier profileWithCredentials( + String accessKeyId, String secretAccessKey) { + String contents = String.format("[test]\naws_access_key_id = %s\naws_secret_access_key = %s\n", + accessKeyId, secretAccessKey); + return () -> software.amazon.awssdk.profiles.ProfileFile.builder() + .content(new software.amazon.awssdk.utils.StringInputStream(contents)) + .type(software.amazon.awssdk.profiles.ProfileFile.Type.CREDENTIALS) + .build(); + } + + /** + * A simple AwsCredentialsProvider that tracks invalidate() calls. + */ + 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); + } + } +} 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..94582659213f 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,23 @@ 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). Implementations MUST NOT discard + * cached credentials entirely, and MUST NOT clear or bypass any refresh backoff.

+ * + *

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..4542bb71054a 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; @@ -48,13 +49,22 @@ public final class SelectedAuthScheme { private final CompletableFuture identity; private final HttpSigner signer; private final AuthSchemeOption authSchemeOption; + private final IdentityProvider identityProvider; public SelectedAuthScheme(CompletableFuture identity, HttpSigner signer, AuthSchemeOption authSchemeOption) { + this(identity, signer, authSchemeOption, null); + } + + public SelectedAuthScheme(CompletableFuture identity, + HttpSigner signer, + AuthSchemeOption authSchemeOption, + IdentityProvider identityProvider) { this.identity = Validate.paramNotNull(identity, "identity"); this.signer = Validate.paramNotNull(signer, "signer"); this.authSchemeOption = Validate.paramNotNull(authSchemeOption, "authSchemeOption"); + this.identityProvider = identityProvider; // nullable for backward compat } public CompletableFuture identity() { @@ -68,4 +78,8 @@ public HttpSigner signer() { public AuthSchemeOption authSchemeOption() { return authSchemeOption; } + + public IdentityProvider identityProvider() { + return identityProvider; + } } 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..831ad91c408b 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 @@ -158,7 +158,8 @@ public static SelectedAuthScheme mergePreExistingAuthSch return new SelectedAuthScheme<>( selectedAuthScheme.identity(), selectedAuthScheme.signer(), - mergedOption.build() + mergedOption.build(), + selectedAuthScheme.identityProvider() ); } @@ -184,7 +185,8 @@ public void accept(SignerProperty key, S value) { return new SelectedAuthScheme<>( selectedAuthScheme.identity(), selectedAuthScheme.signer(), - mergedOption.build() + mergedOption.build(), + selectedAuthScheme.identityProvider() ); } @@ -243,7 +245,8 @@ public void accept(SignerProperty key, S value) { attrs.putAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME, new SelectedAuthScheme<>(currentScheme.identity(), currentScheme.signer(), - mergedOption.build())); + mergedOption.build(), + currentScheme.identityProvider())); } } @@ -281,7 +284,7 @@ private static SelectedAuthScheme trySelectAuthScheme( CompletableFuture identity = resolveIdentity( identityProvider, identityRequestBuilder.build(), metricCollector); - return new SelectedAuthScheme<>(identity, signer, authOption); + return new SelectedAuthScheme<>(identity, signer, authOption, identityProvider); } private static CompletableFuture resolveIdentity( 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..143d882cd8b8 --- /dev/null +++ b/core/sdk-core/src/main/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelper.java @@ -0,0 +1,121 @@ +/* + * 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 java.util.Arrays; +import java.util.HashSet; +import java.util.Set; +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 {@code ExpiredToken}, {@code InvalidToken}, or {@code AuthFailure}, + * 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 static final Set INVALIDATION_ERROR_CODES = new HashSet<>(Arrays.asList( + "ExpiredToken", + "InvalidToken", + "AuthFailure" + )); + + 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; + String errorCode = extractErrorCode(serviceException); + + if (errorCode == null || !INVALIDATION_ERROR_CODES.contains(errorCode)) { + 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); + } + } + + /** + * Extracts the error code from an SdkServiceException. Since sdk-core does not depend on + * aws-core, this uses reflection to access awsErrorDetails().errorCode() which is defined + * on AwsServiceException. + */ + private static String extractErrorCode(SdkServiceException serviceException) { + try { + java.lang.reflect.Method awsErrorDetailsMethod = + serviceException.getClass().getMethod("awsErrorDetails"); + Object errorDetails = awsErrorDetailsMethod.invoke(serviceException); + if (errorDetails == null) { + return null; + } + java.lang.reflect.Method errorCodeMethod = errorDetails.getClass().getMethod("errorCode"); + Object errorCode = errorCodeMethod.invoke(errorDetails); + return errorCode instanceof String ? (String) errorCode : null; + } catch (NoSuchMethodException e) { + // Exception type does not have awsErrorDetails() — not an AWS service exception + return null; + } catch (Exception e) { + LOG.debug(() -> "Failed to extract error code from exception: " + e.getMessage(), e); + return null; + } + } + + @SuppressWarnings("unchecked") + private static void doInvalidate(SelectedAuthScheme selectedAuthScheme) { + T resolvedIdentity = CompletableFutureUtils.joinLikeSync(selectedAuthScheme.identity()); + IdentityProvider provider = selectedAuthScheme.identityProvider(); + provider.invalidate(resolvedIdentity); + } +} 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..058cc6d9c887 --- /dev/null +++ b/core/sdk-core/src/test/java/software/amazon/awssdk/core/internal/http/pipeline/stages/utils/AuthErrorInvalidationHelperTest.java @@ -0,0 +1,321 @@ +/* + * 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.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", "AuthFailure"}) + void invalidateIfAuthError_whenAuthErrorCode_triggersInvalidation(String errorCode) { + // Arrange + TrackingIdentityProvider provider = new TrackingIdentityProvider(); + TestIdentity identity = new TestIdentity(); + RequestExecutionContext context = contextWithProvider(provider, identity); + Throwable exception = serviceExceptionWithErrorCode(errorCode); + + // Act + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context); + + // Assert + assertThat(provider.invalidateCalled()).isTrue(); + assertThat(provider.lastInvalidatedIdentity()).isSameAs(identity); + } + + @Test + void invalidateIfAuthError_whenAccessDenied_doesNotTriggerInvalidation() { + // Arrange + TrackingIdentityProvider provider = new TrackingIdentityProvider(); + TestIdentity identity = new TestIdentity(); + RequestExecutionContext context = contextWithProvider(provider, identity); + Throwable exception = serviceExceptionWithErrorCode("AccessDenied"); + + // Act + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context); + + // Assert + assertThat(provider.invalidateCalled()).isFalse(); + } + + @ParameterizedTest + @ValueSource(strings = {"ThrottlingException", "InternalServerError", "ValidationException", "ResourceNotFoundException"}) + void invalidateIfAuthError_whenUnknownErrorCode_doesNotTriggerInvalidation(String errorCode) { + // Arrange + TrackingIdentityProvider provider = new TrackingIdentityProvider(); + TestIdentity identity = new TestIdentity(); + RequestExecutionContext context = contextWithProvider(provider, identity); + Throwable exception = serviceExceptionWithErrorCode(errorCode); + + // Act + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context); + + // Assert + assertThat(provider.invalidateCalled()).isFalse(); + } + + @ParameterizedTest + @MethodSource("nonServiceExceptions") + void invalidateIfAuthError_whenNonSdkServiceException_doesNotTriggerInvalidation(Throwable exception) { + // Arrange + TrackingIdentityProvider provider = new TrackingIdentityProvider(); + TestIdentity identity = new TestIdentity(); + RequestExecutionContext context = contextWithProvider(provider, identity); + + // Act + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context); + + // Assert + 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() { + // Arrange — context with no SELECTED_AUTH_SCHEME attribute + RequestExecutionContext context = contextWithNoAuthScheme(); + Throwable exception = serviceExceptionWithErrorCode("ExpiredToken"); + + // Act & Assert — no NPE, no invalidation + assertThatNoException().isThrownBy(() -> + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context) + ); + } + + @Test + void invalidateIfAuthError_whenIdentityProviderIsNull_doesNotThrow() { + // Arrange — SelectedAuthScheme created with 3-arg constructor (null identityProvider) + 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"); + + // Act & Assert — no NPE, no invalidation + assertThatNoException().isThrownBy(() -> + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context) + ); + } + + @Test + void invalidateIfAuthError_whenInvalidateThrowsException_doesNotPropagate() { + // Arrange — provider that throws on invalidate() + ThrowingIdentityProvider provider = new ThrowingIdentityProvider(); + TestIdentity identity = new TestIdentity(); + SelectedAuthScheme selectedAuthScheme = new SelectedAuthScheme<>( + CompletableFuture.completedFuture(identity), + mockSigner(), + AuthSchemeOption.builder().schemeId("test").build(), + provider + ); + + RequestExecutionContext context = contextWithSelectedAuthScheme(selectedAuthScheme); + Throwable exception = serviceExceptionWithErrorCode("ExpiredToken"); + + // Act & Assert — exception is caught internally, method returns normally + assertThatNoException().isThrownBy(() -> + AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context) + ); + } + + // --- Helper methods --- + + private RequestExecutionContext contextWithProvider(TrackingIdentityProvider provider, TestIdentity identity) { + SelectedAuthScheme selectedAuthScheme = new SelectedAuthScheme<>( + CompletableFuture.completedFuture(identity), + mockSigner(), + AuthSchemeOption.builder().schemeId("test").build(), + provider + ); + 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 with an {@code awsErrorDetails()} method that returns + * an object with an {@code errorCode()} method. This works with the reflection-based approach + * in AuthErrorInvalidationHelper. + */ + 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 accessible via reflection. The AuthErrorInvalidationHelper + * uses reflection to call awsErrorDetails().errorCode() since sdk-core doesn't depend on aws-core. + * Any class with these methods will work via reflection. + */ + private static class TestAwsServiceException extends SdkServiceException { + private final TestAwsErrorDetails awsErrorDetails; + + TestAwsServiceException(String errorCode) { + super(SdkServiceException.builder().message("test exception").statusCode(401)); + this.awsErrorDetails = new TestAwsErrorDetails(errorCode); + } + + public TestAwsErrorDetails awsErrorDetails() { + return awsErrorDetails; + } + } + + /** + * Simulates AwsErrorDetails. The reflection in AuthErrorInvalidationHelper calls + * awsErrorDetails().errorCode(), so this class just needs an errorCode() method. + */ + private static class TestAwsErrorDetails { + private final String errorCode; + + TestAwsErrorDetails(String errorCode) { + this.errorCode = errorCode; + } + + public String errorCode() { + return 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..9650a787303f 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)); } @@ -217,11 +219,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 +245,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 +297,20 @@ public AwsCredentials resolveCredentials() { return credentialCache.get(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + if (identity instanceof AwsCredentialsIdentity) { + String rejectedAccessKeyId = identity.accessKeyId(); + credentialCache.invalidate(cachedCreds -> { + if (cachedCreds instanceof AwsCredentialsIdentity) { + return rejectedAccessKeyId.equals(((AwsCredentialsIdentity) cachedCreds).accessKeyId()); + } + return false; + }); + } + 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..ea876e1bec09 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,18 @@ public AwsCredentials resolveCredentials() { return credentialCache.get().sessionCredentials(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + if (identity instanceof AwsCredentialsIdentity) { + String rejectedAccessKeyId = identity.accessKeyId(); + credentialCache.invalidate(holder -> { + AwsCredentialsIdentity cachedCreds = holder.sessionCredentials(); + return rejectedAccessKeyId.equals(cachedCreds.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/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..9a48bd740bc5 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,20 @@ public void close() { sessionCache.close(); } + @Override + public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + if (identity instanceof AwsCredentialsIdentity) { + String rejectedAccessKeyId = identity.accessKeyId(); + sessionCache.invalidate(cachedCreds -> { + if (cachedCreds instanceof AwsCredentialsIdentity) { + return rejectedAccessKeyId.equals(((AwsCredentialsIdentity) cachedCreds).accessKeyId()); + } + return false; + }); + } + 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/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationIntegrationTest.java b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationIntegrationTest.java new file mode 100644 index 000000000000..57cae1dd3f4c --- /dev/null +++ b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationIntegrationTest.java @@ -0,0 +1,402 @@ +/* + * 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 java.net.URI; +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Supplier; +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.core.retry.RetryMode; +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; +import software.amazon.awssdk.utils.cache.CachedSupplier; +import software.amazon.awssdk.utils.cache.RefreshResult; + +/** + * Integration test verifying the end-to-end flow: + * service returns ExpiredToken → credentials invalidated → retry uses fresh credentials. + * + * Also verifies backoff interaction: when refresh backoff is active, invalidate() does not + * bypass it, and stale cached credentials are returned until the backoff elapses. + * + * Validates Requirements: 6, 8, 9, 10, 21 + */ +public class AuthErrorInvalidationIntegrationTest { + + /** + * End-to-end: ExpiredToken → invalidation is triggered → next call uses fresh credentials. + * + * The SDK resolves identity once per API call (in beforeExecution interceptor), so the retry + * within the same call reuses the same identity. However, invalidation marks the provider's + * cache as stale, ensuring the NEXT API call resolves fresh credentials. + * + * Scenario: + * 1. First API call: signed with "old-key", service returns 500 with ExpiredToken + * 2. SDK calls invalidate() on the provider (switching it to return "new-key") + * 3. SDK retries (same identity — "old-key" — because identity was already resolved) + * 4. Second retry succeeds with 200 + * 5. Second API call: resolves fresh identity "new-key" + * 6. Verify invalidate was called and second API call uses new credentials + */ + @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) + .overrideConfiguration(c -> c.retryStrategy(RetryMode.STANDARD)) + .build()) { + + // First API call: 500 ExpiredToken (retried), then 200 on retry + mockHttpClient.stubResponses(expiredTokenResponse(), successResponse()); + + // First call succeeds on retry (same credentials used for both attempts since + // identity is resolved once per API call) + client.allTypes(); + + // Verify invalidate was called during the first API call + assertThat(credentialsProvider.invalidateCallCount()).isEqualTo(1); + + // Verify first call's requests both used "old-key" (same identity, same call) + List firstCallRequests = mockHttpClient.getRequests(); + assertThat(firstCallRequests).hasSize(2); + assertRequestUsedAccessKey(firstCallRequests.get(0), "old-key"); + assertRequestUsedAccessKey(firstCallRequests.get(1), "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. + * + * Scenario: + * 1. Client makes a request signed with "my-key" + * 2. Service returns 403 with error code "AccessDenied" + * 3. SDK does NOT invalidate credentials (AccessDenied is not an auth error for invalidation) + * 4. Request fails (403 is not retryable by default for standard retry) + */ + @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) + .overrideConfiguration(c -> c.retryStrategy(RetryMode.STANDARD)) + .build()) { + + // Response: 403 with AccessDenied error code + HttpExecuteResponse accessDeniedResponse = accessDeniedResponse(); + mockHttpClient.stubResponses(accessDeniedResponse); + + try { + client.allTypes(); + } catch (Exception e) { + // Expected — 403 is not retryable + } + + // Verify invalidate was NOT called + assertThat(credentialsProvider.invalidateCallCount()).isEqualTo(0); + } + } + + /** + * Backoff interaction: invalidation during active backoff returns stale credentials. + * + * This test verifies that when CachedSupplier has an active refresh backoff + * (nextAllowedRefreshTime in the future), calling invalidate() marks the cache stale + * but does NOT bypass the backoff. The next get() still returns cached credentials + * until the backoff elapses. + * + * Validates Requirements: 9, 10 + */ + @Test + public void invalidation_duringActiveBackoff_returnsStaleCredentials() { + AdjustableClock clock = new AdjustableClock(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + AtomicInteger fetchCount = new AtomicInteger(0); + AtomicReference>> supplierRef = new AtomicReference<>(); + + // Set up the initial supplier that returns credentials + supplierRef.set(() -> RefreshResult.builder("access-key-1") + .staleTime(now.plusSeconds(60)) + .prefetchTime(now.plusSeconds(30)) + .build()); + + try (CachedSupplier cache = CachedSupplier.builder(() -> { + fetchCount.incrementAndGet(); + return supplierRef.get().get(); + }) + .staleValueBehavior(CachedSupplier.StaleValueBehavior.ALLOW) + .clock(clock) + .build()) { + + // Initial fetch + assertThat(cache.get()).isEqualTo("access-key-1"); + assertThat(fetchCount.get()).isEqualTo(1); + + // Advance past stale time and trigger a refresh failure to set nextAllowedRefreshTime + clock.time = now.plusSeconds(61); + supplierRef.set(() -> { + throw new RuntimeException("credential source unavailable"); + }); + // This get() will try to refresh, fail, set backoff, and return stale value + assertThat(cache.get()).isEqualTo("access-key-1"); + + // Now call invalidate — marks staleTime = now but does NOT clear backoff + clock.time = now.plusSeconds(62); + cache.invalidate(v -> v.equals("access-key-1")); + + // Set up fresh credentials in the supplier + supplierRef.set(() -> RefreshResult.builder("access-key-2") + .staleTime(now.plusSeconds(7200)) + .prefetchTime(now.plusSeconds(5400)) + .build()); + + // get() should return STALE credentials because backoff is still active + // (nextAllowedRefreshTime = ~now+61 + [300,600]s, we're at now+62) + assertThat(cache.get()).isEqualTo("access-key-1"); + + // Advance past maximum possible backoff (61 + 600 = 661 seconds from original now) + clock.time = now.plusSeconds(700); + + // Now backoff has elapsed — next get() should refresh and return fresh value + assertThat(cache.get()).isEqualTo("access-key-2"); + } + } + + /** + * Verify that after backoff elapses following invalidation, fresh credentials are obtained. + * + * This tests the complete lifecycle: + * 1. Cache has credentials + * 2. Refresh fails → backoff is set + * 3. invalidate() is called → staleTime set to now (but backoff stays) + * 4. During backoff → stale value returned + * 5. After backoff → fresh value obtained + * + * Validates Requirements: 8, 9, 10 + */ + @Test + public void afterBackoffElapses_invalidatedCache_obtainsFreshCredentials() { + AdjustableClock clock = new AdjustableClock(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + AtomicReference>> supplierRef = new AtomicReference<>(); + supplierRef.set(() -> RefreshResult.builder("cred-A") + .staleTime(now.plusSeconds(60)) + .prefetchTime(now.plusSeconds(30)) + .build()); + + try (CachedSupplier cache = CachedSupplier.builder(() -> supplierRef.get().get()) + .staleValueBehavior(CachedSupplier.StaleValueBehavior.ALLOW) + .clock(clock) + .build()) { + + // Initial fetch + assertThat(cache.get()).isEqualTo("cred-A"); + + // Advance time so cache is stale and trigger failure + clock.time = now.plusSeconds(61); + supplierRef.set(() -> { + throw new RuntimeException("source down"); + }); + assertThat(cache.get()).isEqualTo("cred-A"); // stale value returned + + // Invalidate + clock.time = now.plusSeconds(62); + cache.invalidate(v -> v.equals("cred-A")); + + // Set up fresh supplier + supplierRef.set(() -> RefreshResult.builder("cred-B") + .staleTime(now.plusSeconds(7200)) + .prefetchTime(now.plusSeconds(5400)) + .build()); + + // Still within backoff — stale returned + clock.time = now.plusSeconds(100); + assertThat(cache.get()).isEqualTo("cred-A"); + + // Advance past max backoff + clock.time = now.plusSeconds(700); + assertThat(cache.get()).isEqualTo("cred-B"); + } + } + + // --- 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\"}"; + // Use 500 so the standard retry strategy retries it. + // In production, services may return 4xx for expired tokens, but the SDK only retries + // on 5xx status codes or specific retryable error codes. Using 500 ensures the retry + // happens so we can verify the full invalidation → retry → fresh credentials flow. + return HttpExecuteResponse.builder() + .response(SdkHttpResponse.builder() + .statusCode(500) + .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(); + } + } + + /** + * A clock whose time can be manually adjusted for testing backoff behavior. + */ + private static class AdjustableClock extends Clock { + volatile Instant time = Instant.now(); + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + return this; + } + + @Override + public Instant instant() { + return time; + } + } +} 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..889affec34d1 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,48 @@ 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}.

+ * + * @param matchesCachedValue A predicate that returns true if the cached value + * is the one that should be invalidated. + */ + public void invalidate(Predicate matchesCachedValue) { + try { + boolean lockAcquired = refreshLock.tryLock(BLOCKING_REFRESH_MAX_WAIT.getSeconds(), TimeUnit.SECONDS); + try { + RefreshResult currentCachedValue = this.cachedValue; + if (currentCachedValue == 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 { + if (lockAcquired) { + refreshLock.unlock(); + } + } + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + log.warn(() -> "(" + cachedValueName + ") Interrupted while invalidating cached value."); + } + } + /** * Determines whether the value in this cache is stale, and all threads should block and wait for an updated value. */ @@ -189,6 +246,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 +308,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())) { @@ -283,26 +351,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); + + ". 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 +374,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; + 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); + + ". Will retry after " + backoffSeconds + " seconds.", e); - return currentCachedValue.toBuilder() - .staleTime(newStaleTime) - .prefetchTime(extendedPrefetchTime) - .build(); + return currentCachedValue; // Return unchanged — staleTime/prefetchTime untouched } return currentCachedValue; @@ -423,7 +477,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; @@ -472,11 +526,11 @@ public Builder cachedValueName(String cachedValueName) { * 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 + *

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 +612,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} + *

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/CachedSupplierInvalidateTest.java b/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierInvalidateTest.java new file mode 100644 index 000000000000..5ba8d494766d --- /dev/null +++ b/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierInvalidateTest.java @@ -0,0 +1,559 @@ +/* + * 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 static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZoneOffset; +import java.util.ArrayList; +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.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.function.Supplier; +import org.junit.jupiter.api.Test; + +/** + * Unit tests for {@link CachedSupplier#invalidate} and the {@code nextAllowedRefreshTime} backoff behavior. + * + * Validates Requirements: 8, 9, 10, 12, 13 + */ +public class CachedSupplierInvalidateTest { + + // --- Test 1: Predicate matching — true case --- + + @Test + public void invalidate_predicateMatches_setsStaleTimeToNow_triggersRefresh() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + // Initial fetch + supplier.set(RefreshResult.builder("value-1") + .staleTime(now.plusSeconds(3600)) + .prefetchTime(now.plusSeconds(1800)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("value-1"); + + // Invalidate with matching predicate + clock.time = now.plusSeconds(10); + cachedSupplier.invalidate(v -> v.equals("value-1")); + + // Set up a new value for the refresh + supplier.set(RefreshResult.builder("value-2") + .staleTime(now.plusSeconds(7200)) + .prefetchTime(now.plusSeconds(5400)) + .build()); + + // Next get() should trigger refresh because staleTime was set to now + assertThat(cachedSupplier.get()).isEqualTo("value-2"); + } + } + + // --- Test 2: Predicate matching — false case --- + + @Test + public void invalidate_predicateDoesNotMatch_nothingChanges() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + // Initial fetch + supplier.set(RefreshResult.builder("value-1") + .staleTime(now.plusSeconds(3600)) + .prefetchTime(now.plusSeconds(1800)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("value-1"); + + // Invalidate with non-matching predicate + cachedSupplier.invalidate(v -> v.equals("different-value")); + + // Set a new value — should NOT be fetched since cache is still valid + supplier.set(RefreshResult.builder("value-2") + .staleTime(now.plusSeconds(7200)) + .prefetchTime(now.plusSeconds(5400)) + .build()); + + // Should still return the original cached value + assertThat(cachedSupplier.get()).isEqualTo("value-1"); + } + } + + // --- Test 3: Null cached value --- + + @Test + public void invalidate_nullCachedValue_doesNothing() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + // Call invalidate before any get() — cachedValue is null + // Should not throw + cachedSupplier.invalidate(v -> true); + + // Now set up a supplier and get the value normally + supplier.set(RefreshResult.builder("value-1") + .staleTime(now.plusSeconds(3600)) + .prefetchTime(now.plusSeconds(1800)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("value-1"); + } + } + + // --- Test 4: Value preservation — successful refresh returns fresh value --- + + @Test + public void invalidate_successfulRefresh_returnsFreshValue() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + // Initial fetch + supplier.set(RefreshResult.builder("old-creds") + .staleTime(now.plusSeconds(3600)) + .prefetchTime(now.plusSeconds(1800)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("old-creds"); + + // Invalidate + clock.time = now.plusSeconds(10); + cachedSupplier.invalidate(v -> v.equals("old-creds")); + + // Supplier returns fresh value + supplier.set(RefreshResult.builder("new-creds") + .staleTime(now.plusSeconds(7200)) + .prefetchTime(now.plusSeconds(5400)) + .build()); + + // get() triggers refresh and returns new value + assertThat(cachedSupplier.get()).isEqualTo("new-creds"); + } + } + + // --- Test 4b: Value preservation — backoff active returns stale cached value --- + + @Test + public void invalidate_backoffActive_returnsStaleCachedValue() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + // Initial fetch + supplier.set(RefreshResult.builder("old-creds") + .staleTime(now.plusSeconds(60)) + .prefetchTime(now.plusSeconds(30)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("old-creds"); + + // Advance past stale time and trigger a failure to set nextAllowedRefreshTime + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("service unavailable")); + assertThat(cachedSupplier.get()).isEqualTo("old-creds"); + + // Now nextAllowedRefreshTime is set (now+61 + [300,600]s) + // Call invalidate — it should NOT clear nextAllowedRefreshTime + clock.time = now.plusSeconds(62); + cachedSupplier.invalidate(v -> v.equals("old-creds")); + + // Set up a fresh value in the supplier + supplier.set(RefreshResult.builder("new-creds") + .staleTime(Instant.MAX) + .prefetchTime(Instant.MAX) + .build()); + + // get() should still return the cached (stale) value because backoff is active + assertThat(cachedSupplier.get()).isEqualTo("old-creds"); + } + } + + // --- Test 5: Backoff gating — invalidate does NOT clear nextAllowedRefreshTime --- + + @Test + public void invalidate_doesNotClearNextAllowedRefreshTime() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + // Initial 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 trigger failure to set backoff gate + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("service unavailable")); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Backoff gate is now set to somewhere in [61+300, 61+600] seconds from 'now' + // Call invalidate + clock.time = now.plusSeconds(70); + cachedSupplier.invalidate(v -> v.equals("cached-creds")); + + // Prepare a new value — but backoff should prevent refresh + supplier.set(RefreshResult.builder("new-creds") + .staleTime(Instant.MAX) + .prefetchTime(Instant.MAX) + .build()); + + // Still within backoff — should return cached value + clock.time = now.plusSeconds(100); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past maximum possible backoff (61 + 600 = 661s) + clock.time = now.plusSeconds(700); + // Now backoff has elapsed — refresh should succeed + assertThat(cachedSupplier.get()).isEqualTo("new-creds"); + } + } + + // --- Test 5b: After nextAllowedRefreshTime elapses, get() attempts refresh --- + + @Test + public void afterBackoffElapses_getAttemptsRefresh() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + // Initial fetch + supplier.set(RefreshResult.builder("cached-creds") + .staleTime(now.plusSeconds(60)) + .prefetchTime(now.plusSeconds(30)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Trigger failure and set backoff + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("service unavailable")); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Invalidate so staleTime = now + clock.time = now.plusSeconds(62); + cachedSupplier.invalidate(v -> v.equals("cached-creds")); + + // Prepare fresh value + supplier.set(RefreshResult.builder("fresh-creds") + .staleTime(now.plusSeconds(7200)) + .prefetchTime(now.plusSeconds(5400)) + .build()); + + // Advance past maximum backoff: 61 + 600 = 661s from now + clock.time = now.plusSeconds(700); + // Backoff elapsed, staleTime (set to now by invalidate) is in the past → mandatory refresh + assertThat(cachedSupplier.get()).isEqualTo("fresh-creds"); + } + } + + // --- Test 6: Successful refresh clears nextAllowedRefreshTime --- + + @Test + public void successfulRefresh_clearsNextAllowedRefreshTime() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + // Initial fetch + supplier.set(RefreshResult.builder("value-1") + .staleTime(now.plusSeconds(60)) + .prefetchTime(now.plusSeconds(30)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("value-1"); + + // Trigger failure — sets nextAllowedRefreshTime + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("service unavailable")); + assertThat(cachedSupplier.get()).isEqualTo("value-1"); + + // Advance past maximum backoff and do a successful refresh + clock.time = now.plusSeconds(700); + supplier.set(RefreshResult.builder("value-2") + .staleTime(now.plusSeconds(3700)) + .prefetchTime(now.plusSeconds(2000)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("value-2"); + + // Now invalidate and verify immediate refresh happens (no backoff gate blocking) + clock.time = now.plusSeconds(710); + cachedSupplier.invalidate(v -> v.equals("value-2")); + + supplier.set(RefreshResult.builder("value-3") + .staleTime(now.plusSeconds(7200)) + .prefetchTime(now.plusSeconds(5400)) + .build()); + + // Since nextAllowedRefreshTime was cleared by the successful refresh, + // the invalidation should trigger an immediate refresh + assertThat(cachedSupplier.get()).isEqualTo("value-3"); + } + } + + // --- Test 7: Thread safety --- + + @Test + public void concurrentInvalidateAndGet_noCorruption() throws Exception { + AdjustableClock clock = new AdjustableClock(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + AtomicInteger counter = new AtomicInteger(0); + Supplier> supplier = () -> { + String val = "value-" + counter.incrementAndGet(); + return RefreshResult.builder(val) + .staleTime(now.plusSeconds(3600)) + .prefetchTime(now.plusSeconds(1800)) + .build(); + }; + + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + // Prime the cache + assertThat(cachedSupplier.get()).isNotNull(); + + int threadCount = 20; + int iterations = 100; + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch startLatch = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + for (int i = 0; i < threadCount; i++) { + final int threadIdx = i; + futures.add(executor.submit(() -> { + try { + startLatch.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + for (int j = 0; j < iterations; j++) { + if (threadIdx % 2 == 0) { + // Half the threads call invalidate + cachedSupplier.invalidate(v -> true); + } else { + // Half the threads call get + String value = cachedSupplier.get(); + assertThat(value).isNotNull(); + } + } + })); + } + + // Release all threads simultaneously + startLatch.countDown(); + + // Wait for all to complete + for (Future future : futures) { + future.get(30, TimeUnit.SECONDS); + } + + executor.shutdown(); + assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); + + // Final get() should return a non-null value + assertThat(cachedSupplier.get()).isNotNull(); + } + } + + // --- Test 8: Invalidate then refresh success (full cycle) --- + + @Test + public void invalidateThenRefreshSuccess_fullCycle() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + // Step 1: Initial get() + supplier.set(RefreshResult.builder("initial-value") + .staleTime(now.plusSeconds(3600)) + .prefetchTime(now.plusSeconds(1800)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("initial-value"); + + // Step 2: invalidate() + clock.time = now.plusSeconds(10); + cachedSupplier.invalidate(v -> v.equals("initial-value")); + + // Step 3: get() triggers refresh → success returns new value + supplier.set(RefreshResult.builder("refreshed-value") + .staleTime(now.plusSeconds(7200)) + .prefetchTime(now.plusSeconds(5400)) + .build()); + assertThat(cachedSupplier.get()).isEqualTo("refreshed-value"); + } + } + + // --- Test 9: Invalidate does NOT modify nextAllowedRefreshTime (explicit scenario) --- + + @Test + public void invalidate_withActiveBackoff_doesNotModifyNextAllowedRefreshTime() { + AdjustableClock clock = new AdjustableClock(); + MutableSupplier supplier = new MutableSupplier(); + Instant now = Instant.parse("2024-01-01T00:00:00Z"); + clock.time = now; + + try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) + .staleValueBehavior(ALLOW) + .clock(clock) + .jitterEnabled(false) + .build()) { + // Initial 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 trigger failure to set nextAllowedRefreshTime + clock.time = now.plusSeconds(61); + supplier.set(new RuntimeException("temporary failure")); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // At this point, nextAllowedRefreshTime is set somewhere in [361, 661] seconds from 'now' + // Call invalidate — should NOT modify nextAllowedRefreshTime + clock.time = now.plusSeconds(65); + cachedSupplier.invalidate(v -> v.equals("cached-creds")); + + // Prepare a new value that would be returned if refresh is attempted + supplier.set(RefreshResult.builder("new-creds") + .staleTime(Instant.MAX) + .prefetchTime(Instant.MAX) + .build()); + + // Still within the backoff window (65s < minimum backoff end at 361s) + // get() should return cached value without attempting refresh + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance slightly — still within backoff range + clock.time = now.plusSeconds(200); + assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); + + // Advance past maximum possible backoff (61 + 600 = 661s) + clock.time = now.plusSeconds(700); + // Now the backoff has elapsed — refresh should be attempted and succeed + assertThat(cachedSupplier.get()).isEqualTo("new-creds"); + } + } + + // --- Helper classes --- + + private static class MutableSupplier implements Supplier> { + private volatile RuntimeException thingToThrow; + private volatile RefreshResult thingToReturn; + + @Override + public RefreshResult get() { + if (thingToThrow != null) { + throw thingToThrow; + } + return thingToReturn; + } + + private MutableSupplier set(RuntimeException exception) { + this.thingToThrow = exception; + this.thingToReturn = null; + return this; + } + + private MutableSupplier set(RefreshResult value) { + this.thingToThrow = null; + this.thingToReturn = value; + return this; + } + } + + private static class AdjustableClock extends Clock { + private volatile Instant time; + + @Override + public ZoneId getZone() { + return ZoneOffset.UTC; + } + + @Override + public Clock withZone(ZoneId zone) { + throw new UnsupportedOperationException(); + } + + @Override + public Instant instant() { + return time; + } + } +} 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..fc6318104f4b 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 @@ -397,7 +397,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 +449,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 +493,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 +535,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 +553,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) From 511b5af1f9a3710d9ba6acea5cd47d4f185cc22c Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Tue, 7 Jul 2026 13:10:42 -0700 Subject: [PATCH 2/9] Minor cleanups --- .../AwsCredentialsProviderChain.java | 9 ++- .../ContainerCredentialsProvider.java | 13 +--- .../InstanceProfileCredentialsProvider.java | 13 +--- .../ProcessCredentialsProvider.java | 13 +--- .../CredentialsInvalidationUtils.java | 75 +++++++++++++++++++ .../sso/auth/SsoCredentialsProvider.java | 11 +-- .../sts/auth/StsCredentialsProvider.java | 13 +--- 7 files changed, 97 insertions(+), 50 deletions(-) create mode 100644 core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/CredentialsInvalidationUtils.java 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 9fd7d8d057b2..149db4cad444 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 @@ -133,17 +133,22 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { + List> futures = new ArrayList<>(); for (IdentityProvider provider : credentialsProviders) { try { @SuppressWarnings("unchecked") IdentityProvider typedProvider = (IdentityProvider) provider; - typedProvider.invalidate(identity); + CompletableFuture future = typedProvider.invalidate(identity); + futures.add(future.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); + return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); } @Override 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 d19dc132edac..7f8b6df99533 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 @@ -38,6 +38,7 @@ import java.util.function.Predicate; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.auth.credentials.internal.ContainerCredentialsRetryPolicy; +import software.amazon.awssdk.auth.credentials.internal.CredentialsInvalidationUtils; import software.amazon.awssdk.auth.credentials.internal.HttpCredentialsLoader; import software.amazon.awssdk.auth.credentials.internal.HttpCredentialsLoader.LoadedCredentials; import software.amazon.awssdk.core.SdkSystemSetting; @@ -190,16 +191,8 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { - if (identity instanceof AwsCredentialsIdentity) { - String rejectedAccessKeyId = identity.accessKeyId(); - credentialsCache.invalidate(cachedCreds -> { - if (cachedCreds instanceof AwsCredentialsIdentity) { - return rejectedAccessKeyId.equals(((AwsCredentialsIdentity) cachedCreds).accessKeyId()); - } - return false; - }); - } - return CompletableFuture.completedFuture(null); + return CredentialsInvalidationUtils.invalidateCredentialsCache( + identity, credentialsCache, cachedCreds -> (AwsCredentialsIdentity) cachedCreds); } @Override 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 3a38af7b47c1..8a8ad80ed20c 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 @@ -30,6 +30,7 @@ import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.annotations.SdkTestInternalApi; +import software.amazon.awssdk.auth.credentials.internal.CredentialsInvalidationUtils; import software.amazon.awssdk.auth.credentials.internal.Ec2MetadataConfigProvider; import software.amazon.awssdk.auth.credentials.internal.HttpCredentialsLoader; import software.amazon.awssdk.auth.credentials.internal.HttpCredentialsLoader.LoadedCredentials; @@ -171,16 +172,8 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { - if (identity instanceof AwsCredentialsIdentity) { - String rejectedAccessKeyId = identity.accessKeyId(); - credentialsCache.invalidate(cachedCreds -> { - if (cachedCreds instanceof AwsCredentialsIdentity) { - return rejectedAccessKeyId.equals(((AwsCredentialsIdentity) cachedCreds).accessKeyId()); - } - return false; - }); - } - return CompletableFuture.completedFuture(null); + return CredentialsInvalidationUtils.invalidateCredentialsCache( + identity, credentialsCache, cachedCreds -> (AwsCredentialsIdentity) cachedCreds); } private RefreshResult refreshCredentials() { 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 4f0035a4edfd..69947f123df1 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 @@ -27,6 +27,7 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; import software.amazon.awssdk.annotations.SdkPublicApi; +import software.amazon.awssdk.auth.credentials.internal.CredentialsInvalidationUtils; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.protocols.jsoncore.JsonNode; @@ -170,16 +171,8 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { - if (identity instanceof AwsCredentialsIdentity) { - String rejectedAccessKeyId = identity.accessKeyId(); - processCredentialCache.invalidate(cachedCreds -> { - if (cachedCreds instanceof AwsCredentialsIdentity) { - return rejectedAccessKeyId.equals(((AwsCredentialsIdentity) cachedCreds).accessKeyId()); - } - return false; - }); - } - return CompletableFuture.completedFuture(null); + return CredentialsInvalidationUtils.invalidateCredentialsCache( + identity, processCredentialCache, cachedCreds -> (AwsCredentialsIdentity) cachedCreds); } private RefreshResult refreshCredentials() { diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/CredentialsInvalidationUtils.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/CredentialsInvalidationUtils.java new file mode 100644 index 000000000000..0293d83e1b77 --- /dev/null +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/CredentialsInvalidationUtils.java @@ -0,0 +1,75 @@ +/* + * 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.auth.credentials.internal; + +import java.util.concurrent.CompletableFuture; +import java.util.function.Function; +import software.amazon.awssdk.annotations.SdkProtectedApi; +import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; +import software.amazon.awssdk.utils.CompletableFutureUtils; +import software.amazon.awssdk.utils.cache.CachedSupplier; + +/** + * Utility methods for credential provider invalidation logic. + * + *

This class provides shared implementations for the common pattern of comparing a rejected + * identity's access key ID against a cached credential and conditionally invalidating the cache.

+ */ +@SdkProtectedApi +public final class CredentialsInvalidationUtils { + + private CredentialsInvalidationUtils() { + } + + /** + * Invalidates the given cache if the rejected identity's access key ID matches the cached credential's access key ID. + * + *

This method encapsulates the common pattern used by caching credential providers: + *

    + *
  1. Extract the rejected access key ID from the identity
  2. + *
  3. Compare it against the currently cached credentials
  4. + *
  5. If they match, mark the cache for mandatory refresh
  6. + *
+ * + * @param identity The identity that was rejected by the service. + * @param cache The cached supplier to invalidate. + * @param credentialsExtractor A function that extracts the {@link AwsCredentialsIdentity} from the cached value type. + * For providers that cache {@code AwsCredentials} directly, use {@code Function.identity()}. + * For providers that cache a holder object, provide the appropriate extraction function. + * @param The type of value stored in the cache. + * @return A {@link CompletableFuture} that completes when the invalidation check is done, or completes exceptionally + * if an error occurs during the invalidation. + */ + public static CompletableFuture invalidateCredentialsCache( + AwsCredentialsIdentity identity, + CachedSupplier cache, + Function credentialsExtractor) { + + try { + String rejectedAccessKeyId = identity.accessKeyId(); + cache.invalidate(cachedValue -> { + AwsCredentialsIdentity cachedIdentity = credentialsExtractor.apply(cachedValue); + if (cachedIdentity == null) { + return false; + } + return rejectedAccessKeyId.equals(cachedIdentity.accessKeyId()); + }); + return CompletableFuture.completedFuture(null); + } catch (Exception e) { + return CompletableFutureUtils.failedFuture(e); + } + } +} 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 ea876e1bec09..7a7ddcda6f09 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 @@ -28,6 +28,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.auth.credentials.internal.CredentialsInvalidationUtils; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.sso.SsoClient; @@ -172,14 +173,8 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { - if (identity instanceof AwsCredentialsIdentity) { - String rejectedAccessKeyId = identity.accessKeyId(); - credentialCache.invalidate(holder -> { - AwsCredentialsIdentity cachedCreds = holder.sessionCredentials(); - return rejectedAccessKeyId.equals(cachedCreds.accessKeyId()); - }); - } - return CompletableFuture.completedFuture(null); + return CredentialsInvalidationUtils.invalidateCredentialsCache( + identity, credentialCache, holder -> holder.sessionCredentials()); } @Override 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 9a48bd740bc5..2bccf484a768 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 @@ -26,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.auth.credentials.internal.CredentialsInvalidationUtils; import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.sts.StsClient; import software.amazon.awssdk.utils.Logger; @@ -127,16 +128,8 @@ public void close() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { - if (identity instanceof AwsCredentialsIdentity) { - String rejectedAccessKeyId = identity.accessKeyId(); - sessionCache.invalidate(cachedCreds -> { - if (cachedCreds instanceof AwsCredentialsIdentity) { - return rejectedAccessKeyId.equals(((AwsCredentialsIdentity) cachedCreds).accessKeyId()); - } - return false; - }); - } - return CompletableFuture.completedFuture(null); + return CredentialsInvalidationUtils.invalidateCredentialsCache( + identity, sessionCache, cachedCreds -> (AwsCredentialsIdentity) cachedCreds); } /** From bc4ac1ea0be0a0bcf4cc51cd8324e94d7ae9d8fa Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Tue, 7 Jul 2026 14:34:58 -0700 Subject: [PATCH 3/9] More cleanups --- .../AwsCredentialsProviderChain.java | 31 ++++++------- .../ContainerCredentialsProvider.java | 2 +- .../InstanceProfileCredentialsProvider.java | 2 +- .../ProcessCredentialsProvider.java | 2 +- .../ProfileCredentialsProvider.java | 1 + ...bIdentityTokenFileCredentialsProvider.java | 8 +--- .../CredentialsInvalidationUtils.java | 22 ++++------ .../IdentityProviderInvalidateTest.java | 5 +-- .../exception/AwsServiceException.java | 13 ++++++ .../awssdk/awscore/internal/AwsErrorCode.java | 11 +++++ .../awssdk/identity/spi/IdentityProvider.java | 3 +- .../core/exception/SdkServiceException.java | 14 ++++++ .../utils/AuthErrorInvalidationHelper.java | 43 ++----------------- .../AuthErrorInvalidationHelperTest.java | 40 +++++++---------- .../sso/auth/SsoCredentialsProvider.java | 2 +- .../sts/auth/StsCredentialsProvider.java | 2 +- 16 files changed, 93 insertions(+), 108 deletions(-) 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 149db4cad444..468fdbbfb3f4 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 @@ -132,23 +132,24 @@ public AwsCredentials resolveCredentials() { } @Override + @SuppressWarnings("unchecked") public CompletableFuture invalidate(AwsCredentialsIdentity identity) { - List> futures = new ArrayList<>(); - for (IdentityProvider provider : credentialsProviders) { - try { - @SuppressWarnings("unchecked") - IdentityProvider typedProvider = - (IdentityProvider) provider; - CompletableFuture future = typedProvider.invalidate(identity); - futures.add(future.exceptionally(e -> { + CompletableFuture[] futures = credentialsProviders.stream() + .map(provider -> { + try { + return ((IdentityProvider) provider) + .invalidate(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 null; - })); - } catch (Exception e) { - log.debug(() -> "Failed to invalidate provider " + provider + ": " + e.getMessage(), e); - } - } - return CompletableFuture.allOf(futures.toArray(new CompletableFuture[0])); + return CompletableFuture.completedFuture(null); + } + }) + .toArray(CompletableFuture[]::new); + return CompletableFuture.allOf(futures); } @Override 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 7f8b6df99533..c69193f41d38 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 @@ -192,7 +192,7 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { return CredentialsInvalidationUtils.invalidateCredentialsCache( - identity, credentialsCache, cachedCreds -> (AwsCredentialsIdentity) cachedCreds); + identity, credentialsCache, AwsCredentialsIdentity::accessKeyId); } @Override 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 8a8ad80ed20c..977660058622 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 @@ -173,7 +173,7 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { return CredentialsInvalidationUtils.invalidateCredentialsCache( - identity, credentialsCache, cachedCreds -> (AwsCredentialsIdentity) cachedCreds); + identity, credentialsCache, AwsCredentialsIdentity::accessKeyId); } private RefreshResult refreshCredentials() { 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 69947f123df1..de1a4350e771 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 @@ -172,7 +172,7 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { return CredentialsInvalidationUtils.invalidateCredentialsCache( - identity, processCredentialCache, cachedCreds -> (AwsCredentialsIdentity) cachedCreds); + identity, processCredentialCache, AwsCredentialsIdentity::accessKeyId); } private RefreshResult refreshCredentials() { 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 b8063f0ca735..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 @@ -162,6 +162,7 @@ public void close() { @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); 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 385420294cc8..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 @@ -27,7 +27,6 @@ import software.amazon.awssdk.core.SdkSystemSetting; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; -import software.amazon.awssdk.identity.spi.IdentityProvider; import software.amazon.awssdk.utils.IoUtils; import software.amazon.awssdk.utils.SdkAutoCloseable; import software.amazon.awssdk.utils.ToString; @@ -168,11 +167,8 @@ public void close() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { - if (credentialsProvider instanceof IdentityProvider) { - @SuppressWarnings("unchecked") - IdentityProvider provider = - (IdentityProvider) credentialsProvider; - return provider.invalidate(identity); + if (credentialsProvider != null) { + return credentialsProvider.invalidate(identity); } return CompletableFuture.completedFuture(null); } diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/CredentialsInvalidationUtils.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/CredentialsInvalidationUtils.java index 0293d83e1b77..d76b8f45e459 100644 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/CredentialsInvalidationUtils.java +++ b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/CredentialsInvalidationUtils.java @@ -35,20 +35,22 @@ private CredentialsInvalidationUtils() { } /** - * Invalidates the given cache if the rejected identity's access key ID matches the cached credential's access key ID. + * Invalidates the given cache if the rejected identity's access key ID matches the access key ID of the cached value. * *

This method encapsulates the common pattern used by caching credential providers: *

    *
  1. Extract the rejected access key ID from the identity
  2. - *
  3. Compare it against the currently cached credentials
  4. + *
  5. Use the {@code accessKeyIdExtractor} to get the access key ID from the currently cached value
  6. *
  7. If they match, mark the cache for mandatory refresh
  8. *
* + *

For providers whose cache directly stores {@link AwsCredentialsIdentity} (or a subtype), pass + * {@code AwsCredentialsIdentity::accessKeyId} as the extractor. For providers that cache a wrapper type, + * provide an appropriate extraction function (e.g., {@code holder -> holder.sessionCredentials().accessKeyId()}). + * * @param identity The identity that was rejected by the service. * @param cache The cached supplier to invalidate. - * @param credentialsExtractor A function that extracts the {@link AwsCredentialsIdentity} from the cached value type. - * For providers that cache {@code AwsCredentials} directly, use {@code Function.identity()}. - * For providers that cache a holder object, provide the appropriate extraction function. + * @param accessKeyIdExtractor A function that extracts the access key ID from the cached value. * @param The type of value stored in the cache. * @return A {@link CompletableFuture} that completes when the invalidation check is done, or completes exceptionally * if an error occurs during the invalidation. @@ -56,17 +58,11 @@ private CredentialsInvalidationUtils() { public static CompletableFuture invalidateCredentialsCache( AwsCredentialsIdentity identity, CachedSupplier cache, - Function credentialsExtractor) { + Function accessKeyIdExtractor) { try { String rejectedAccessKeyId = identity.accessKeyId(); - cache.invalidate(cachedValue -> { - AwsCredentialsIdentity cachedIdentity = credentialsExtractor.apply(cachedValue); - if (cachedIdentity == null) { - return false; - } - return rejectedAccessKeyId.equals(cachedIdentity.accessKeyId()); - }); + cache.invalidate(cachedValue -> rejectedAccessKeyId.equals(accessKeyIdExtractor.apply(cachedValue))); return CompletableFuture.completedFuture(null); } catch (Exception e) { return CompletableFutureUtils.failedFuture(e); diff --git a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/IdentityProviderInvalidateTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/IdentityProviderInvalidateTest.java index c2927d50d600..7b9ee9c63d1e 100644 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/IdentityProviderInvalidateTest.java +++ b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/IdentityProviderInvalidateTest.java @@ -66,13 +66,12 @@ class IdentityProviderInvalidateTest { @BeforeEach void setup() { environmentVariableHelper.reset(); - System.setProperty(SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT.property(), - "http://localhost:" + wireMockServer.getPort()); + environmentVariableHelper.set(SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT, + "http://localhost:" + wireMockServer.getPort()); } @AfterAll static void teardown() { - System.clearProperty(SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT.property()); environmentVariableHelper.reset(); } 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..4a31dbd985d3 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,12 @@ public final class AwsErrorCode { retryableErrorCodes.add("RequestTimeoutException"); retryableErrorCodes.add("InternalError"); RETRYABLE_ERROR_CODES = unmodifiableSet(retryableErrorCodes); + + Set authErrorCodes = new HashSet<>(3); + authErrorCodes.add("ExpiredToken"); + authErrorCodes.add("InvalidToken"); + authErrorCodes.add("AuthFailure"); + AUTH_ERROR_CODES = unmodifiableSet(authErrorCodes); } private AwsErrorCode() { @@ -86,4 +93,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 94582659213f..dcdc14fadc7c 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 @@ -166,8 +166,7 @@ default CompletableFuture resolveIdentity() { * 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). Implementations MUST NOT discard - * cached credentials entirely, and MUST NOT clear or bypass any refresh backoff.

+ * the rejected identity (e.g., same access key ID).

* *

The default implementation is a no-op, suitable for providers that do not cache.

* 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..2f5cc422a346 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 or authorization error where the credentials + * used for the request were rejected by the service. Subclasses can override this to check service-specific + * error codes (e.g., {@code ExpiredToken}, {@code InvalidToken}, {@code AuthFailure}). + * + *

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/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 index 143d882cd8b8..2894323dcf92 100644 --- 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 @@ -15,9 +15,6 @@ package software.amazon.awssdk.core.internal.http.pipeline.stages.utils; -import java.util.Arrays; -import java.util.HashSet; -import java.util.Set; import software.amazon.awssdk.annotations.SdkInternalApi; import software.amazon.awssdk.core.SelectedAuthScheme; import software.amazon.awssdk.core.exception.SdkServiceException; @@ -32,8 +29,9 @@ * Utility that detects authentication error responses and triggers credential invalidation * on the identity provider that produced the rejected credentials. * - *

When a service returns {@code ExpiredToken}, {@code InvalidToken}, or {@code AuthFailure}, - * this helper retrieves the {@link SelectedAuthScheme} from the execution context and calls + *

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. @@ -44,12 +42,6 @@ public final class AuthErrorInvalidationHelper { private static final Logger LOG = Logger.loggerFor(AuthErrorInvalidationHelper.class); - private static final Set INVALIDATION_ERROR_CODES = new HashSet<>(Arrays.asList( - "ExpiredToken", - "InvalidToken", - "AuthFailure" - )); - private AuthErrorInvalidationHelper() { } @@ -67,9 +59,7 @@ public static void invalidateIfAuthError(Throwable exception, RequestExecutionCo } SdkServiceException serviceException = (SdkServiceException) exception; - String errorCode = extractErrorCode(serviceException); - - if (errorCode == null || !INVALIDATION_ERROR_CODES.contains(errorCode)) { + if (!serviceException.isAuthenticationError()) { return; } @@ -87,31 +77,6 @@ public static void invalidateIfAuthError(Throwable exception, RequestExecutionCo } } - /** - * Extracts the error code from an SdkServiceException. Since sdk-core does not depend on - * aws-core, this uses reflection to access awsErrorDetails().errorCode() which is defined - * on AwsServiceException. - */ - private static String extractErrorCode(SdkServiceException serviceException) { - try { - java.lang.reflect.Method awsErrorDetailsMethod = - serviceException.getClass().getMethod("awsErrorDetails"); - Object errorDetails = awsErrorDetailsMethod.invoke(serviceException); - if (errorDetails == null) { - return null; - } - java.lang.reflect.Method errorCodeMethod = errorDetails.getClass().getMethod("errorCode"); - Object errorCode = errorCodeMethod.invoke(errorDetails); - return errorCode instanceof String ? (String) errorCode : null; - } catch (NoSuchMethodException e) { - // Exception type does not have awsErrorDetails() — not an AWS service exception - return null; - } catch (Exception e) { - LOG.debug(() -> "Failed to extract error code from exception: " + e.getMessage(), e); - return null; - } - } - @SuppressWarnings("unchecked") private static void doInvalidate(SelectedAuthScheme selectedAuthScheme) { T resolvedIdentity = CompletableFutureUtils.joinLikeSync(selectedAuthScheme.identity()); 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 index 058cc6d9c887..8c913effbf2b 100644 --- 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 @@ -20,6 +20,9 @@ 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; @@ -211,9 +214,8 @@ private RequestExecutionContext contextWithNoAuthScheme() { } /** - * Creates an SdkServiceException subclass with an {@code awsErrorDetails()} method that returns - * an object with an {@code errorCode()} method. This works with the reflection-based approach - * in AuthErrorInvalidationHelper. + * 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); @@ -286,36 +288,24 @@ public CompletableFuture invalidate(TestIdentity identity) { } /** - * Simulates an AwsServiceException accessible via reflection. The AuthErrorInvalidationHelper - * uses reflection to call awsErrorDetails().errorCode() since sdk-core doesn't depend on aws-core. - * Any class with these methods will work via reflection. + * Simulates an AwsServiceException that reports authentication errors via the + * {@link SdkServiceException#isAuthenticationError()} virtual method. */ private static class TestAwsServiceException extends SdkServiceException { - private final TestAwsErrorDetails awsErrorDetails; + private static final Set AUTH_ERROR_CODES = new HashSet<>(Arrays.asList( + "ExpiredToken", "InvalidToken", "AuthFailure" + )); - TestAwsServiceException(String errorCode) { - super(SdkServiceException.builder().message("test exception").statusCode(401)); - this.awsErrorDetails = new TestAwsErrorDetails(errorCode); - } - - public TestAwsErrorDetails awsErrorDetails() { - return awsErrorDetails; - } - } - - /** - * Simulates AwsErrorDetails. The reflection in AuthErrorInvalidationHelper calls - * awsErrorDetails().errorCode(), so this class just needs an errorCode() method. - */ - private static class TestAwsErrorDetails { private final String errorCode; - TestAwsErrorDetails(String errorCode) { + TestAwsServiceException(String errorCode) { + super(SdkServiceException.builder().message("test exception").statusCode(401)); this.errorCode = errorCode; } - public String errorCode() { - return errorCode; + @Override + public boolean isAuthenticationError() { + return AUTH_ERROR_CODES.contains(errorCode); } } } 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 7a7ddcda6f09..f133a0531652 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 @@ -174,7 +174,7 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { return CredentialsInvalidationUtils.invalidateCredentialsCache( - identity, credentialCache, holder -> holder.sessionCredentials()); + identity, credentialCache, holder -> holder.sessionCredentials().accessKeyId()); } @Override 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 2bccf484a768..8fd1acda683d 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 @@ -129,7 +129,7 @@ public void close() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { return CredentialsInvalidationUtils.invalidateCredentialsCache( - identity, sessionCache, cachedCreds -> (AwsCredentialsIdentity) cachedCreds); + identity, sessionCache, AwsCredentialsIdentity::accessKeyId); } /** From 192c33f27db1cbbe47fc5e90393bbb879b29dc12 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Thu, 9 Jul 2026 09:27:58 -0700 Subject: [PATCH 4/9] Cleanups and fixes --- .../AwsCredentialsProviderChain.java | 15 +- .../ContainerCredentialsProvider.java | 7 +- .../InstanceProfileCredentialsProvider.java | 7 +- .../ProcessCredentialsProvider.java | 7 +- .../CredentialsInvalidationUtils.java | 71 --- .../AwsCredentialsProviderChainTest.java | 65 ++ .../IdentityProviderInvalidateTest.java | 300 ---------- ...nstanceProfileCredentialsProviderTest.java | 55 ++ .../LazyAwsCredentialsProviderTest.java | 25 + .../awssdk/awscore/internal/AwsErrorCode.java | 1 - .../core/exception/SdkServiceException.java | 6 +- .../utils/AuthErrorInvalidationHelper.java | 6 +- .../AuthErrorInvalidationHelperTest.java | 24 +- .../signin/auth/LoginCredentialsProvider.java | 14 +- .../sso/auth/SsoCredentialsProvider.java | 7 +- .../sso/auth/SsoCredentialsProviderTest.java | 69 +++ .../sts/auth/StsCredentialsProvider.java | 7 +- .../auth/StsCredentialsProviderTestBase.java | 51 ++ .../AuthErrorInvalidationIntegrationTest.java | 131 +--- .../awssdk/utils/cache/CachedSupplier.java | 15 +- .../cache/CachedSupplierInvalidateTest.java | 559 ------------------ .../utils/cache/CachedSupplierTest.java | 145 +++++ 22 files changed, 492 insertions(+), 1095 deletions(-) delete mode 100644 core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/CredentialsInvalidationUtils.java delete mode 100644 core/auth/src/test/java/software/amazon/awssdk/auth/credentials/IdentityProviderInvalidateTest.java delete mode 100644 utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierInvalidateTest.java 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 468fdbbfb3f4..5d4e46bd2395 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 @@ -132,13 +132,11 @@ public AwsCredentials resolveCredentials() { } @Override - @SuppressWarnings("unchecked") public CompletableFuture invalidate(AwsCredentialsIdentity identity) { CompletableFuture[] futures = credentialsProviders.stream() .map(provider -> { try { - return ((IdentityProvider) provider) - .invalidate(identity) + return invalidateProvider(provider, identity) .exceptionally(e -> { log.debug(() -> "Failed to invalidate provider " + provider + ": " + e.getMessage(), e); return null; @@ -152,6 +150,17 @@ public CompletableFuture invalidate(AwsCredentialsIdentity identity) { 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 c69193f41d38..9a95620ecb60 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 @@ -38,7 +38,6 @@ import java.util.function.Predicate; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.auth.credentials.internal.ContainerCredentialsRetryPolicy; -import software.amazon.awssdk.auth.credentials.internal.CredentialsInvalidationUtils; import software.amazon.awssdk.auth.credentials.internal.HttpCredentialsLoader; import software.amazon.awssdk.auth.credentials.internal.HttpCredentialsLoader.LoadedCredentials; import software.amazon.awssdk.core.SdkSystemSetting; @@ -191,8 +190,10 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { - return CredentialsInvalidationUtils.invalidateCredentialsCache( - identity, credentialsCache, AwsCredentialsIdentity::accessKeyId); + String rejectedAccessKeyId = identity.accessKeyId(); + return CompletableFuture.runAsync(() -> + credentialsCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())) + ); } @Override 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 977660058622..19baaff757fe 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 @@ -30,7 +30,6 @@ import java.util.function.Supplier; import software.amazon.awssdk.annotations.SdkPublicApi; import software.amazon.awssdk.annotations.SdkTestInternalApi; -import software.amazon.awssdk.auth.credentials.internal.CredentialsInvalidationUtils; import software.amazon.awssdk.auth.credentials.internal.Ec2MetadataConfigProvider; import software.amazon.awssdk.auth.credentials.internal.HttpCredentialsLoader; import software.amazon.awssdk.auth.credentials.internal.HttpCredentialsLoader.LoadedCredentials; @@ -172,8 +171,10 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { - return CredentialsInvalidationUtils.invalidateCredentialsCache( - identity, credentialsCache, AwsCredentialsIdentity::accessKeyId); + String rejectedAccessKeyId = identity.accessKeyId(); + return CompletableFuture.runAsync(() -> + credentialsCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())) + ); } private RefreshResult refreshCredentials() { 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 de1a4350e771..e32a319a7e4c 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 @@ -27,7 +27,6 @@ import java.util.Optional; import java.util.concurrent.CompletableFuture; import software.amazon.awssdk.annotations.SdkPublicApi; -import software.amazon.awssdk.auth.credentials.internal.CredentialsInvalidationUtils; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.protocols.jsoncore.JsonNode; @@ -171,8 +170,10 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { - return CredentialsInvalidationUtils.invalidateCredentialsCache( - identity, processCredentialCache, AwsCredentialsIdentity::accessKeyId); + String rejectedAccessKeyId = identity.accessKeyId(); + return CompletableFuture.runAsync(() -> + processCredentialCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())) + ); } private RefreshResult refreshCredentials() { diff --git a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/CredentialsInvalidationUtils.java b/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/CredentialsInvalidationUtils.java deleted file mode 100644 index d76b8f45e459..000000000000 --- a/core/auth/src/main/java/software/amazon/awssdk/auth/credentials/internal/CredentialsInvalidationUtils.java +++ /dev/null @@ -1,71 +0,0 @@ -/* - * 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.auth.credentials.internal; - -import java.util.concurrent.CompletableFuture; -import java.util.function.Function; -import software.amazon.awssdk.annotations.SdkProtectedApi; -import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; -import software.amazon.awssdk.utils.CompletableFutureUtils; -import software.amazon.awssdk.utils.cache.CachedSupplier; - -/** - * Utility methods for credential provider invalidation logic. - * - *

This class provides shared implementations for the common pattern of comparing a rejected - * identity's access key ID against a cached credential and conditionally invalidating the cache.

- */ -@SdkProtectedApi -public final class CredentialsInvalidationUtils { - - private CredentialsInvalidationUtils() { - } - - /** - * Invalidates the given cache if the rejected identity's access key ID matches the access key ID of the cached value. - * - *

This method encapsulates the common pattern used by caching credential providers: - *

    - *
  1. Extract the rejected access key ID from the identity
  2. - *
  3. Use the {@code accessKeyIdExtractor} to get the access key ID from the currently cached value
  4. - *
  5. If they match, mark the cache for mandatory refresh
  6. - *
- * - *

For providers whose cache directly stores {@link AwsCredentialsIdentity} (or a subtype), pass - * {@code AwsCredentialsIdentity::accessKeyId} as the extractor. For providers that cache a wrapper type, - * provide an appropriate extraction function (e.g., {@code holder -> holder.sessionCredentials().accessKeyId()}). - * - * @param identity The identity that was rejected by the service. - * @param cache The cached supplier to invalidate. - * @param accessKeyIdExtractor A function that extracts the access key ID from the cached value. - * @param The type of value stored in the cache. - * @return A {@link CompletableFuture} that completes when the invalidation check is done, or completes exceptionally - * if an error occurs during the invalidation. - */ - public static CompletableFuture invalidateCredentialsCache( - AwsCredentialsIdentity identity, - CachedSupplier cache, - Function accessKeyIdExtractor) { - - try { - String rejectedAccessKeyId = identity.accessKeyId(); - cache.invalidate(cachedValue -> rejectedAccessKeyId.equals(accessKeyIdExtractor.apply(cachedValue))); - return CompletableFuture.completedFuture(null); - } catch (Exception e) { - return CompletableFutureUtils.failedFuture(e); - } - } -} 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..a8e19f5ddb87 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,65 @@ private static void assertChainResolvesCorrectly(AwsCredentialsProviderChain cha assertThat(credentials.secretAccessKey()).isEqualTo("secretKey"); } + @Test + public void invalidate_propagatesToAllChildren() { + TrackingCredentialsProvider provider1 = new TrackingCredentialsProvider("key1", "secret1"); + TrackingCredentialsProvider provider2 = new TrackingCredentialsProvider("key2", "secret2"); + TrackingCredentialsProvider provider3 = new TrackingCredentialsProvider("key3", "secret3"); + + AwsCredentialsProviderChain chain = AwsCredentialsProviderChain.builder() + .credentialsProviders(provider1, provider2, provider3) + .build(); + + AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); + chain.invalidate(identity).join(); + + assertThat(provider1.invalidateCallCount).isEqualTo(1); + assertThat(provider2.invalidateCallCount).isEqualTo(1); + assertThat(provider3.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) + .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/IdentityProviderInvalidateTest.java b/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/IdentityProviderInvalidateTest.java deleted file mode 100644 index 7b9ee9c63d1e..000000000000 --- a/core/auth/src/test/java/software/amazon/awssdk/auth/credentials/IdentityProviderInvalidateTest.java +++ /dev/null @@ -1,300 +0,0 @@ -/* - * 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.auth.credentials; - -import static com.github.tomakehurst.wiremock.client.WireMock.aResponse; -import static com.github.tomakehurst.wiremock.client.WireMock.get; -import static com.github.tomakehurst.wiremock.client.WireMock.put; -import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo; -import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig; -import static org.assertj.core.api.Assertions.assertThat; -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 com.github.tomakehurst.wiremock.junit5.WireMockExtension; -import com.github.tomakehurst.wiremock.junit5.WireMockTest; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import org.junit.jupiter.api.AfterAll; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.RegisterExtension; -import software.amazon.awssdk.auth.credentials.internal.LazyAwsCredentialsProvider; -import software.amazon.awssdk.core.SdkSystemSetting; -import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; -import software.amazon.awssdk.utils.DateUtils; -import software.amazon.awssdk.testutils.EnvironmentVariableHelper; - -/** - * Unit tests for identity provider invalidation: chain propagation, LazyAwsCredentialsProvider delegation, - * and individual provider accessKeyId matching. - */ -@WireMockTest -class IdentityProviderInvalidateTest { - - private static final String TOKEN_RESOURCE_PATH = "/latest/api/token"; - private static final String CREDENTIALS_RESOURCE_PATH = "/latest/meta-data/iam/security-credentials/"; - private static final String PROFILE_NAME = "some-profile"; - private static final String TOKEN_STUB = "some-token"; - private static final EnvironmentVariableHelper environmentVariableHelper = new EnvironmentVariableHelper(); - - @RegisterExtension - static WireMockExtension wireMockServer = WireMockExtension.newInstance() - .options(wireMockConfig().dynamicPort().dynamicPort()) - .configureStaticDsl(true) - .build(); - - @BeforeEach - void setup() { - environmentVariableHelper.reset(); - environmentVariableHelper.set(SdkSystemSetting.AWS_EC2_METADATA_SERVICE_ENDPOINT, - "http://localhost:" + wireMockServer.getPort()); - } - - @AfterAll - static void teardown() { - environmentVariableHelper.reset(); - } - - // ==================== Chain Propagation Tests ==================== - - @Test - void invalidate_chainPropagatesTo_allChildren() { - TrackingCredentialsProvider provider1 = new TrackingCredentialsProvider("key1", "secret1"); - TrackingCredentialsProvider provider2 = new TrackingCredentialsProvider("key2", "secret2"); - TrackingCredentialsProvider provider3 = new TrackingCredentialsProvider("key3", "secret3"); - - AwsCredentialsProviderChain chain = AwsCredentialsProviderChain.builder() - .credentialsProviders(provider1, provider2, provider3) - .build(); - - AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); - chain.invalidate(identity); - - assertThat(provider1.invalidateCallCount).isEqualTo(1); - assertThat(provider2.invalidateCallCount).isEqualTo(1); - assertThat(provider3.invalidateCallCount).isEqualTo(1); - } - - @SuppressWarnings("unchecked") - @Test - void invalidate_chainDoesNotShortCircuit_whenChildThrows() { - AwsCredentialsProvider mockProvider1 = mock(AwsCredentialsProvider.class); - AwsCredentialsProvider mockProvider2 = mock(AwsCredentialsProvider.class); - AwsCredentialsProvider mockProvider3 = mock(AwsCredentialsProvider.class); - - // First provider throws on invalidate - doThrow(new RuntimeException("Provider 1 failed")).when(mockProvider1).invalidate(any()); - - AwsCredentialsProviderChain chain = AwsCredentialsProviderChain.builder() - .credentialsProviders(mockProvider1, mockProvider2, mockProvider3) - .build(); - - AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); - chain.invalidate(identity); - - // All providers should still be called despite the first one throwing - verify(mockProvider1, times(1)).invalidate(identity); - verify(mockProvider2, times(1)).invalidate(identity); - verify(mockProvider3, times(1)).invalidate(identity); - } - - @Test - void invalidate_chainWithStaticProvider_isNoOpWithoutError() { - StaticCredentialsProvider staticProvider = StaticCredentialsProvider.create( - AwsBasicCredentials.create("staticKey", "staticSecret")); - TrackingCredentialsProvider trackingProvider = new TrackingCredentialsProvider("key1", "secret1"); - - AwsCredentialsProviderChain chain = AwsCredentialsProviderChain.builder() - .credentialsProviders(staticProvider, trackingProvider) - .build(); - - AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); - - // Should not throw - StaticCredentialsProvider uses default no-op - chain.invalidate(identity); - - // The tracking provider should still receive the call - assertThat(trackingProvider.invalidateCallCount).isEqualTo(1); - } - - // ==================== LazyAwsCredentialsProvider Delegation Tests ==================== - - @Test - void invalidate_lazyProvider_initialized_delegatesToInner() { - TrackingCredentialsProvider innerProvider = new TrackingCredentialsProvider("key1", "secret1"); - LazyAwsCredentialsProvider lazyProvider = LazyAwsCredentialsProvider.create(() -> innerProvider); - - // Force initialization by calling resolveCredentials - lazyProvider.resolveCredentials(); - - AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); - lazyProvider.invalidate(identity); - - assertThat(innerProvider.invalidateCallCount).isEqualTo(1); - } - - @Test - void invalidate_lazyProvider_notInitialized_isNoOp() { - List constructorCalled = new ArrayList<>(); - LazyAwsCredentialsProvider lazyProvider = LazyAwsCredentialsProvider.create(() -> { - constructorCalled.add(true); - return new TrackingCredentialsProvider("key1", "secret1"); - }); - - // Do NOT call resolveCredentials — provider should not be initialized - AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1"); - lazyProvider.invalidate(identity); - - // The supplier should never have been called - lazy not initialized - assertThat(constructorCalled).isEmpty(); - } - - // ==================== DefaultCredentialsProvider Delegation Test ==================== - - @Test - void invalidate_defaultCredentialsProvider_delegatesToLazyChain() { - // DefaultCredentialsProvider delegates to LazyAwsCredentialsProvider which delegates to chain. - // We verify end-to-end by using profile credentials and checking delegation completes without error. - DefaultCredentialsProvider provider = DefaultCredentialsProvider.builder() - .profileFile(profileWithCredentials("testKey", "testSecret")) - .profileName("test") - .build(); - - // Trigger initialization - AwsCredentials credentials = provider.resolveCredentials(); - assertThat(credentials.accessKeyId()).isEqualTo("testKey"); - - // Calling invalidate should not throw — it propagates through lazy -> chain -> children - AwsCredentialsIdentity identity = AwsBasicCredentials.create("testKey", "testSecret"); - provider.invalidate(identity); - } - - // ==================== Individual Provider AccessKeyId Matching Tests ==================== - - @Test - void invalidate_instanceProfileProvider_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 + "\"}"; - - stubImdsCredentials(credentialsJson); - - InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder().build(); - - // First call: fetches and caches credentials - AwsCredentials firstCredentials = provider.resolveCredentials(); - assertThat(firstCredentials.accessKeyId()).isEqualTo(accessKeyId); - - // Set up a different credential response for second fetch - String newAccessKeyId = "NEW_ACCESS_KEY_ID"; - String newCredentialsJson = "{\"AccessKeyId\":\"" + newAccessKeyId + "\"," - + "\"SecretAccessKey\":\"NEW_SECRET_ACCESS_KEY\"," - + "\"Expiration\":\"" + expirationFarFuture + "\"}"; - stubImdsCredentials(newCredentialsJson); - - // Invalidate with matching accessKeyId — should mark cache as stale - AwsCredentialsIdentity identity = AwsBasicCredentials.create(accessKeyId, "SECRET_ACCESS_KEY"); - provider.invalidate(identity); - - // Next resolveCredentials() should attempt a fresh fetch - AwsCredentials refreshedCredentials = provider.resolveCredentials(); - assertThat(refreshedCredentials.accessKeyId()).isEqualTo(newAccessKeyId); - } - - @Test - void invalidate_instanceProfileProvider_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 + "\"}"; - - stubImdsCredentials(credentialsJson); - - InstanceProfileCredentialsProvider provider = InstanceProfileCredentialsProvider.builder().build(); - - // First call: fetches and caches credentials - AwsCredentials firstCredentials = provider.resolveCredentials(); - assertThat(firstCredentials.accessKeyId()).isEqualTo(accessKeyId); - - // Set up different credentials on the server (would be returned if cache is invalidated) - String newCredentialsJson = "{\"AccessKeyId\":\"NEW_ACCESS_KEY_ID\"," - + "\"SecretAccessKey\":\"NEW_SECRET_ACCESS_KEY\"," - + "\"Expiration\":\"" + expirationFarFuture + "\"}"; - stubImdsCredentials(newCredentialsJson); - - // Invalidate with a DIFFERENT accessKeyId — should NOT invalidate cache - AwsCredentialsIdentity differentIdentity = AwsBasicCredentials.create("DIFFERENT_KEY", "SECRET"); - provider.invalidate(differentIdentity); - - // Cache should still return the original cached credential - AwsCredentials secondCredentials = provider.resolveCredentials(); - assertThat(secondCredentials.accessKeyId()).isEqualTo(accessKeyId); - } - - // ==================== Helper Methods ==================== - - private void stubImdsCredentials(String credentialsJson) { - wireMockServer.stubFor(put(urlPathEqualTo(TOKEN_RESOURCE_PATH)) - .willReturn(aResponse().withBody(TOKEN_STUB))); - wireMockServer.stubFor(get(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH)) - .willReturn(aResponse().withBody(PROFILE_NAME))); - wireMockServer.stubFor(get(urlPathEqualTo(CREDENTIALS_RESOURCE_PATH + PROFILE_NAME)) - .willReturn(aResponse().withBody(credentialsJson))); - } - - private java.util.function.Supplier profileWithCredentials( - String accessKeyId, String secretAccessKey) { - String contents = String.format("[test]\naws_access_key_id = %s\naws_secret_access_key = %s\n", - accessKeyId, secretAccessKey); - return () -> software.amazon.awssdk.profiles.ProfileFile.builder() - .content(new software.amazon.awssdk.utils.StringInputStream(contents)) - .type(software.amazon.awssdk.profiles.ProfileFile.Type.CREDENTIALS) - .build(); - } - - /** - * A simple AwsCredentialsProvider that tracks invalidate() calls. - */ - 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); - } - } -} 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/internal/AwsErrorCode.java b/core/aws-core/src/main/java/software/amazon/awssdk/awscore/internal/AwsErrorCode.java index 4a31dbd985d3..6fd9267b8000 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 @@ -71,7 +71,6 @@ public final class AwsErrorCode { Set authErrorCodes = new HashSet<>(3); authErrorCodes.add("ExpiredToken"); authErrorCodes.add("InvalidToken"); - authErrorCodes.add("AuthFailure"); AUTH_ERROR_CODES = unmodifiableSet(authErrorCodes); } 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 2f5cc422a346..fc3a339cded6 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 @@ -103,9 +103,9 @@ public boolean isRetryableException() { } /** - * Specifies whether an exception indicates an authentication or authorization error where the credentials - * used for the request were rejected by the service. Subclasses can override this to check service-specific - * error codes (e.g., {@code ExpiredToken}, {@code InvalidToken}, {@code AuthFailure}). + * 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.

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 index 2894323dcf92..d5fe21a707b9 100644 --- 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 @@ -81,6 +81,10 @@ public static void invalidateIfAuthError(Throwable exception, RequestExecutionCo private static void doInvalidate(SelectedAuthScheme selectedAuthScheme) { T resolvedIdentity = CompletableFutureUtils.joinLikeSync(selectedAuthScheme.identity()); IdentityProvider provider = selectedAuthScheme.identityProvider(); - provider.invalidate(resolvedIdentity); + try { + CompletableFutureUtils.joinLikeSync(provider.invalidate(resolvedIdentity)); + } catch (Exception e) { + LOG.debug(() -> "Failed to invalidate identity provider: " + e.getMessage(), e); + } } } 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 index 8c913effbf2b..fa8a2bb1b0c5 100644 --- 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 @@ -51,65 +51,53 @@ public class AuthErrorInvalidationHelperTest { @ParameterizedTest - @ValueSource(strings = {"ExpiredToken", "InvalidToken", "AuthFailure"}) + @ValueSource(strings = {"ExpiredToken", "InvalidToken"}) void invalidateIfAuthError_whenAuthErrorCode_triggersInvalidation(String errorCode) { - // Arrange TrackingIdentityProvider provider = new TrackingIdentityProvider(); TestIdentity identity = new TestIdentity(); RequestExecutionContext context = contextWithProvider(provider, identity); Throwable exception = serviceExceptionWithErrorCode(errorCode); - // Act AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context); - // Assert assertThat(provider.invalidateCalled()).isTrue(); assertThat(provider.lastInvalidatedIdentity()).isSameAs(identity); } @Test void invalidateIfAuthError_whenAccessDenied_doesNotTriggerInvalidation() { - // Arrange TrackingIdentityProvider provider = new TrackingIdentityProvider(); TestIdentity identity = new TestIdentity(); RequestExecutionContext context = contextWithProvider(provider, identity); Throwable exception = serviceExceptionWithErrorCode("AccessDenied"); - // Act AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context); - // Assert assertThat(provider.invalidateCalled()).isFalse(); } @ParameterizedTest - @ValueSource(strings = {"ThrottlingException", "InternalServerError", "ValidationException", "ResourceNotFoundException"}) + @ValueSource(strings = {"ThrottlingException", "InternalServerError", "ValidationException", "ResourceNotFoundException", "AuthFailure"}) void invalidateIfAuthError_whenUnknownErrorCode_doesNotTriggerInvalidation(String errorCode) { - // Arrange TrackingIdentityProvider provider = new TrackingIdentityProvider(); TestIdentity identity = new TestIdentity(); RequestExecutionContext context = contextWithProvider(provider, identity); Throwable exception = serviceExceptionWithErrorCode(errorCode); - // Act AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context); - // Assert assertThat(provider.invalidateCalled()).isFalse(); } @ParameterizedTest @MethodSource("nonServiceExceptions") void invalidateIfAuthError_whenNonSdkServiceException_doesNotTriggerInvalidation(Throwable exception) { - // Arrange TrackingIdentityProvider provider = new TrackingIdentityProvider(); TestIdentity identity = new TestIdentity(); RequestExecutionContext context = contextWithProvider(provider, identity); - // Act AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context); - // Assert assertThat(provider.invalidateCalled()).isFalse(); } @@ -123,11 +111,9 @@ static Stream nonServiceExceptions() { @Test void invalidateIfAuthError_whenSelectedAuthSchemeIsNull_doesNotThrow() { - // Arrange — context with no SELECTED_AUTH_SCHEME attribute RequestExecutionContext context = contextWithNoAuthScheme(); Throwable exception = serviceExceptionWithErrorCode("ExpiredToken"); - // Act & Assert — no NPE, no invalidation assertThatNoException().isThrownBy(() -> AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context) ); @@ -135,7 +121,6 @@ void invalidateIfAuthError_whenSelectedAuthSchemeIsNull_doesNotThrow() { @Test void invalidateIfAuthError_whenIdentityProviderIsNull_doesNotThrow() { - // Arrange — SelectedAuthScheme created with 3-arg constructor (null identityProvider) TestIdentity identity = new TestIdentity(); SelectedAuthScheme selectedAuthScheme = new SelectedAuthScheme<>( CompletableFuture.completedFuture(identity), @@ -146,7 +131,6 @@ void invalidateIfAuthError_whenIdentityProviderIsNull_doesNotThrow() { RequestExecutionContext context = contextWithSelectedAuthScheme(selectedAuthScheme); Throwable exception = serviceExceptionWithErrorCode("ExpiredToken"); - // Act & Assert — no NPE, no invalidation assertThatNoException().isThrownBy(() -> AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context) ); @@ -154,7 +138,6 @@ void invalidateIfAuthError_whenIdentityProviderIsNull_doesNotThrow() { @Test void invalidateIfAuthError_whenInvalidateThrowsException_doesNotPropagate() { - // Arrange — provider that throws on invalidate() ThrowingIdentityProvider provider = new ThrowingIdentityProvider(); TestIdentity identity = new TestIdentity(); SelectedAuthScheme selectedAuthScheme = new SelectedAuthScheme<>( @@ -167,7 +150,6 @@ void invalidateIfAuthError_whenInvalidateThrowsException_doesNotPropagate() { RequestExecutionContext context = contextWithSelectedAuthScheme(selectedAuthScheme); Throwable exception = serviceExceptionWithErrorCode("ExpiredToken"); - // Act & Assert — exception is caught internally, method returns normally assertThatNoException().isThrownBy(() -> AuthErrorInvalidationHelper.invalidateIfAuthError(exception, context) ); @@ -293,7 +275,7 @@ public CompletableFuture invalidate(TestIdentity identity) { */ private static class TestAwsServiceException extends SdkServiceException { private static final Set AUTH_ERROR_CODES = new HashSet<>(Arrays.asList( - "ExpiredToken", "InvalidToken", "AuthFailure" + "ExpiredToken", "InvalidToken" )); private final String 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 9650a787303f..e90986badc5b 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 @@ -299,16 +299,10 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { - if (identity instanceof AwsCredentialsIdentity) { - String rejectedAccessKeyId = identity.accessKeyId(); - credentialCache.invalidate(cachedCreds -> { - if (cachedCreds instanceof AwsCredentialsIdentity) { - return rejectedAccessKeyId.equals(((AwsCredentialsIdentity) cachedCreds).accessKeyId()); - } - return false; - }); - } - return CompletableFuture.completedFuture(null); + String rejectedAccessKeyId = identity.accessKeyId(); + return CompletableFuture.runAsync(() -> + credentialCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())) + ); } @Override 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 f133a0531652..8e22e0853aa2 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 @@ -28,7 +28,6 @@ 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.auth.credentials.internal.CredentialsInvalidationUtils; import software.amazon.awssdk.core.useragent.BusinessMetricFeatureId; import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.sso.SsoClient; @@ -173,8 +172,10 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { - return CredentialsInvalidationUtils.invalidateCredentialsCache( - identity, credentialCache, holder -> holder.sessionCredentials().accessKeyId()); + String rejectedAccessKeyId = identity.accessKeyId(); + return CompletableFuture.runAsync(() -> + credentialCache.invalidate(holder -> rejectedAccessKeyId.equals(holder.sessionCredentials().accessKeyId())) + ); } @Override 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/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 8fd1acda683d..2782ec960986 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 @@ -26,7 +26,6 @@ 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.auth.credentials.internal.CredentialsInvalidationUtils; import software.amazon.awssdk.identity.spi.AwsCredentialsIdentity; import software.amazon.awssdk.services.sts.StsClient; import software.amazon.awssdk.utils.Logger; @@ -128,8 +127,10 @@ public void close() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { - return CredentialsInvalidationUtils.invalidateCredentialsCache( - identity, sessionCache, AwsCredentialsIdentity::accessKeyId); + String rejectedAccessKeyId = identity.accessKeyId(); + return CompletableFuture.runAsync(() -> + sessionCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())) + ); } /** 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/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationIntegrationTest.java b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationIntegrationTest.java index 57cae1dd3f4c..a555c5d4cda8 100644 --- a/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationIntegrationTest.java +++ b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationIntegrationTest.java @@ -16,6 +16,7 @@ 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.time.Clock; @@ -31,7 +32,7 @@ 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.core.retry.RetryMode; +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; @@ -47,29 +48,25 @@ /** * Integration test verifying the end-to-end flow: - * service returns ExpiredToken → credentials invalidated → retry uses fresh credentials. + * service returns ExpiredToken → credentials invalidated → next call uses fresh credentials. * * Also verifies backoff interaction: when refresh backoff is active, invalidate() does not * bypass it, and stale cached credentials are returned until the backoff elapses. - * - * Validates Requirements: 6, 8, 9, 10, 21 */ public class AuthErrorInvalidationIntegrationTest { /** * End-to-end: ExpiredToken → invalidation is triggered → next call uses fresh credentials. * - * The SDK resolves identity once per API call (in beforeExecution interceptor), so the retry - * within the same call reuses the same identity. However, invalidation marks the provider's - * cache as stale, ensuring the NEXT API call resolves 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 500 with ExpiredToken + * 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. SDK retries (same identity — "old-key" — because identity was already resolved) - * 4. Second retry succeeds with 200 - * 5. Second API call: resolves fresh identity "new-key" - * 6. Verify invalidate was called and second API call uses new credentials + * 3. First call fails with ExpiredToken exception + * 4. Second API call: resolves fresh identity "new-key", succeeds */ @Test public void expiredToken_invalidatesCredentials_nextCallUsesFreshCredentials() { @@ -82,24 +79,23 @@ public void expiredToken_invalidatesCredentials_nextCallUsesFreshCredentials() { .region(Region.US_EAST_1) .endpointOverride(URI.create("http://localhost")) .httpClient(mockHttpClient) - .overrideConfiguration(c -> c.retryStrategy(RetryMode.STANDARD)) .build()) { - // First API call: 500 ExpiredToken (retried), then 200 on retry - mockHttpClient.stubResponses(expiredTokenResponse(), successResponse()); + // First API call: ExpiredToken (not retryable — fails immediately) + mockHttpClient.stubResponses(expiredTokenResponse()); - // First call succeeds on retry (same credentials used for both attempts since - // identity is resolved once per API call) - client.allTypes(); + 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's requests both used "old-key" (same identity, same call) + // Verify first call used "old-key" List firstCallRequests = mockHttpClient.getRequests(); - assertThat(firstCallRequests).hasSize(2); + assertThat(firstCallRequests).hasSize(1); assertRequestUsedAccessKey(firstCallRequests.get(0), "old-key"); - assertRequestUsedAccessKey(firstCallRequests.get(1), "old-key"); // Now make a second API call — this should resolve fresh credentials mockHttpClient.reset(); @@ -115,13 +111,7 @@ public void expiredToken_invalidatesCredentials_nextCallUsesFreshCredentials() { } /** - * Verify that AccessDenied does NOT trigger invalidation. - * - * Scenario: - * 1. Client makes a request signed with "my-key" - * 2. Service returns 403 with error code "AccessDenied" - * 3. SDK does NOT invalidate credentials (AccessDenied is not an auth error for invalidation) - * 4. Request fails (403 is not retryable by default for standard retry) + * Verify that AccessDenied does NOT trigger invalidation and the exception propagates. */ @Test public void accessDenied_doesNotInvalidateCredentials() { @@ -134,18 +124,14 @@ public void accessDenied_doesNotInvalidateCredentials() { .region(Region.US_EAST_1) .endpointOverride(URI.create("http://localhost")) .httpClient(mockHttpClient) - .overrideConfiguration(c -> c.retryStrategy(RetryMode.STANDARD)) .build()) { - // Response: 403 with AccessDenied error code - HttpExecuteResponse accessDeniedResponse = accessDeniedResponse(); - mockHttpClient.stubResponses(accessDeniedResponse); + mockHttpClient.stubResponses(accessDeniedResponse()); - try { - client.allTypes(); - } catch (Exception e) { - // Expected — 403 is not retryable - } + 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); @@ -158,9 +144,7 @@ public void accessDenied_doesNotInvalidateCredentials() { * This test verifies that when CachedSupplier has an active refresh backoff * (nextAllowedRefreshTime in the future), calling invalidate() marks the cache stale * but does NOT bypass the backoff. The next get() still returns cached credentials - * until the backoff elapses. - * - * Validates Requirements: 9, 10 + * until the backoff elapses, at which point fresh credentials are obtained. */ @Test public void invalidation_duringActiveBackoff_returnsStaleCredentials() { @@ -171,7 +155,6 @@ public void invalidation_duringActiveBackoff_returnsStaleCredentials() { AtomicInteger fetchCount = new AtomicInteger(0); AtomicReference>> supplierRef = new AtomicReference<>(); - // Set up the initial supplier that returns credentials supplierRef.set(() -> RefreshResult.builder("access-key-1") .staleTime(now.plusSeconds(60)) .prefetchTime(now.plusSeconds(30)) @@ -208,7 +191,6 @@ public void invalidation_duringActiveBackoff_returnsStaleCredentials() { .build()); // get() should return STALE credentials because backoff is still active - // (nextAllowedRefreshTime = ~now+61 + [300,600]s, we're at now+62) assertThat(cache.get()).isEqualTo("access-key-1"); // Advance past maximum possible backoff (61 + 600 = 661 seconds from original now) @@ -219,65 +201,6 @@ public void invalidation_duringActiveBackoff_returnsStaleCredentials() { } } - /** - * Verify that after backoff elapses following invalidation, fresh credentials are obtained. - * - * This tests the complete lifecycle: - * 1. Cache has credentials - * 2. Refresh fails → backoff is set - * 3. invalidate() is called → staleTime set to now (but backoff stays) - * 4. During backoff → stale value returned - * 5. After backoff → fresh value obtained - * - * Validates Requirements: 8, 9, 10 - */ - @Test - public void afterBackoffElapses_invalidatedCache_obtainsFreshCredentials() { - AdjustableClock clock = new AdjustableClock(); - Instant now = Instant.parse("2024-01-01T00:00:00Z"); - clock.time = now; - - AtomicReference>> supplierRef = new AtomicReference<>(); - supplierRef.set(() -> RefreshResult.builder("cred-A") - .staleTime(now.plusSeconds(60)) - .prefetchTime(now.plusSeconds(30)) - .build()); - - try (CachedSupplier cache = CachedSupplier.builder(() -> supplierRef.get().get()) - .staleValueBehavior(CachedSupplier.StaleValueBehavior.ALLOW) - .clock(clock) - .build()) { - - // Initial fetch - assertThat(cache.get()).isEqualTo("cred-A"); - - // Advance time so cache is stale and trigger failure - clock.time = now.plusSeconds(61); - supplierRef.set(() -> { - throw new RuntimeException("source down"); - }); - assertThat(cache.get()).isEqualTo("cred-A"); // stale value returned - - // Invalidate - clock.time = now.plusSeconds(62); - cache.invalidate(v -> v.equals("cred-A")); - - // Set up fresh supplier - supplierRef.set(() -> RefreshResult.builder("cred-B") - .staleTime(now.plusSeconds(7200)) - .prefetchTime(now.plusSeconds(5400)) - .build()); - - // Still within backoff — stale returned - clock.time = now.plusSeconds(100); - assertThat(cache.get()).isEqualTo("cred-A"); - - // Advance past max backoff - clock.time = now.plusSeconds(700); - assertThat(cache.get()).isEqualTo("cred-B"); - } - } - // --- Helper methods --- private void assertRequestUsedAccessKey(SdkHttpRequest request, String expectedAccessKeyId) { @@ -291,13 +214,9 @@ private void assertRequestUsedAccessKey(SdkHttpRequest request, String expectedA private HttpExecuteResponse expiredTokenResponse() { String errorBody = "{\"__type\":\"ExpiredTokenException\",\"message\":\"The security token included in the request " + "is expired\"}"; - // Use 500 so the standard retry strategy retries it. - // In production, services may return 4xx for expired tokens, but the SDK only retries - // on 5xx status codes or specific retryable error codes. Using 500 ensures the retry - // happens so we can verify the full invalidation → retry → fresh credentials flow. return HttpExecuteResponse.builder() .response(SdkHttpResponse.builder() - .statusCode(500) + .statusCode(403) .putHeader("x-amzn-ErrorType", "ExpiredToken") .putHeader("content-length", String.valueOf(errorBody.length())) 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 889affec34d1..bd830c00e9e4 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 @@ -176,15 +176,22 @@ public T get() { *

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.

+ * * @param matchesCachedValue A predicate that returns true if the cached value - * is the one that should be invalidated. + * is the one that should be invalidated. The value passed + * to the predicate is guaranteed to be non-null. */ public void invalidate(Predicate matchesCachedValue) { try { boolean lockAcquired = refreshLock.tryLock(BLOCKING_REFRESH_MAX_WAIT.getSeconds(), TimeUnit.SECONDS); + if (!lockAcquired) { + log.debug(() -> "(" + cachedValueName + ") Unable to acquire lock for invalidation; skipping."); + return; + } try { RefreshResult currentCachedValue = this.cachedValue; - if (currentCachedValue == null) { + if (currentCachedValue == null || currentCachedValue.value() == null) { return; } if (!matchesCachedValue.test(currentCachedValue.value())) { @@ -200,9 +207,7 @@ public void invalidate(Predicate matchesCachedValue) { log.debug(() -> "(" + cachedValueName + ") Cached value invalidated. " + "Next get() will attempt mandatory refresh."); } finally { - if (lockAcquired) { - refreshLock.unlock(); - } + refreshLock.unlock(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); diff --git a/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierInvalidateTest.java b/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierInvalidateTest.java deleted file mode 100644 index 5ba8d494766d..000000000000 --- a/utils/src/test/java/software/amazon/awssdk/utils/cache/CachedSupplierInvalidateTest.java +++ /dev/null @@ -1,559 +0,0 @@ -/* - * 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 static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW; - -import java.time.Clock; -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZoneOffset; -import java.util.ArrayList; -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.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.function.Supplier; -import org.junit.jupiter.api.Test; - -/** - * Unit tests for {@link CachedSupplier#invalidate} and the {@code nextAllowedRefreshTime} backoff behavior. - * - * Validates Requirements: 8, 9, 10, 12, 13 - */ -public class CachedSupplierInvalidateTest { - - // --- Test 1: Predicate matching — true case --- - - @Test - public void invalidate_predicateMatches_setsStaleTimeToNow_triggersRefresh() { - AdjustableClock clock = new AdjustableClock(); - MutableSupplier supplier = new MutableSupplier(); - Instant now = Instant.parse("2024-01-01T00:00:00Z"); - clock.time = now; - - try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) - .staleValueBehavior(ALLOW) - .clock(clock) - .jitterEnabled(false) - .build()) { - // Initial fetch - supplier.set(RefreshResult.builder("value-1") - .staleTime(now.plusSeconds(3600)) - .prefetchTime(now.plusSeconds(1800)) - .build()); - assertThat(cachedSupplier.get()).isEqualTo("value-1"); - - // Invalidate with matching predicate - clock.time = now.plusSeconds(10); - cachedSupplier.invalidate(v -> v.equals("value-1")); - - // Set up a new value for the refresh - supplier.set(RefreshResult.builder("value-2") - .staleTime(now.plusSeconds(7200)) - .prefetchTime(now.plusSeconds(5400)) - .build()); - - // Next get() should trigger refresh because staleTime was set to now - assertThat(cachedSupplier.get()).isEqualTo("value-2"); - } - } - - // --- Test 2: Predicate matching — false case --- - - @Test - public void invalidate_predicateDoesNotMatch_nothingChanges() { - AdjustableClock clock = new AdjustableClock(); - MutableSupplier supplier = new MutableSupplier(); - Instant now = Instant.parse("2024-01-01T00:00:00Z"); - clock.time = now; - - try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) - .staleValueBehavior(ALLOW) - .clock(clock) - .jitterEnabled(false) - .build()) { - // Initial fetch - supplier.set(RefreshResult.builder("value-1") - .staleTime(now.plusSeconds(3600)) - .prefetchTime(now.plusSeconds(1800)) - .build()); - assertThat(cachedSupplier.get()).isEqualTo("value-1"); - - // Invalidate with non-matching predicate - cachedSupplier.invalidate(v -> v.equals("different-value")); - - // Set a new value — should NOT be fetched since cache is still valid - supplier.set(RefreshResult.builder("value-2") - .staleTime(now.plusSeconds(7200)) - .prefetchTime(now.plusSeconds(5400)) - .build()); - - // Should still return the original cached value - assertThat(cachedSupplier.get()).isEqualTo("value-1"); - } - } - - // --- Test 3: Null cached value --- - - @Test - public void invalidate_nullCachedValue_doesNothing() { - AdjustableClock clock = new AdjustableClock(); - MutableSupplier supplier = new MutableSupplier(); - Instant now = Instant.parse("2024-01-01T00:00:00Z"); - clock.time = now; - - try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) - .staleValueBehavior(ALLOW) - .clock(clock) - .jitterEnabled(false) - .build()) { - // Call invalidate before any get() — cachedValue is null - // Should not throw - cachedSupplier.invalidate(v -> true); - - // Now set up a supplier and get the value normally - supplier.set(RefreshResult.builder("value-1") - .staleTime(now.plusSeconds(3600)) - .prefetchTime(now.plusSeconds(1800)) - .build()); - assertThat(cachedSupplier.get()).isEqualTo("value-1"); - } - } - - // --- Test 4: Value preservation — successful refresh returns fresh value --- - - @Test - public void invalidate_successfulRefresh_returnsFreshValue() { - AdjustableClock clock = new AdjustableClock(); - MutableSupplier supplier = new MutableSupplier(); - Instant now = Instant.parse("2024-01-01T00:00:00Z"); - clock.time = now; - - try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) - .staleValueBehavior(ALLOW) - .clock(clock) - .jitterEnabled(false) - .build()) { - // Initial fetch - supplier.set(RefreshResult.builder("old-creds") - .staleTime(now.plusSeconds(3600)) - .prefetchTime(now.plusSeconds(1800)) - .build()); - assertThat(cachedSupplier.get()).isEqualTo("old-creds"); - - // Invalidate - clock.time = now.plusSeconds(10); - cachedSupplier.invalidate(v -> v.equals("old-creds")); - - // Supplier returns fresh value - supplier.set(RefreshResult.builder("new-creds") - .staleTime(now.plusSeconds(7200)) - .prefetchTime(now.plusSeconds(5400)) - .build()); - - // get() triggers refresh and returns new value - assertThat(cachedSupplier.get()).isEqualTo("new-creds"); - } - } - - // --- Test 4b: Value preservation — backoff active returns stale cached value --- - - @Test - public void invalidate_backoffActive_returnsStaleCachedValue() { - AdjustableClock clock = new AdjustableClock(); - MutableSupplier supplier = new MutableSupplier(); - Instant now = Instant.parse("2024-01-01T00:00:00Z"); - clock.time = now; - - try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) - .staleValueBehavior(ALLOW) - .clock(clock) - .jitterEnabled(false) - .build()) { - // Initial fetch - supplier.set(RefreshResult.builder("old-creds") - .staleTime(now.plusSeconds(60)) - .prefetchTime(now.plusSeconds(30)) - .build()); - assertThat(cachedSupplier.get()).isEqualTo("old-creds"); - - // Advance past stale time and trigger a failure to set nextAllowedRefreshTime - clock.time = now.plusSeconds(61); - supplier.set(new RuntimeException("service unavailable")); - assertThat(cachedSupplier.get()).isEqualTo("old-creds"); - - // Now nextAllowedRefreshTime is set (now+61 + [300,600]s) - // Call invalidate — it should NOT clear nextAllowedRefreshTime - clock.time = now.plusSeconds(62); - cachedSupplier.invalidate(v -> v.equals("old-creds")); - - // Set up a fresh value in the supplier - supplier.set(RefreshResult.builder("new-creds") - .staleTime(Instant.MAX) - .prefetchTime(Instant.MAX) - .build()); - - // get() should still return the cached (stale) value because backoff is active - assertThat(cachedSupplier.get()).isEqualTo("old-creds"); - } - } - - // --- Test 5: Backoff gating — invalidate does NOT clear nextAllowedRefreshTime --- - - @Test - public void invalidate_doesNotClearNextAllowedRefreshTime() { - AdjustableClock clock = new AdjustableClock(); - MutableSupplier supplier = new MutableSupplier(); - Instant now = Instant.parse("2024-01-01T00:00:00Z"); - clock.time = now; - - try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) - .staleValueBehavior(ALLOW) - .clock(clock) - .jitterEnabled(false) - .build()) { - // Initial 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 trigger failure to set backoff gate - clock.time = now.plusSeconds(61); - supplier.set(new RuntimeException("service unavailable")); - assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); - - // Backoff gate is now set to somewhere in [61+300, 61+600] seconds from 'now' - // Call invalidate - clock.time = now.plusSeconds(70); - cachedSupplier.invalidate(v -> v.equals("cached-creds")); - - // Prepare a new value — but backoff should prevent refresh - supplier.set(RefreshResult.builder("new-creds") - .staleTime(Instant.MAX) - .prefetchTime(Instant.MAX) - .build()); - - // Still within backoff — should return cached value - clock.time = now.plusSeconds(100); - assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); - - // Advance past maximum possible backoff (61 + 600 = 661s) - clock.time = now.plusSeconds(700); - // Now backoff has elapsed — refresh should succeed - assertThat(cachedSupplier.get()).isEqualTo("new-creds"); - } - } - - // --- Test 5b: After nextAllowedRefreshTime elapses, get() attempts refresh --- - - @Test - public void afterBackoffElapses_getAttemptsRefresh() { - AdjustableClock clock = new AdjustableClock(); - MutableSupplier supplier = new MutableSupplier(); - Instant now = Instant.parse("2024-01-01T00:00:00Z"); - clock.time = now; - - try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) - .staleValueBehavior(ALLOW) - .clock(clock) - .jitterEnabled(false) - .build()) { - // Initial fetch - supplier.set(RefreshResult.builder("cached-creds") - .staleTime(now.plusSeconds(60)) - .prefetchTime(now.plusSeconds(30)) - .build()); - assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); - - // Trigger failure and set backoff - clock.time = now.plusSeconds(61); - supplier.set(new RuntimeException("service unavailable")); - assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); - - // Invalidate so staleTime = now - clock.time = now.plusSeconds(62); - cachedSupplier.invalidate(v -> v.equals("cached-creds")); - - // Prepare fresh value - supplier.set(RefreshResult.builder("fresh-creds") - .staleTime(now.plusSeconds(7200)) - .prefetchTime(now.plusSeconds(5400)) - .build()); - - // Advance past maximum backoff: 61 + 600 = 661s from now - clock.time = now.plusSeconds(700); - // Backoff elapsed, staleTime (set to now by invalidate) is in the past → mandatory refresh - assertThat(cachedSupplier.get()).isEqualTo("fresh-creds"); - } - } - - // --- Test 6: Successful refresh clears nextAllowedRefreshTime --- - - @Test - public void successfulRefresh_clearsNextAllowedRefreshTime() { - AdjustableClock clock = new AdjustableClock(); - MutableSupplier supplier = new MutableSupplier(); - Instant now = Instant.parse("2024-01-01T00:00:00Z"); - clock.time = now; - - try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) - .staleValueBehavior(ALLOW) - .clock(clock) - .jitterEnabled(false) - .build()) { - // Initial fetch - supplier.set(RefreshResult.builder("value-1") - .staleTime(now.plusSeconds(60)) - .prefetchTime(now.plusSeconds(30)) - .build()); - assertThat(cachedSupplier.get()).isEqualTo("value-1"); - - // Trigger failure — sets nextAllowedRefreshTime - clock.time = now.plusSeconds(61); - supplier.set(new RuntimeException("service unavailable")); - assertThat(cachedSupplier.get()).isEqualTo("value-1"); - - // Advance past maximum backoff and do a successful refresh - clock.time = now.plusSeconds(700); - supplier.set(RefreshResult.builder("value-2") - .staleTime(now.plusSeconds(3700)) - .prefetchTime(now.plusSeconds(2000)) - .build()); - assertThat(cachedSupplier.get()).isEqualTo("value-2"); - - // Now invalidate and verify immediate refresh happens (no backoff gate blocking) - clock.time = now.plusSeconds(710); - cachedSupplier.invalidate(v -> v.equals("value-2")); - - supplier.set(RefreshResult.builder("value-3") - .staleTime(now.plusSeconds(7200)) - .prefetchTime(now.plusSeconds(5400)) - .build()); - - // Since nextAllowedRefreshTime was cleared by the successful refresh, - // the invalidation should trigger an immediate refresh - assertThat(cachedSupplier.get()).isEqualTo("value-3"); - } - } - - // --- Test 7: Thread safety --- - - @Test - public void concurrentInvalidateAndGet_noCorruption() throws Exception { - AdjustableClock clock = new AdjustableClock(); - Instant now = Instant.parse("2024-01-01T00:00:00Z"); - clock.time = now; - - AtomicInteger counter = new AtomicInteger(0); - Supplier> supplier = () -> { - String val = "value-" + counter.incrementAndGet(); - return RefreshResult.builder(val) - .staleTime(now.plusSeconds(3600)) - .prefetchTime(now.plusSeconds(1800)) - .build(); - }; - - try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) - .staleValueBehavior(ALLOW) - .clock(clock) - .jitterEnabled(false) - .build()) { - // Prime the cache - assertThat(cachedSupplier.get()).isNotNull(); - - int threadCount = 20; - int iterations = 100; - ExecutorService executor = Executors.newFixedThreadPool(threadCount); - CountDownLatch startLatch = new CountDownLatch(1); - List> futures = new ArrayList<>(); - - for (int i = 0; i < threadCount; i++) { - final int threadIdx = i; - futures.add(executor.submit(() -> { - try { - startLatch.await(); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return; - } - for (int j = 0; j < iterations; j++) { - if (threadIdx % 2 == 0) { - // Half the threads call invalidate - cachedSupplier.invalidate(v -> true); - } else { - // Half the threads call get - String value = cachedSupplier.get(); - assertThat(value).isNotNull(); - } - } - })); - } - - // Release all threads simultaneously - startLatch.countDown(); - - // Wait for all to complete - for (Future future : futures) { - future.get(30, TimeUnit.SECONDS); - } - - executor.shutdown(); - assertThat(executor.awaitTermination(5, TimeUnit.SECONDS)).isTrue(); - - // Final get() should return a non-null value - assertThat(cachedSupplier.get()).isNotNull(); - } - } - - // --- Test 8: Invalidate then refresh success (full cycle) --- - - @Test - public void invalidateThenRefreshSuccess_fullCycle() { - AdjustableClock clock = new AdjustableClock(); - MutableSupplier supplier = new MutableSupplier(); - Instant now = Instant.parse("2024-01-01T00:00:00Z"); - clock.time = now; - - try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) - .staleValueBehavior(ALLOW) - .clock(clock) - .jitterEnabled(false) - .build()) { - // Step 1: Initial get() - supplier.set(RefreshResult.builder("initial-value") - .staleTime(now.plusSeconds(3600)) - .prefetchTime(now.plusSeconds(1800)) - .build()); - assertThat(cachedSupplier.get()).isEqualTo("initial-value"); - - // Step 2: invalidate() - clock.time = now.plusSeconds(10); - cachedSupplier.invalidate(v -> v.equals("initial-value")); - - // Step 3: get() triggers refresh → success returns new value - supplier.set(RefreshResult.builder("refreshed-value") - .staleTime(now.plusSeconds(7200)) - .prefetchTime(now.plusSeconds(5400)) - .build()); - assertThat(cachedSupplier.get()).isEqualTo("refreshed-value"); - } - } - - // --- Test 9: Invalidate does NOT modify nextAllowedRefreshTime (explicit scenario) --- - - @Test - public void invalidate_withActiveBackoff_doesNotModifyNextAllowedRefreshTime() { - AdjustableClock clock = new AdjustableClock(); - MutableSupplier supplier = new MutableSupplier(); - Instant now = Instant.parse("2024-01-01T00:00:00Z"); - clock.time = now; - - try (CachedSupplier cachedSupplier = CachedSupplier.builder(supplier) - .staleValueBehavior(ALLOW) - .clock(clock) - .jitterEnabled(false) - .build()) { - // Initial 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 trigger failure to set nextAllowedRefreshTime - clock.time = now.plusSeconds(61); - supplier.set(new RuntimeException("temporary failure")); - assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); - - // At this point, nextAllowedRefreshTime is set somewhere in [361, 661] seconds from 'now' - // Call invalidate — should NOT modify nextAllowedRefreshTime - clock.time = now.plusSeconds(65); - cachedSupplier.invalidate(v -> v.equals("cached-creds")); - - // Prepare a new value that would be returned if refresh is attempted - supplier.set(RefreshResult.builder("new-creds") - .staleTime(Instant.MAX) - .prefetchTime(Instant.MAX) - .build()); - - // Still within the backoff window (65s < minimum backoff end at 361s) - // get() should return cached value without attempting refresh - assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); - - // Advance slightly — still within backoff range - clock.time = now.plusSeconds(200); - assertThat(cachedSupplier.get()).isEqualTo("cached-creds"); - - // Advance past maximum possible backoff (61 + 600 = 661s) - clock.time = now.plusSeconds(700); - // Now the backoff has elapsed — refresh should be attempted and succeed - assertThat(cachedSupplier.get()).isEqualTo("new-creds"); - } - } - - // --- Helper classes --- - - private static class MutableSupplier implements Supplier> { - private volatile RuntimeException thingToThrow; - private volatile RefreshResult thingToReturn; - - @Override - public RefreshResult get() { - if (thingToThrow != null) { - throw thingToThrow; - } - return thingToReturn; - } - - private MutableSupplier set(RuntimeException exception) { - this.thingToThrow = exception; - this.thingToReturn = null; - return this; - } - - private MutableSupplier set(RefreshResult value) { - this.thingToThrow = null; - this.thingToReturn = value; - return this; - } - } - - private static class AdjustableClock extends Clock { - private volatile Instant time; - - @Override - public ZoneId getZone() { - return ZoneOffset.UTC; - } - - @Override - public Clock withZone(ZoneId zone) { - throw new UnsupportedOperationException(); - } - - @Override - public Instant instant() { - return time; - } - } -} 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 fc6318104f4b..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; @@ -868,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 499a0950f225c236ce569ed35356f429e8e716a6 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Thu, 9 Jul 2026 13:28:00 -0700 Subject: [PATCH 5/9] Switch to builder for SelectedAuthScheme instead of 4 arg constructor --- .../amazon/awssdk/spotbugs-suppressions.xml | 1 + .../codegen/poet/client/ClientClassUtils.java | 6 +- .../poet/rules/EndpointResolverUtilsSpec.java | 15 ++-- ...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 +- .../awssdk/core/SelectedAuthScheme.java | 85 +++++++++++++++++-- .../core/http/auth/AuthSchemeResolver.java | 41 +++++---- .../stages/AuthSchemeResolutionStage.java | 7 +- .../AuthErrorInvalidationHelperTest.java | 24 +++--- 18 files changed, 155 insertions(+), 72 deletions(-) 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..67401329cc01 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,10 @@ 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 = $T.builder().identity(selectedAuthScheme.identity())" + + ".signer(selectedAuthScheme.signer()).authSchemeOption(optionBuilder.build())" + + ".identityProvider(selectedAuthScheme.identityProvider()).build()", + SelectedAuthScheme.class); 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 09eb138689b1..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,8 +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(), " - + "selectedAuthScheme.identityProvider())", + 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(); @@ -632,8 +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(), " - + "selectedAuthScheme.identityProvider())", + 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(); @@ -658,8 +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(), " - + "selectedAuthScheme.identityProvider())", + 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..0283255249f7 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.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(optionBuilder.build()).identityProvider(selectedAuthScheme.identityProvider()).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..57be531f36ea 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.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(optionBuilder.build()).identityProvider(selectedAuthScheme.identityProvider()).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..9e95995a17de 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.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(optionBuilder.build()).identityProvider(selectedAuthScheme.identityProvider()).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..884e9ca4eda9 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.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(optionBuilder.build()).identityProvider(selectedAuthScheme.identityProvider()).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/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 4542bb71054a..232aae6aca25 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 @@ -43,6 +43,7 @@ /// - identity: CompletableFuture ← the resolved identity! /// - signer: HttpSigner /// - authSchemeOption: AuthSchemeOption +/// - identityProvider: IdentityProvider ← the provider that resolved the identity /// ``` @SdkProtectedApi public final class SelectedAuthScheme { @@ -51,20 +52,24 @@ public final class SelectedAuthScheme { private final AuthSchemeOption authSchemeOption; private final IdentityProvider identityProvider; + /** + * @deprecated Use {@link #builder()} instead. + */ + @Deprecated public SelectedAuthScheme(CompletableFuture identity, HttpSigner signer, AuthSchemeOption authSchemeOption) { - this(identity, signer, authSchemeOption, null); - } - - public SelectedAuthScheme(CompletableFuture identity, - HttpSigner signer, - AuthSchemeOption authSchemeOption, - IdentityProvider identityProvider) { this.identity = Validate.paramNotNull(identity, "identity"); this.signer = Validate.paramNotNull(signer, "signer"); this.authSchemeOption = Validate.paramNotNull(authSchemeOption, "authSchemeOption"); - this.identityProvider = identityProvider; // nullable for backward compat + 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() { @@ -82,4 +87,68 @@ public AuthSchemeOption authSchemeOption() { public IdentityProvider identityProvider() { return 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/http/auth/AuthSchemeResolver.java b/core/sdk-core/src/main/java/software/amazon/awssdk/core/http/auth/AuthSchemeResolver.java index 831ad91c408b..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,12 +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(), - selectedAuthScheme.identityProvider() - ); + 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. @@ -182,12 +182,12 @@ public void accept(SignerProperty key, S value) { existingAuthScheme.authSchemeOption().forEachIdentityProperty(mergedOption::putIdentityPropertyIfAbsent); - return new SelectedAuthScheme<>( - selectedAuthScheme.identity(), - selectedAuthScheme.signer(), - mergedOption.build(), - selectedAuthScheme.identityProvider() - ); + return SelectedAuthScheme.builder() + .identity(selectedAuthScheme.identity()) + .signer(selectedAuthScheme.signer()) + .authSchemeOption(mergedOption.build()) + .identityProvider(selectedAuthScheme.identityProvider()) + .build(); } /** @@ -243,10 +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(), - currentScheme.identityProvider())); + SelectedAuthScheme.builder() + .identity(currentScheme.identity()) + .signer(currentScheme.signer()) + .authSchemeOption(mergedOption.build()) + .identityProvider(currentScheme.identityProvider()) + .build()); } } @@ -284,7 +286,12 @@ private static SelectedAuthScheme trySelectAuthScheme( CompletableFuture identity = resolveIdentity( identityProvider, identityRequestBuilder.build(), metricCollector); - return new SelectedAuthScheme<>(identity, signer, authOption, identityProvider); + 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/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 index fa8a2bb1b0c5..62b8b6e4aca9 100644 --- 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 @@ -140,12 +140,12 @@ void invalidateIfAuthError_whenIdentityProviderIsNull_doesNotThrow() { void invalidateIfAuthError_whenInvalidateThrowsException_doesNotPropagate() { ThrowingIdentityProvider provider = new ThrowingIdentityProvider(); TestIdentity identity = new TestIdentity(); - SelectedAuthScheme selectedAuthScheme = new SelectedAuthScheme<>( - CompletableFuture.completedFuture(identity), - mockSigner(), - AuthSchemeOption.builder().schemeId("test").build(), - provider - ); + 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"); @@ -158,12 +158,12 @@ void invalidateIfAuthError_whenInvalidateThrowsException_doesNotPropagate() { // --- Helper methods --- private RequestExecutionContext contextWithProvider(TrackingIdentityProvider provider, TestIdentity identity) { - SelectedAuthScheme selectedAuthScheme = new SelectedAuthScheme<>( - CompletableFuture.completedFuture(identity), - mockSigner(), - AuthSchemeOption.builder().schemeId("test").build(), - provider - ); + SelectedAuthScheme selectedAuthScheme = SelectedAuthScheme.builder() + .identity(CompletableFuture.completedFuture(identity)) + .signer(mockSigner()) + .authSchemeOption(AuthSchemeOption.builder().schemeId("test").build()) + .identityProvider(provider) + .build(); return contextWithSelectedAuthScheme(selectedAuthScheme); } From 8a6d122c45b3b86d586456025bc02ff9254b1f4b Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Fri, 10 Jul 2026 09:36:51 -0700 Subject: [PATCH 6/9] Fix types in codegen --- .../awssdk/codegen/poet/client/ClientClassUtils.java | 5 +---- ...st-unsigned-payload-trait-async-client-class.java | 2 +- ...est-unsigned-payload-trait-sync-client-class.java | 2 +- ...-resolve-interceptor-with-endpointsbasedauth.java | 2 +- ...int-resolve-interceptor-with-multiauthsigv4a.java | 2 +- .../amazon/awssdk/core/SelectedAuthScheme.java | 12 ++++++++++++ 6 files changed, 17 insertions(+), 8 deletions(-) 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 67401329cc01..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,10 +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 = $T.builder().identity(selectedAuthScheme.identity())" - + ".signer(selectedAuthScheme.signer()).authSchemeOption(optionBuilder.build())" - + ".identityProvider(selectedAuthScheme.identityProvider()).build()", - SelectedAuthScheme.class); + b.addStatement("selectedAuthScheme = selectedAuthScheme.toBuilder().authSchemeOption(optionBuilder.build()).build()"); b.endControlFlow(); } 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 0283255249f7..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,7 +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 = SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(optionBuilder.build()).identityProvider(selectedAuthScheme.identityProvider()).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 57be531f36ea..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,7 +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 = SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(optionBuilder.build()).identityProvider(selectedAuthScheme.identityProvider()).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-with-endpointsbasedauth.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-endpointsbasedauth.java index 9e95995a17de..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,7 +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 = SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(optionBuilder.build()).identityProvider(selectedAuthScheme.identityProvider()).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-with-multiauthsigv4a.java b/codegen/src/test/resources/software/amazon/awssdk/codegen/poet/rules/endpoint-resolve-interceptor-with-multiauthsigv4a.java index 884e9ca4eda9..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,7 +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 = SelectedAuthScheme.builder().identity(selectedAuthScheme.identity()).signer(selectedAuthScheme.signer()).authSchemeOption(optionBuilder.build()).identityProvider(selectedAuthScheme.identityProvider()).build(); + selectedAuthScheme = selectedAuthScheme.toBuilder().authSchemeOption(optionBuilder.build()).build(); } executionAttributes.putAttribute(SdkInternalExecutionAttribute.SELECTED_AUTH_SCHEME, selectedAuthScheme); } 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 232aae6aca25..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 @@ -88,6 +88,18 @@ 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<>(); } From 9b561b54319a7ae39440568207370f76e400a704 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Fri, 10 Jul 2026 10:23:19 -0700 Subject: [PATCH 7/9] Fix failing SSO test --- .../sso/auth/SsoProfileCredentialsProviderFactoryTest.java | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) 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); From 6f0444da8e5e14e01abc5ebfebe3701d5727d393 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Fri, 10 Jul 2026 11:48:41 -0700 Subject: [PATCH 8/9] PR Cleanups --- .../ContainerCredentialsProvider.java | 5 +- .../InstanceProfileCredentialsProvider.java | 5 +- .../ProcessCredentialsProvider.java | 9 +- .../awssdk/awscore/internal/AwsErrorCode.java | 2 +- .../awssdk/identity/spi/IdentityProvider.java | 6 +- .../core/exception/SdkServiceException.java | 2 +- .../utils/AuthErrorInvalidationHelper.java | 4 +- .../signin/auth/LoginCredentialsProvider.java | 5 +- .../sso/auth/SsoCredentialsProvider.java | 5 +- .../sts/auth/StsCredentialsProvider.java | 5 +- .../AuthErrorInvalidationIntegrationTest.java | 321 ------------------ .../awssdk/utils/cache/CacheRefreshUtils.java | 2 +- .../awssdk/utils/cache/CachedSupplier.java | 16 +- 13 files changed, 30 insertions(+), 357 deletions(-) delete mode 100644 test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationIntegrationTest.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 9a95620ecb60..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 @@ -191,9 +191,8 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { String rejectedAccessKeyId = identity.accessKeyId(); - return CompletableFuture.runAsync(() -> - credentialsCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())) - ); + credentialsCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())); + return CompletableFuture.completedFuture(null); } @Override 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 19baaff757fe..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 @@ -172,9 +172,8 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { String rejectedAccessKeyId = identity.accessKeyId(); - return CompletableFuture.runAsync(() -> - credentialsCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())) - ); + credentialsCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())); + return CompletableFuture.completedFuture(null); } private RefreshResult refreshCredentials() { 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 e32a319a7e4c..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 @@ -171,9 +171,8 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { String rejectedAccessKeyId = identity.accessKeyId(); - return CompletableFuture.runAsync(() -> - processCredentialCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())) - ); + processCredentialCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())); + return CompletableFuture.completedFuture(null); } private RefreshResult refreshCredentials() { @@ -373,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 */ @@ -399,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/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 6fd9267b8000..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 @@ -68,7 +68,7 @@ public final class AwsErrorCode { retryableErrorCodes.add("InternalError"); RETRYABLE_ERROR_CODES = unmodifiableSet(retryableErrorCodes); - Set authErrorCodes = new HashSet<>(3); + Set authErrorCodes = new HashSet<>(2); authErrorCodes.add("ExpiredToken"); authErrorCodes.add("InvalidToken"); AUTH_ERROR_CODES = unmodifiableSet(authErrorCodes); 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 dcdc14fadc7c..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 @@ -163,12 +163,12 @@ default CompletableFuture resolveIdentity() { * *

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.

+ * 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 rejected identity (e.g., same access key ID). * - *

The default implementation is a no-op, suitable for providers that do not cache.

+ *

The default implementation is a no-op, suitable for providers that do not cache. * * @param identity The identity that was rejected by the service. */ 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 fc3a339cded6..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 @@ -108,7 +108,7 @@ public boolean isRetryableException() { * 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.

+ * resolves fresh ones. * * @return true if the exception is classified as an authentication error, otherwise false. */ 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 index d5fe21a707b9..90e20203ee06 100644 --- 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 @@ -32,10 +32,10 @@ *

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 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.

+ * Invalidation failures never disrupt the normal request/retry flow. */ @SdkInternalApi public final class AuthErrorInvalidationHelper { 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 e90986badc5b..8515b4900b79 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 @@ -300,9 +300,8 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { String rejectedAccessKeyId = identity.accessKeyId(); - return CompletableFuture.runAsync(() -> - credentialCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())) - ); + credentialCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())); + return CompletableFuture.completedFuture(null); } @Override 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 8e22e0853aa2..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 @@ -173,9 +173,8 @@ public AwsCredentials resolveCredentials() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { String rejectedAccessKeyId = identity.accessKeyId(); - return CompletableFuture.runAsync(() -> - credentialCache.invalidate(holder -> rejectedAccessKeyId.equals(holder.sessionCredentials().accessKeyId())) - ); + credentialCache.invalidate(holder -> rejectedAccessKeyId.equals(holder.sessionCredentials().accessKeyId())); + return CompletableFuture.completedFuture(null); } @Override 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 2782ec960986..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 @@ -128,9 +128,8 @@ public void close() { @Override public CompletableFuture invalidate(AwsCredentialsIdentity identity) { String rejectedAccessKeyId = identity.accessKeyId(); - return CompletableFuture.runAsync(() -> - sessionCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())) - ); + sessionCache.invalidate(cachedCreds -> rejectedAccessKeyId.equals(cachedCreds.accessKeyId())); + return CompletableFuture.completedFuture(null); } /** diff --git a/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationIntegrationTest.java b/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationIntegrationTest.java deleted file mode 100644 index a555c5d4cda8..000000000000 --- a/test/codegen-generated-classes-test/src/test/java/software/amazon/awssdk/services/retry/AuthErrorInvalidationIntegrationTest.java +++ /dev/null @@ -1,321 +0,0 @@ -/* - * 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.time.Clock; -import java.time.Instant; -import java.time.ZoneId; -import java.time.ZoneOffset; -import java.util.List; -import java.util.concurrent.CompletableFuture; -import java.util.concurrent.atomic.AtomicInteger; -import java.util.concurrent.atomic.AtomicReference; -import java.util.function.Supplier; -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; -import software.amazon.awssdk.utils.cache.CachedSupplier; -import software.amazon.awssdk.utils.cache.RefreshResult; - -/** - * Integration test verifying the end-to-end flow: - * service returns ExpiredToken → credentials invalidated → next call uses fresh credentials. - * - * Also verifies backoff interaction: when refresh backoff is active, invalidate() does not - * bypass it, and stale cached credentials are returned until the backoff elapses. - */ -public class AuthErrorInvalidationIntegrationTest { - - /** - * 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); - } - } - - /** - * Backoff interaction: invalidation during active backoff returns stale credentials. - * - * This test verifies that when CachedSupplier has an active refresh backoff - * (nextAllowedRefreshTime in the future), calling invalidate() marks the cache stale - * but does NOT bypass the backoff. The next get() still returns cached credentials - * until the backoff elapses, at which point fresh credentials are obtained. - */ - @Test - public void invalidation_duringActiveBackoff_returnsStaleCredentials() { - AdjustableClock clock = new AdjustableClock(); - Instant now = Instant.parse("2024-01-01T00:00:00Z"); - clock.time = now; - - AtomicInteger fetchCount = new AtomicInteger(0); - AtomicReference>> supplierRef = new AtomicReference<>(); - - supplierRef.set(() -> RefreshResult.builder("access-key-1") - .staleTime(now.plusSeconds(60)) - .prefetchTime(now.plusSeconds(30)) - .build()); - - try (CachedSupplier cache = CachedSupplier.builder(() -> { - fetchCount.incrementAndGet(); - return supplierRef.get().get(); - }) - .staleValueBehavior(CachedSupplier.StaleValueBehavior.ALLOW) - .clock(clock) - .build()) { - - // Initial fetch - assertThat(cache.get()).isEqualTo("access-key-1"); - assertThat(fetchCount.get()).isEqualTo(1); - - // Advance past stale time and trigger a refresh failure to set nextAllowedRefreshTime - clock.time = now.plusSeconds(61); - supplierRef.set(() -> { - throw new RuntimeException("credential source unavailable"); - }); - // This get() will try to refresh, fail, set backoff, and return stale value - assertThat(cache.get()).isEqualTo("access-key-1"); - - // Now call invalidate — marks staleTime = now but does NOT clear backoff - clock.time = now.plusSeconds(62); - cache.invalidate(v -> v.equals("access-key-1")); - - // Set up fresh credentials in the supplier - supplierRef.set(() -> RefreshResult.builder("access-key-2") - .staleTime(now.plusSeconds(7200)) - .prefetchTime(now.plusSeconds(5400)) - .build()); - - // get() should return STALE credentials because backoff is still active - assertThat(cache.get()).isEqualTo("access-key-1"); - - // Advance past maximum possible backoff (61 + 600 = 661 seconds from original now) - clock.time = now.plusSeconds(700); - - // Now backoff has elapsed — next get() should refresh and return fresh value - assertThat(cache.get()).isEqualTo("access-key-2"); - } - } - - // --- 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(); - } - } - - /** - * A clock whose time can be manually adjusted for testing backoff behavior. - */ - private static class AdjustableClock extends Clock { - volatile Instant time = Instant.now(); - - @Override - public ZoneId getZone() { - return ZoneOffset.UTC; - } - - @Override - public Clock withZone(ZoneId zone) { - return this; - } - - @Override - public Instant instant() { - return time; - } - } -} 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 bd830c00e9e4..8ce75cf3751e 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 @@ -124,7 +124,7 @@ public class CachedSupplier implements Supplier, SdkAutoCloseable { * 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.

    + *

    This field is NEVER modified by {@code invalidate()} — backoff remains independently tracked. */ private volatile Instant nextAllowedRefreshTime; @@ -174,9 +174,9 @@ public T get() { * the refresh backoff gate ({@code nextAllowedRefreshTime}). * *

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

    + * 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 there is no cached value, this method is a no-op — the predicate will not be called. * * @param matchesCachedValue A predicate that returns true if the cached value * is the one that should be invalidated. The value passed @@ -211,7 +211,7 @@ public void invalidate(Predicate matchesCachedValue) { } } catch (InterruptedException e) { Thread.currentThread().interrupt(); - log.warn(() -> "(" + cachedValueName + ") Interrupted while invalidating cached value."); + log.debug(() -> "(" + cachedValueName + ") Interrupted while invalidating cached value."); } } @@ -529,10 +529,10 @@ 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 non-recoverable (all failures trigger static stability - * backoff when {@link StaleValueBehavior#ALLOW} is configured).

    + * backoff when {@link StaleValueBehavior#ALLOW} is configured). */ public Builder nonRecoverableErrorPredicate(Predicate nonRecoverableErrorPredicate) { this.nonRecoverableErrorPredicate = nonRecoverableErrorPredicate; @@ -618,10 +618,10 @@ public enum StaleValueBehavior { * extends the stale time by a uniformly random backoff between 5 and 10 minutes (300-600 seconds). * *

    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.

    + * 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 non-recoverable.

    + * unless the error is non-recoverable. */ ALLOW } From d7574c3b61e23e8533d073ec7dc2b2d87a4dbcda Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Fri, 10 Jul 2026 14:30:10 -0700 Subject: [PATCH 9/9] invalidate only the lastProvider when reuseLastProviderEnabled=true --- .../AwsCredentialsProviderChain.java | 8 +++ .../AwsCredentialsProviderChainTest.java | 49 +++++++++++++++++-- 2 files changed, 53 insertions(+), 4 deletions(-) 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 5d4e46bd2395..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 @@ -133,6 +133,14 @@ public AwsCredentials resolveCredentials() { @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 { 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 a8e19f5ddb87..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 @@ -179,21 +179,61 @@ private static void assertChainResolvesCorrectly(AwsCredentialsProviderChain cha } @Test - public void invalidate_propagatesToAllChildren() { + public void invalidate_reuseEnabled_lastProviderSet_onlyInvalidatesLastProvider() { TrackingCredentialsProvider provider1 = new TrackingCredentialsProvider("key1", "secret1"); TrackingCredentialsProvider provider2 = new TrackingCredentialsProvider("key2", "secret2"); - TrackingCredentialsProvider provider3 = new TrackingCredentialsProvider("key3", "secret3"); AwsCredentialsProviderChain chain = AwsCredentialsProviderChain.builder() - .credentialsProviders(provider1, provider2, provider3) + .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); - assertThat(provider3.invalidateCallCount).isEqualTo(1); } @SuppressWarnings("unchecked") @@ -207,6 +247,7 @@ public void invalidate_doesNotShortCircuit_whenChildThrows() { AwsCredentialsProviderChain chain = AwsCredentialsProviderChain.builder() .credentialsProviders(mockProvider1, mockProvider2, mockProvider3) + .reuseLastProviderEnabled(false) .build(); AwsCredentialsIdentity identity = AwsBasicCredentials.create("key1", "secret1");