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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@
package software.amazon.awssdk.auth.credentials;

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

import java.io.IOException;
import java.net.InetAddress;
Expand Down Expand Up @@ -113,10 +114,12 @@ private ContainerCredentialsProvider(BuilderImpl builder) {
this.credentialsCache = CachedSupplier.builder(this::refreshCredentials)
.cachedValueName(toString())
.prefetchStrategy(new NonBlocking(builder.asyncThreadName))
.staleValueBehavior(ALLOW)
.build();
} else {
this.credentialsCache = CachedSupplier.builder(this::refreshCredentials)
.cachedValueName(toString())
.staleValueBehavior(ALLOW)
.build();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,54 @@ private String getSuccessfulBody() {
"\"Token\":\"TOKEN_TOKEN_TOKEN\"," +
"\"Expiration\":\"3000-05-03T04:55:54Z\"}";
}

/**
* Tests that when the cache is stale and refresh fails, the provider returns cached credentials
* instead of throwing an exception (ALLOW behavior / static stability).
*/
@Test
public void testRefreshFailureReturnsCachedCredentials_whenCacheIsStale() {
// First call succeeds with credentials that are already expired (stale immediately on next get)
String alreadyExpiredBody = "{\"AccessKeyId\":\"ACCESS_KEY_ID\"," +
"\"SecretAccessKey\":\"SECRET_ACCESS_KEY\"," +
"\"Token\":\"TOKEN_TOKEN_TOKEN\"," +
"\"Expiration\":\"2020-01-01T00:00:00Z\"}";

stubFor(get(urlPathEqualTo(CREDENTIALS_PATH))
.willReturn(aResponse()
.withStatus(200)
.withHeader("Content-Type", "application/json")
.withBody(alreadyExpiredBody)));

// First call succeeds (initial fetch always succeeds even if credentials are expired)
AwsCredentials firstCredentials = credentialsProvider.resolveCredentials();
assertThat(firstCredentials.accessKeyId()).isEqualTo(ACCESS_KEY_ID);

// Now stub the endpoint to return a 500 error (simulating container metadata endpoint failure)
stubFor(get(urlPathEqualTo(CREDENTIALS_PATH))
.willReturn(aResponse()
.withStatus(500)
.withBody("Internal Server Error")));

// Second call: cache is stale (expiration is in the past), refresh fails with 500,
// but ALLOW behavior should return the cached credentials
AwsCredentials secondCredentials = credentialsProvider.resolveCredentials();
assertThat(secondCredentials.accessKeyId()).isEqualTo(ACCESS_KEY_ID);
assertThat(secondCredentials.secretAccessKey()).isEqualTo(SECRET_ACCESS_KEY);
}

/**
* Tests that when no credentials are cached (initial fetch) and the endpoint fails,
* an exception is thrown.
*/
@Test
public void testInitialFetchFailure_throwsException() {
stubFor(get(urlPathEqualTo(CREDENTIALS_PATH))
.willReturn(aResponse()
.withStatus(500)
.withBody("Internal Server Error")));

assertThatThrownBy(credentialsProvider::resolveCredentials)
.isInstanceOf(SdkClientException.class);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import static software.amazon.awssdk.utils.UserHomeDirectoryUtils.userHomeDirectory;
import static software.amazon.awssdk.utils.Validate.notNull;
import static software.amazon.awssdk.utils.Validate.paramNotBlank;
import static software.amazon.awssdk.utils.cache.CachedSupplier.StaleValueBehavior.ALLOW;

import java.nio.file.Path;
import java.nio.file.Paths;
Expand All @@ -43,6 +44,7 @@
import software.amazon.awssdk.services.signin.model.AccessDeniedException;
import software.amazon.awssdk.services.signin.model.CreateOAuth2TokenRequest;
import software.amazon.awssdk.services.signin.model.CreateOAuth2TokenResponse;
import software.amazon.awssdk.services.signin.model.OAuth2ErrorCode;
import software.amazon.awssdk.utils.Logger;
import software.amazon.awssdk.utils.SdkAutoCloseable;
import software.amazon.awssdk.utils.StringUtils;
Expand Down Expand Up @@ -120,7 +122,9 @@ private LoginCredentialsProvider(BuilderImpl builder) {
this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled;
CachedSupplier.Builder<AwsCredentials> cacheBuilder =
CachedSupplier.builder(this::updateSigninCredentials)
.cachedValueName(toString());
.cachedValueName(toString())
.staleValueBehavior(ALLOW)
.cacheInvalidatingPredicate(LoginCredentialsProvider::isCacheInvalidating);
if (builder.asyncCredentialUpdateEnabled) {
cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME));
}
Expand Down Expand Up @@ -205,15 +209,10 @@ private RefreshResult<AwsCredentials> refreshFromSigninService(LoginAccessToken

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

/**
* Determines whether a given exception represents a non-recoverable refresh failure that should bypass
* static stability. For Login, this is an {@link AccessDeniedException} with error code
* {@link OAuth2ErrorCode#TOKEN_EXPIRED} or {@link OAuth2ErrorCode#USER_CREDENTIALS_CHANGED}.
*/
private static boolean isCacheInvalidating(RuntimeException e) {
if (!(e instanceof AccessDeniedException)) {
return false;
}
AccessDeniedException ade = (AccessDeniedException) e;
return ade.error() == OAuth2ErrorCode.TOKEN_EXPIRED
|| ade.error() == OAuth2ErrorCode.USER_CREDENTIALS_CHANGED;
}

/**
* The amount of time, relative to session token expiration, that the cached credentials are considered stale and should no
* longer be used. All threads will block until the value is updated.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@
import software.amazon.awssdk.services.signin.internal.LoginAccessToken;
import software.amazon.awssdk.services.signin.internal.OnDiskTokenManager;
import software.amazon.awssdk.services.signin.model.CreateOAuth2TokenRequest;
import software.amazon.awssdk.services.signin.model.AccessDeniedException;
import software.amazon.awssdk.services.signin.model.OAuth2ErrorCode;
import software.amazon.awssdk.services.signin.model.SigninException;
import software.amazon.awssdk.testutils.service.http.MockSyncHttpClient;
Expand Down Expand Up @@ -182,7 +183,7 @@ public void resolveCredentials_whenCredentialsExpired_refreshesAndUpdatesCache()

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

@Test
public void resolveCredentials_transientFailureAfterSuccessfulCache_returnsCachedCredentials() {
// First: store token with expired credentials so it triggers refresh from service
AwsSessionCredentials creds = buildCredentials(Instant.now().minusSeconds(600));
LoginAccessToken token = buildAccessToken(creds);
tokenManager.storeToken(token);

// First response: successful refresh with short-lived credentials (expires in 30s)
// staleTime will be now+30s - 1min = now-30s (already stale), so next get() will refresh again
String shortLivedJsonBody =
"{\"accessToken\":"
+ "{\"accessKeyId\":\"new-akid\","
+ "\"secretAccessKey\":\"new-skid\","
+ "\"sessionToken\":\"new-session-token\"},"
+ "\"tokenType\":\"aws_sigv4\","
+ "\"expiresIn\":30,"
+ "\"refreshToken\":\"new-refresh-token\"}";

HttpExecuteResponse successResponse = HttpExecuteResponse
.builder()
.response(SdkHttpResponse.builder().statusCode(200).build())
.responseBody(AbortableInputStream.create(
new ByteArrayInputStream(shortLivedJsonBody.getBytes(StandardCharsets.UTF_8))))
.build();

// Second response: transient 500 error
HttpExecuteResponse failureResponse = HttpExecuteResponse
.builder()
.response(SdkHttpResponse.builder().statusCode(500).build())
.build();

mockHttpClient.stubResponses(successResponse, failureResponse);

// First call: succeeds and populates the CachedSupplier cache
AwsCredentials firstResolve = loginCredentialsProvider.resolveCredentials();
assertEquals("new-akid", firstResolve.accessKeyId());

// Second call: the cached value is already stale (30s expiry - 1min staleTime < now),
// so CachedSupplier tries to refresh, gets 500, and with ALLOW behavior returns cached value
AwsCredentials secondResolve = loginCredentialsProvider.resolveCredentials();
assertEquals("new-akid", secondResolve.accessKeyId());
assertEquals("new-skid", secondResolve.secretAccessKey());
}

@Test
public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithTokenExpired_raisesException() {
// expired
Expand All @@ -203,8 +248,9 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithTokenE
tokenManager.storeToken(token);

stubAccessDeniedException(OAuth2ErrorCode.TOKEN_EXPIRED);
SdkClientException e = assertThrows(SdkClientException.class, () -> loginCredentialsProvider.resolveCredentials());
assertTrue(e.getMessage().contains("Your session has expired"));
AccessDeniedException e = assertThrows(AccessDeniedException.class,
() -> loginCredentialsProvider.resolveCredentials());
assertNotNull(e);
}

@Test
Expand All @@ -215,8 +261,9 @@ public void resolveCredentials_whenCredentialsExpired_serviceCallFailsWithUserEx
tokenManager.storeToken(token);

stubAccessDeniedException(OAuth2ErrorCode.USER_CREDENTIALS_CHANGED);
SdkClientException e = assertThrows(SdkClientException.class, () -> loginCredentialsProvider.resolveCredentials());
assertTrue(e.getMessage().contains("change in your password"));
AccessDeniedException e = assertThrows(AccessDeniedException.class,
() -> loginCredentialsProvider.resolveCredentials());
assertNotNull(e);
}

@Test
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@
import software.amazon.awssdk.services.sso.internal.SessionCredentialsHolder;
import software.amazon.awssdk.services.sso.model.GetRoleCredentialsRequest;
import software.amazon.awssdk.services.sso.model.RoleCredentials;
import software.amazon.awssdk.services.sso.model.UnauthorizedException;
import software.amazon.awssdk.utils.SdkAutoCloseable;
import software.amazon.awssdk.utils.StringUtils;
import software.amazon.awssdk.utils.builder.CopyableBuilder;
Expand Down Expand Up @@ -90,7 +91,10 @@ private SsoCredentialsProvider(BuilderImpl builder) {
this.asyncCredentialUpdateEnabled = builder.asyncCredentialUpdateEnabled;
CachedSupplier.Builder<SessionCredentialsHolder> cacheBuilder =
CachedSupplier.builder(this::updateSsoCredentials)
.cachedValueName(toString());
.cachedValueName(toString())
.staleValueBehavior(CachedSupplier.StaleValueBehavior.ALLOW)
.cacheInvalidatingPredicate(
e -> e instanceof ExpiredTokenException || e instanceof UnauthorizedException);
if (builder.asyncCredentialUpdateEnabled) {
cacheBuilder.prefetchStrategy(new NonBlocking(ASYNC_THREAD_NAME));
}
Expand All @@ -115,6 +119,7 @@ private RefreshResult<SessionCredentialsHolder> updateSsoCredentials() {
private SessionCredentialsHolder getUpdatedCredentials(SsoClient ssoClient) {
GetRoleCredentialsRequest request = getRoleCredentialsRequestSupplier.get();
notNull(request, "GetRoleCredentialsRequest can't be null.");

RoleCredentials roleCredentials = ssoClient.getRoleCredentials(request).roleCredentials();
AwsSessionCredentials sessionCredentials = AwsSessionCredentials.builder()
.accessKeyId(roleCredentials.accessKeyId())
Expand Down
Loading
Loading