Skip to content

Commit 31b290a

Browse files
committed
Switch credential providers to ALLOW static stability + cacheInvalidatingPredicate
- STS, Container, SSO, and Login providers now use StaleValueBehavior.ALLOW - SSO provider configures cacheInvalidatingPredicate for ExpiredTokenException and UnauthorizedException (original exceptions propagate unchanged) - Login provider configures cacheInvalidatingPredicate for AccessDeniedException with TOKEN_EXPIRED or USER_CREDENTIALS_CHANGED error codes - Removes all CacheInvalidatingException wrapping — service exceptions flow through to customers unchanged
1 parent 5a90034 commit 31b290a

8 files changed

Lines changed: 312 additions & 16 deletions

File tree

core/auth/src/main/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProvider.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
package software.amazon.awssdk.auth.credentials;
1717

1818
import static java.nio.charset.StandardCharsets.UTF_8;
19+
import static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW;
1920

2021
import java.io.IOException;
2122
import java.net.InetAddress;
@@ -113,10 +114,12 @@ private ContainerCredentialsProvider(BuilderImpl builder) {
113114
this.credentialsCache = CachedSupplier.builder(this::refreshCredentials)
114115
.cachedValueName(toString())
115116
.prefetchStrategy(new NonBlocking(builder.asyncThreadName))
117+
.staleValueBehavior(ALLOW)
116118
.build();
117119
} else {
118120
this.credentialsCache = CachedSupplier.builder(this::refreshCredentials)
119121
.cachedValueName(toString())
122+
.staleValueBehavior(ALLOW)
120123
.build();
121124
}
122125
}

core/auth/src/test/java/software/amazon/awssdk/auth/credentials/ContainerCredentialsProviderTest.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -140,4 +140,54 @@ private String getSuccessfulBody() {
140140
"\"Token\":\"TOKEN_TOKEN_TOKEN\"," +
141141
"\"Expiration\":\"3000-05-03T04:55:54Z\"}";
142142
}
143+
144+
/**
145+
* Tests that when the cache is stale and refresh fails, the provider returns cached credentials
146+
* instead of throwing an exception (ALLOW behavior / static stability).
147+
*/
148+
@Test
149+
public void testRefreshFailureReturnsCachedCredentials_whenCacheIsStale() {
150+
// First call succeeds with credentials that are already expired (stale immediately on next get)
151+
String alreadyExpiredBody = "{\"AccessKeyId\":\"ACCESS_KEY_ID\"," +
152+
"\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," +
153+
"\"Token\":\"TOKEN_TOKEN_TOKEN\"," +
154+
"\"Expiration\":\"2020-01-01T00:00:00Z\"}";
155+
156+
stubFor(get(urlPathEqualTo(CREDENTIALS_PATH))
157+
.willReturn(aResponse()
158+
.withStatus(200)
159+
.withHeader("Content-Type", "application/json")
160+
.withBody(alreadyExpiredBody)));
161+
162+
// First call succeeds (initial fetch always succeeds even if credentials are expired)
163+
AwsCredentials firstCredentials = credentialsProvider.resolveCredentials();
164+
assertThat(firstCredentials.accessKeyId()).isEqualTo(ACCESS_KEY_ID);
165+
166+
// Now stub the endpoint to return a 500 error (simulating container metadata endpoint failure)
167+
stubFor(get(urlPathEqualTo(CREDENTIALS_PATH))
168+
.willReturn(aResponse()
169+
.withStatus(500)
170+
.withBody("Internal Server Error")));
171+
172+
// Second call: cache is stale (expiration is in the past), refresh fails with 500,
173+
// but ALLOW behavior should return the cached credentials
174+
AwsCredentials secondCredentials = credentialsProvider.resolveCredentials();
175+
assertThat(secondCredentials.accessKeyId()).isEqualTo(ACCESS_KEY_ID);
176+
assertThat(secondCredentials.secretAccessKey()).isEqualTo(SECRET_ACCESS_KEY);
177+
}
178+
179+
/**
180+
* Tests that when no credentials are cached (initial fetch) and the endpoint fails,
181+
* an exception is thrown.
182+
*/
183+
@Test
184+
public void testInitialFetchFailure_throwsException() {
185+
stubFor(get(urlPathEqualTo(CREDENTIALS_PATH))
186+
.willReturn(aResponse()
187+
.withStatus(500)
188+
.withBody("Internal Server Error")));
189+
190+
assertThatThrownBy(credentialsProvider::resolveCredentials)
191+
.isInstanceOf(SdkClientException.class);
192+
}
143193
}

services/signin/src/main/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProvider.java

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
import static software.amazon.awssdk.utils.UserHomeDirectoryUtils.userHomeDirectory;
1919
import static software.amazon.awssdk.utils.Validate.notNull;
2020
import static software.amazon.awssdk.utils.Validate.paramNotBlank;
21+
import static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW;
2122

2223
import java.nio.file.Path;
2324
import java.nio.file.Paths;
@@ -43,6 +44,7 @@
4344
import software.amazon.awssdk.services.signin.model.AccessDeniedException;
4445
import software.amazon.awssdk.services.signin.model.CreateOAuth2TokenRequest;
4546
import software.amazon.awssdk.services.signin.model.CreateOAuth2TokenResponse;
47+
import software.amazon.awssdk.services.signin.model.OAuth2ErrorCode;
4648
import software.amazon.awssdk.utils.Logger;
4749
import software.amazon.awssdk.utils.SdkAutoCloseable;
4850
import software.amazon.awssdk.utils.StringUtils;
@@ -120,7 +122,9 @@ private LoginCredentialsProvider(BuilderImpl builder) {
120122
this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled;
121123
CachedSupplier.Builder<AwsCredentials> cacheBuilder =
122124
CachedSupplier.builder(this::updateSigninCredentials)
123-
.cachedValueName(toString());
125+
.cachedValueName(toString())
126+
.staleValueBehavior(ALLOW)
127+
.cacheInvalidatingPredicate(LoginCredentialsProvider::isCacheInvalidating);
124128
if (builder.asyncCredentialUpdateEnabled) {
125129
cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME));
126130
}
@@ -205,15 +209,10 @@ private RefreshResult<AwsCredentials> refreshFromSigninService(LoginAccessToken
205209

206210
switch (accessDeniedException.error()) {
207211
case TOKEN_EXPIRED:
208-
throw SdkClientException.create(
209-
"Your session has expired. Please reauthenticate.",
210-
accessDeniedException);
211212
case USER_CREDENTIALS_CHANGED:
212-
throw SdkClientException.create(
213-
"Unable to refresh credentials because of a change in your password. "
214-
+ "Please reauthenticate with your new password.",
215-
accessDeniedException
216-
);
213+
// Let the original AccessDeniedException propagate — the cacheInvalidatingPredicate
214+
// on CachedSupplier will identify it and bypass static stability.
215+
throw accessDeniedException;
217216
case INSUFFICIENT_PERMISSIONS:
218217
throw SdkClientException.create(
219218
"Unable to refresh credentials due to insufficient permissions. You may be missing permission "
@@ -227,6 +226,20 @@ private RefreshResult<AwsCredentials> refreshFromSigninService(LoginAccessToken
227226
}
228227
}
229228

229+
/**
230+
* Determines whether a given exception represents a non-recoverable refresh failure that should bypass
231+
* static stability. For Login, this is an {@link AccessDeniedException} with error code
232+
* {@link OAuth2ErrorCode#TOKEN_EXPIRED} or {@link OAuth2ErrorCode#USER_CREDENTIALS_CHANGED}.
233+
*/
234+
private static boolean isCacheInvalidating(RuntimeException e) {
235+
if (!(e instanceof AccessDeniedException)) {
236+
return false;
237+
}
238+
AccessDeniedException ade = (AccessDeniedException) e;
239+
return ade.error() == OAuth2ErrorCode.TOKEN_EXPIRED
240+
|| ade.error() == OAuth2ErrorCode.USER_CREDENTIALS_CHANGED;
241+
}
242+
230243
/**
231244
* The amount of time, relative to session token expiration, that the cached credentials are considered stale and should no
232245
* longer be used. All threads will block until the value is updated.

services/signin/src/test/java/software/amazon/awssdk/services/signin/auth/LoginCredentialsProviderTest.java

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@
5454
import software.amazon.awssdk.services.signin.internal.LoginAccessToken;
5555
import software.amazon.awssdk.services.signin.internal.OnDiskTokenManager;
5656
import software.amazon.awssdk.services.signin.model.CreateOAuth2TokenRequest;
57+
import software.amazon.awssdk.services.signin.model.AccessDeniedException;
5758
import software.amazon.awssdk.services.signin.model.OAuth2ErrorCode;
5859
import software.amazon.awssdk.services.signin.model.SigninException;
5960
import software.amazon.awssdk.testutils.service.http.MockSyncHttpClient;
@@ -182,7 +183,7 @@ public void resolveCredentials_whenCredentialsExpired_refreshesAndUpdatesCache()
182183

183184
@Test
184185
public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithGeneric500_raisesException() {
185-
// expired
186+
// expired - no cached value in CachedSupplier yet, so ALLOW still throws on first failure
186187
AwsSessionCredentials creds = buildCredentials(Instant.now().minusSeconds(60));
187188
LoginAccessToken token = buildAccessToken(creds);
188189
tokenManager.storeToken(token);
@@ -195,6 +196,50 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithGeneri
195196
assertThrows(SigninException.class, () -> loginCredentialsProvider.resolveCredentials());
196197
}
197198

199+
@Test
200+
public void resolveCredentials_transientFailureAfterSuccessfulCache_returnsCachedCredentials() {
201+
// First: store token with expired credentials so it triggers refresh from service
202+
AwsSessionCredentials creds = buildCredentials(Instant.now().minusSeconds(600));
203+
LoginAccessToken token = buildAccessToken(creds);
204+
tokenManager.storeToken(token);
205+
206+
// First response: successful refresh with short-lived credentials (expires in 30s)
207+
// staleTime will be now+30s - 1min = now-30s (already stale), so next get() will refresh again
208+
String shortLivedJsonBody =
209+
"{\"accessToken\":"
210+
+ "{\"accessKeyId\":\"new-akid\","
211+
+ "\"secretAccessKey\":\"new-skid\","
212+
+ "\"sessionToken\":\"new-session-token\"},"
213+
+ "\"tokenType\":\"aws_sigv4\","
214+
+ "\"expiresIn\":30,"
215+
+ "\"refreshToken\":\"new-refresh-token\"}";
216+
217+
HttpExecuteResponse successResponse = HttpExecuteResponse
218+
.builder()
219+
.response(SdkHttpResponse.builder().statusCode(200).build())
220+
.responseBody(AbortableInputStream.create(
221+
new ByteArrayInputStream(shortLivedJsonBody.getBytes(StandardCharsets.UTF_8))))
222+
.build();
223+
224+
// Second response: transient 500 error
225+
HttpExecuteResponse failureResponse = HttpExecuteResponse
226+
.builder()
227+
.response(SdkHttpResponse.builder().statusCode(500).build())
228+
.build();
229+
230+
mockHttpClient.stubResponses(successResponse, failureResponse);
231+
232+
// First call: succeeds and populates the CachedSupplier cache
233+
AwsCredentials firstResolve = loginCredentialsProvider.resolveCredentials();
234+
assertEquals("new-akid", firstResolve.accessKeyId());
235+
236+
// Second call: the cached value is already stale (30s expiry - 1min staleTime < now),
237+
// so CachedSupplier tries to refresh, gets 500, and with ALLOW behavior returns cached value
238+
AwsCredentials secondResolve = loginCredentialsProvider.resolveCredentials();
239+
assertEquals("new-akid", secondResolve.accessKeyId());
240+
assertEquals("new-skid", secondResolve.secretAccessKey());
241+
}
242+
198243
@Test
199244
public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithTokenExpired_raisesException() {
200245
// expired
@@ -203,8 +248,9 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithTokenE
203248
tokenManager.storeToken(token);
204249

205250
stubAccessDeniedException(OAuth2ErrorCode.TOKEN_EXPIRED);
206-
SdkClientException e = assertThrows(SdkClientException.class, () -> loginCredentialsProvider.resolveCredentials());
207-
assertTrue(e.getMessage().contains("Your session has expired"));
251+
AccessDeniedException e = assertThrows(AccessDeniedException.class,
252+
() -> loginCredentialsProvider.resolveCredentials());
253+
assertNotNull(e);
208254
}
209255

210256
@Test
@@ -215,8 +261,9 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithUserEx
215261
tokenManager.storeToken(token);
216262

217263
stubAccessDeniedException(OAuth2ErrorCode.USER_CREDENTIALS_CHANGED);
218-
SdkClientException e = assertThrows(SdkClientException.class, () -> loginCredentialsProvider.resolveCredentials());
219-
assertTrue(e.getMessage().contains("change in your password"));
264+
AccessDeniedException e = assertThrows(AccessDeniedException.class,
265+
() -> loginCredentialsProvider.resolveCredentials());
266+
assertNotNull(e);
220267
}
221268

222269
@Test

services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoCredentialsProvider.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
import software.amazon.awssdk.services.sso.internal.SessionCredentialsHolder;
3131
import software.amazon.awssdk.services.sso.model.GetRoleCredentialsRequest;
3232
import software.amazon.awssdk.services.sso.model.RoleCredentials;
33+
import software.amazon.awssdk.services.sso.model.UnauthorizedException;
3334
import software.amazon.awssdk.utils.SdkAutoCloseable;
3435
import software.amazon.awssdk.utils.StringUtils;
3536
import software.amazon.awssdk.utils.builder.CopyableBuilder;
@@ -90,7 +91,10 @@ private SsoCredentialsProvider(BuilderImpl builder) {
9091
this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled;
9192
CachedSupplier.Builder<SessionCredentialsHolder> cacheBuilder =
9293
CachedSupplier.builder(this::updateSsoCredentials)
93-
.cachedValueName(toString());
94+
.cachedValueName(toString())
95+
.staleValueBehavior(CachedSupplier.StaleValueBehavior.ALLOW)
96+
.cacheInvalidatingPredicate(
97+
e -> e instanceof ExpiredTokenException || e instanceof UnauthorizedException);
9498
if (builder.asyncCredentialUpdateEnabled) {
9599
cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME));
96100
}
@@ -115,6 +119,7 @@ private RefreshResult<SessionCredentialsHolder> updateSsoCredentials() {
115119
private SessionCredentialsHolder getUpdatedCredentials(SsoClient ssoClient) {
116120
GetRoleCredentialsRequest request = getRoleCredentialsRequestSupplier.get();
117121
notNull(request, "GetRoleCredentialsRequest can't be null.");
122+
118123
RoleCredentials roleCredentials = ssoClient.getRoleCredentials(request).roleCredentials();
119124
AwsSessionCredentials sessionCredentials = AwsSessionCredentials.builder()
120125
.accessKeyId(roleCredentials.accessKeyId())

0 commit comments

Comments
 (0)