diff --git a/.changes/next-release/bugfix-AWSSDKforJavav2-0f86113.json b/.changes/next-release/bugfix-AWSSDKforJavav2-0f86113.json new file mode 100644 index 000000000000..c3f3e2878445 --- /dev/null +++ b/.changes/next-release/bugfix-AWSSDKforJavav2-0f86113.json @@ -0,0 +1,6 @@ +{ + "type": "bugfix", + "category": "AWS SDK for Java v2", + "contributor": "", + "description": "Re-resolve SSO access token on each credential refresh in the SSOCredentialsProvider instead of caching it at construction time ensuring that refreshed tokens (for example from running `aws sso login`) are always used." +} diff --git a/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/ExpiredTokenException.java b/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/ExpiredTokenException.java index d8d6ad63785d..4c72d9d45db0 100644 --- a/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/ExpiredTokenException.java +++ b/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/ExpiredTokenException.java @@ -31,6 +31,10 @@ @SdkPublicApi public final class ExpiredTokenException extends SdkClientException { + public static final String DEFAULT_MESSAGE = + "The SSO session associated with this profile has expired or is otherwise invalid." + + " To refresh this SSO session run aws sso login with the corresponding profile."; + private static final List> SDK_FIELDS = Collections.unmodifiableList(Arrays.asList()); private ExpiredTokenException(Builder b) { @@ -88,6 +92,9 @@ public BuilderImpl writableStackTrace(Boolean writableStackTrace) { @Override public ExpiredTokenException build() { + if (this.message == null) { + this.message = DEFAULT_MESSAGE; + } return new ExpiredTokenException(this); } 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 40a95b35f9ce..4204c8559d12 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 @@ -32,6 +32,7 @@ import software.amazon.awssdk.auth.token.credentials.SdkToken; 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.profiles.Profile; import software.amazon.awssdk.profiles.ProfileFile; import software.amazon.awssdk.profiles.ProfileProperty; @@ -63,7 +64,7 @@ public class SsoProfileCredentialsProviderFactory implements ProfileCredentialsP */ @Override public AwsCredentialsProvider create(ProfileProviderCredentialsContext credentialsContext) { - return new SsoProfileCredentialsProvider(credentialsContext, sdkTokenProvider(credentialsContext)); + return new SsoProfileCredentialsProvider(credentialsContext, sdkTokenProvider(credentialsContext), null); } /** @@ -73,7 +74,18 @@ public AwsCredentialsProvider create(ProfileProviderCredentialsContext credentia @SdkTestInternalApi public AwsCredentialsProvider create(ProfileProviderCredentialsContext credentialsContext, SdkTokenProvider tokenProvider) { - return new SsoProfileCredentialsProvider(credentialsContext, tokenProvider); + return new SsoProfileCredentialsProvider(credentialsContext, tokenProvider, null); + } + + /** + * Alternative method to create the {@link SsoProfileCredentialsProvider} with a customized {@link SdkTokenProvider} + * and {@link SsoClient}. This method is only used for testing. + */ + @SdkTestInternalApi + public AwsCredentialsProvider create(ProfileProviderCredentialsContext credentialsContext, + SdkTokenProvider tokenProvider, + SsoClient ssoClient) { + return new SsoProfileCredentialsProvider(credentialsContext, tokenProvider, ssoClient); } /** @@ -87,30 +99,34 @@ private static final class SsoProfileCredentialsProvider implements AwsCredentia private final SsoCredentialsProvider credentialsProvider; private SsoProfileCredentialsProvider(ProfileProviderCredentialsContext credentialsContext, - SdkTokenProvider tokenProvider) { + SdkTokenProvider tokenProvider, + SsoClient ssoClient) { Profile profile = credentialsContext.profile(); String ssoAccountId = profile.properties().get(ProfileProperty.SSO_ACCOUNT_ID); String ssoRoleName = profile.properties().get(ProfileProperty.SSO_ROLE_NAME); String ssoRegion = regionFromProfileOrSession(profile, credentialsContext.profileFile()); - this.ssoClient = SsoClient.builder() - .credentialsProvider(AnonymousCredentialsProvider.create()) - .region(Region.of(ssoRegion)) - .build(); + this.ssoClient = ssoClient != null + ? ssoClient + : SsoClient.builder() + .credentialsProvider(AnonymousCredentialsProvider.create()) + .region(Region.of(ssoRegion)) + .build(); GetRoleCredentialsRequest request = GetRoleCredentialsRequest.builder() .accountId(ssoAccountId) .roleName(ssoRoleName) .build(); - SdkToken sdkToken = tokenProvider.resolveToken(); - Validate.paramNotNull(sdkToken, "Token provided by the TokenProvider is null"); - Supplier supplier = () -> request.toBuilder() - .accessToken(sdkToken.token()) - .build(); + Supplier supplier = () -> { + SdkToken token = resolveTokenOrThrow(tokenProvider); + return request.toBuilder() + .accessToken(token.token()) + .build(); + }; this.credentialsProvider = SsoCredentialsProvider.builder() - .ssoClient(ssoClient) + .ssoClient(this.ssoClient) .refreshRequest(supplier) .sourceChain(credentialsContext.sourceChain()) .build(); @@ -127,6 +143,23 @@ public void close() { IoUtils.closeQuietly(ssoClient, null); } + private static SdkToken resolveTokenOrThrow(SdkTokenProvider tokenProvider) { + SdkToken token; + try { + token = tokenProvider.resolveToken(); + } catch (ExpiredTokenException | SdkServiceException e) { + throw e; + } catch (RuntimeException e) { + throw ExpiredTokenException.builder() + .cause(e) + .build(); + } + if (token == null || token.token() == null) { + throw ExpiredTokenException.builder().build(); + } + return token; + } + private static String regionFromProfileOrSession(Profile profile, ProfileFile profileFile) { Optional ssoSession = profile.property(ProfileSection.SSO_SESSION.getPropertyKeyName()); String profileRegion = profile.properties().get(ProfileProperty.SSO_REGION); diff --git a/services/sso/src/main/java/software/amazon/awssdk/services/sso/internal/SsoAccessTokenProvider.java b/services/sso/src/main/java/software/amazon/awssdk/services/sso/internal/SsoAccessTokenProvider.java index 710e40d7c98b..5c732a3d2b0b 100644 --- a/services/sso/src/main/java/software/amazon/awssdk/services/sso/internal/SsoAccessTokenProvider.java +++ b/services/sso/src/main/java/software/amazon/awssdk/services/sso/internal/SsoAccessTokenProvider.java @@ -29,7 +29,6 @@ import software.amazon.awssdk.services.sso.auth.ExpiredTokenException; import software.amazon.awssdk.services.sso.auth.SsoCredentialsProvider; import software.amazon.awssdk.utils.IoUtils; -import software.amazon.awssdk.utils.Validate; /** * Resolve the access token from the cached token file. If the token has expired then throw out an exception to ask the users to @@ -48,7 +47,15 @@ public SsoAccessTokenProvider(Path cachedTokenFilePath) { @Override public SdkToken resolveToken() { - return tokenFromFile(); + try { + return tokenFromFile(); + } catch (ExpiredTokenException e) { + throw e; + } catch (Exception e) { + throw ExpiredTokenException.builder() + .cause(e) + .build(); + } } private SdkToken tokenFromFile() { @@ -61,25 +68,22 @@ private SdkToken tokenFromFile() { private SdkToken getTokenFromJson(String json) { JsonNode jsonNode = PARSER.parse(json); - String expiration = jsonNode.field("expiresAt").map(JsonNode::text).orElse(null); + String expirationStr = jsonNode.field("expiresAt").map(JsonNode::text).orElse(null); + + if (expirationStr == null) { + throw ExpiredTokenException.builder().build(); + } - Validate.notNull(expiration, - "The SSO session's expiration time could not be determined. Please refresh your SSO session."); + Instant expiration = Instant.parse(expirationStr); - if (tokenIsInvalid(expiration)) { - throw ExpiredTokenException.builder().message("The SSO session associated with this profile has expired or is" - + " otherwise invalid. To refresh this SSO session run aws sso" - + " login with the corresponding profile.").build(); + if (Instant.now().isAfter(expiration)) { + throw ExpiredTokenException.builder().build(); } return SsoAccessToken.builder() .accessToken(jsonNode.asObject().get("accessToken").text()) - .expiresAt(Instant.parse(expiration)).build(); - + .expiresAt(expiration).build(); } - private boolean tokenIsInvalid(String expirationTime) { - return Instant.now().isAfter(Instant.parse(expirationTime)); - } } 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 8da326bf589f..e1277e0a80f9 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 @@ -17,6 +17,8 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; @@ -24,6 +26,7 @@ import com.google.common.jimfs.Configuration; import com.google.common.jimfs.Jimfs; import java.io.IOException; +import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; import java.nio.file.FileSystem; import java.nio.file.Files; @@ -35,15 +38,23 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.Mockito; import org.mockito.junit.jupiter.MockitoExtension; import software.amazon.awssdk.auth.credentials.AwsCredentialsProvider; 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.profiles.ProfileFile; +import software.amazon.awssdk.services.sso.SsoClient; +import software.amazon.awssdk.services.sso.auth.ExpiredTokenException; import software.amazon.awssdk.services.sso.internal.SsoAccessToken; import software.amazon.awssdk.services.sso.internal.SsoAccessTokenProvider; +import software.amazon.awssdk.services.sso.model.GetRoleCredentialsRequest; +import software.amazon.awssdk.services.sso.model.GetRoleCredentialsResponse; +import software.amazon.awssdk.utils.SdkAutoCloseable; +import software.amazon.awssdk.services.sso.model.RoleCredentials; import software.amazon.awssdk.utils.StringInputStream; /** @@ -162,6 +173,8 @@ private static Stream ssoErrorValues() { @Test public void tokenResolvedFromTokenProvider(@Mock SdkTokenProvider sdkTokenProvider){ + SsoClient mockSsoClient = mock(SsoClient.class); + ProfileFile profileFile = configFile("[profile test]\n" + "sso_account_id=accountId\n" + "sso_role_name=roleName\n" + @@ -172,15 +185,569 @@ public void tokenResolvedFromTokenProvider(@Mock SdkTokenProvider sdkTokenProvid "sso_start_url=https//d-abc123.awsapps.com/start"); SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); when(sdkTokenProvider.resolveToken()).thenReturn(SsoAccessToken.builder().accessToken("sample").expiresAt(Instant.now()).build()); + + RoleCredentials roleCredentials = RoleCredentials.builder() + .accessKeyId("AKID") + .secretAccessKey("secret") + .sessionToken("session") + .expiration(Instant.now().minusSeconds(1).toEpochMilli()) + .build(); + when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class))) + .thenReturn(GetRoleCredentialsResponse.builder().roleCredentials(roleCredentials).build()); + AwsCredentialsProvider credentialsProvider = factory.create(ProfileProviderCredentialsContext.builder() .profile(profileFile.profile("test").get()) .profileFile(profileFile) - .build(), sdkTokenProvider); - try { + .build(), + sdkTokenProvider, + mockSsoClient); + credentialsProvider.resolveCredentials(); + credentialsProvider.resolveCredentials(); + + Mockito.verify(sdkTokenProvider, times(2)).resolveToken(); + } + + @Test + public void missingSsoSessionSection_throwsSsoSessionNotFound() { + ProfileFile profileFile = configFile("[profile test]\n" + + "sso_account_id=accountId\n" + + "sso_role_name=roleName\n" + + "sso_session=nonexistent\n" + + "[sso-session bar]\n" + + "sso_start_url=https//d-abc123.awsapps.com/start\n" + + "sso_region=region"); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + assertThatThrownBy(() -> factory.create(ProfileProviderCredentialsContext.builder() + .profileFile(profileFile) + .profile(profileFile.profile("test").get()) + .build())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Sso-session section not found with sso-session title nonexistent."); + } + + + @Test + public void missingSsoRegionInSsoSession_throwsValidationError() { + ProfileFile profileFile = configFile("[profile test]\n" + + "sso_account_id=accountId\n" + + "sso_role_name=roleName\n" + + "sso_session=foo\n" + + "[sso-session foo]\n" + + "sso_start_url=https//d-abc123.awsapps.com/start"); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + assertThatThrownBy(() -> factory.create(ProfileProviderCredentialsContext.builder() + .profileFile(profileFile) + .profile(profileFile.profile("test").get()) + .build())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("'sso_region' must be set to use role-based credential loading in the 'foo' profile."); + } + + @Test + public void ssoStartUrlMismatch_throwsValidationError() { + ProfileFile profileFile = configFile("[profile test]\n" + + "sso_account_id=accountId\n" + + "sso_role_name=roleName\n" + + "sso_start_url=https//d-abc123.awsapps.com/startProfile\n" + + "sso_session=foo\n" + + "[sso-session foo]\n" + + "sso_region=region\n" + + "sso_start_url=https//d-abc123.awsapps.com/startSession"); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + assertThatThrownBy(() -> factory.create(ProfileProviderCredentialsContext.builder() + .profileFile(profileFile) + .profile(profileFile.profile("test").get()) + .build())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Profile test and Sso-session foo has different sso_start_url."); + } + + @Test + public void ssoRegionMismatch_throwsValidationError() { + ProfileFile profileFile = configFile("[profile test]\n" + + "sso_account_id=accountId\n" + + "sso_role_name=roleName\n" + + "sso_region=us-east-1\n" + + "sso_session=foo\n" + + "[sso-session foo]\n" + + "sso_region=us-west-2\n" + + "sso_start_url=https//d-abc123.awsapps.com/start"); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + assertThatThrownBy(() -> factory.create(ProfileProviderCredentialsContext.builder() + .profileFile(profileFile) + .profile(profileFile.profile("test").get()) + .build())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Profile test and Sso-session foo has different sso_region."); + } + + @Test + public void validProfileWithTokenProvider_createsProviderSuccessfully() { + ProfileFile profileFile = configFile("[profile test]\n" + + "sso_account_id=accountId\n" + + "sso_role_name=roleName\n" + + "sso_region=us-east-1\n" + + "sso_session=foo\n" + + "[sso-session foo]\n" + + "sso_region=us-east-1\n" + + "sso_start_url=https//d-abc123.awsapps.com/start"); + + lenient().when(sdkTokenProvider.resolveToken()).thenReturn( + SsoAccessToken.builder().accessToken("valid-token").expiresAt(Instant.now().plusSeconds(3600)).build()); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + AwsCredentialsProvider credentialsProvider = factory.create( + ProfileProviderCredentialsContext.builder() + .profile(profileFile.profile("test").get()) + .profileFile(profileFile) + .build(), + sdkTokenProvider); + + assertThat(credentialsProvider).isNotNull(); + assertThat(credentialsProvider).isInstanceOf(AwsCredentialsProvider.class); + } + + @Test + public void tokenIsReResolvedOnEachCredentialRefresh() { + int numberOfRefreshCalls = 3; + SsoClient mockSsoClient = mock(SsoClient.class); + + ProfileFile profileFile = configFile("[profile test]\n" + + "sso_account_id=accountId\n" + + "sso_role_name=roleName\n" + + "sso_region=region\n" + + "sso_session=foo\n" + + "[sso-session foo]\n" + + "sso_region=region\n" + + "sso_start_url=https//d-abc123.awsapps.com/start"); + + when(sdkTokenProvider.resolveToken()).thenReturn( + SsoAccessToken.builder().accessToken("token-1").expiresAt(Instant.now().plusSeconds(3600)).build(), + SsoAccessToken.builder().accessToken("token-2").expiresAt(Instant.now().plusSeconds(3600)).build(), + SsoAccessToken.builder().accessToken("token-3").expiresAt(Instant.now().plusSeconds(3600)).build(), + SsoAccessToken.builder().accessToken("token-4").expiresAt(Instant.now().plusSeconds(3600)).build() + ); + + RoleCredentials roleCredentials = RoleCredentials.builder() + .accessKeyId("AKID") + .secretAccessKey("secret") + .sessionToken("session") + .expiration(Instant.now().minusSeconds(1).toEpochMilli()) + .build(); + when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class))) + .thenReturn(GetRoleCredentialsResponse.builder().roleCredentials(roleCredentials).build()); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + AwsCredentialsProvider credentialsProvider = factory.create( + ProfileProviderCredentialsContext.builder() + .profile(profileFile.profile("test").get()) + .profileFile(profileFile) + .build(), + sdkTokenProvider, + mockSsoClient); + + for (int i = 0; i < numberOfRefreshCalls; i++) { + credentialsProvider.resolveCredentials(); + } + + Mockito.verify(sdkTokenProvider, Mockito.atLeast(numberOfRefreshCalls)).resolveToken(); + } + + @Test + public void ssoSessionPath_eachRefreshUsesLatestToken() { + SsoClient mockSsoClient = mock(SsoClient.class); + SdkTokenProvider mockTokenProvider = mock(SdkTokenProvider.class); + + ProfileFile profileFile = configFile("[profile test]\n" + + "sso_account_id=123456789\n" + + "sso_role_name=TestRole\n" + + "sso_region=us-east-1\n" + + "sso_session=foo\n" + + "[sso-session foo]\n" + + "sso_region=us-east-1\n" + + "sso_start_url=https//d-abc123.awsapps.com/start"); + + when(mockTokenProvider.resolveToken()).thenReturn( + SsoAccessToken.builder().accessToken("token-A").expiresAt(Instant.now().plusSeconds(3600)).build(), + SsoAccessToken.builder().accessToken("token-B").expiresAt(Instant.now().plusSeconds(3600)).build(), + SsoAccessToken.builder().accessToken("token-C").expiresAt(Instant.now().plusSeconds(3600)).build() + ); + + RoleCredentials roleCredentials = RoleCredentials.builder() + .accessKeyId("AKID") + .secretAccessKey("secret") + .sessionToken("session") + .expiration(Instant.now().minusSeconds(1).toEpochMilli()) + .build(); + when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class))) + .thenReturn(GetRoleCredentialsResponse.builder().roleCredentials(roleCredentials).build()); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + AwsCredentialsProvider credentialsProvider = factory.create( + ProfileProviderCredentialsContext.builder() + .profile(profileFile.profile("test").get()) + .profileFile(profileFile) + .build(), + mockTokenProvider, + mockSsoClient); + + credentialsProvider.resolveCredentials(); + credentialsProvider.resolveCredentials(); + credentialsProvider.resolveCredentials(); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(GetRoleCredentialsRequest.class); + Mockito.verify(mockSsoClient, Mockito.atLeast(3)).getRoleCredentials(requestCaptor.capture()); + + assertThat(requestCaptor.getAllValues().get(0).accessToken()).isEqualTo("token-A"); + assertThat(requestCaptor.getAllValues().get(1).accessToken()).isEqualTo("token-B"); + assertThat(requestCaptor.getAllValues().get(2).accessToken()).isEqualTo("token-C"); + } + + @Test + public void legacyPath_tokenReReadFromDiskOnEachRefresh() { + SsoClient mockSsoClient = mock(SsoClient.class); + SdkTokenProvider mockTokenProvider = mock(SdkTokenProvider.class); + + ProfileFile profileFile = configFile("[profile test]\n" + + "sso_account_id=987654321\n" + + "sso_role_name=LegacyRole\n" + + "sso_region=us-west-2\n" + + "sso_start_url=https//d-abc123.awsapps.com/start"); + + when(mockTokenProvider.resolveToken()).thenReturn( + SsoAccessToken.builder().accessToken("disk-token-1").expiresAt(Instant.now().plusSeconds(3600)).build(), + SsoAccessToken.builder().accessToken("disk-token-2").expiresAt(Instant.now().plusSeconds(3600)).build() + ); + + RoleCredentials roleCredentials = RoleCredentials.builder() + .accessKeyId("AKID") + .secretAccessKey("secret") + .sessionToken("session") + .expiration(Instant.now().minusSeconds(1).toEpochMilli()) + .build(); + when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class))) + .thenReturn(GetRoleCredentialsResponse.builder().roleCredentials(roleCredentials).build()); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + AwsCredentialsProvider credentialsProvider = factory.create( + ProfileProviderCredentialsContext.builder() + .profile(profileFile.profile("test").get()) + .profileFile(profileFile) + .build(), + mockTokenProvider, + mockSsoClient); + + credentialsProvider.resolveCredentials(); + credentialsProvider.resolveCredentials(); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(GetRoleCredentialsRequest.class); + Mockito.verify(mockSsoClient, times(2)).getRoleCredentials(requestCaptor.capture()); + + assertThat(requestCaptor.getAllValues().get(0).accessToken()).isEqualTo("disk-token-1"); + assertThat(requestCaptor.getAllValues().get(1).accessToken()).isEqualTo("disk-token-2"); + Mockito.verify(mockTokenProvider, times(2)).resolveToken(); + } + + @Test + public void errorPropagation_resolveTokenThrowsUncheckedIOException_propagatesToCaller() { + ProfileFile profileFile = configFile("[profile test]\n" + + "sso_account_id=accountId\n" + + "sso_role_name=roleName\n" + + "sso_region=region\n" + + "sso_session=foo\n" + + "[sso-session foo]\n" + + "sso_region=region\n" + + "sso_start_url=https//d-abc123.awsapps.com/start"); + + UncheckedIOException ioException = new UncheckedIOException(new IOException("Token file not found")); + when(sdkTokenProvider.resolveToken()).thenThrow(ioException); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + AwsCredentialsProvider credentialsProvider = factory.create( + ProfileProviderCredentialsContext.builder() + .profile(profileFile.profile("test").get()) + .profileFile(profileFile) + .build(), + sdkTokenProvider); + + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(ExpiredTokenException.class) + .hasMessageContaining("expired or is otherwise invalid") + .hasCauseInstanceOf(UncheckedIOException.class); + } + + @Test + public void errorPropagation_resolveTokenThrowsRuntimeException_propagatesToCaller() { + ProfileFile profileFile = configFile("[profile test]\n" + + "sso_account_id=accountId\n" + + "sso_role_name=roleName\n" + + "sso_region=region\n" + + "sso_session=foo\n" + + "[sso-session foo]\n" + + "sso_region=region\n" + + "sso_start_url=https//d-abc123.awsapps.com/start"); + + RuntimeException tokenExpired = new RuntimeException("Token is expired"); + when(sdkTokenProvider.resolveToken()).thenThrow(tokenExpired); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + AwsCredentialsProvider credentialsProvider = factory.create( + ProfileProviderCredentialsContext.builder() + .profile(profileFile.profile("test").get()) + .profileFile(profileFile) + .build(), + sdkTokenProvider); + + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(ExpiredTokenException.class) + .hasMessageContaining("expired or is otherwise invalid") + .hasCauseInstanceOf(RuntimeException.class); + } + + @Test + public void errorPropagation_resolveTokenReturnsNullTokenValue_errorPropagates() { + ProfileFile profileFile = configFile("[profile test]\n" + + "sso_account_id=accountId\n" + + "sso_role_name=roleName\n" + + "sso_region=region\n" + + "sso_session=foo\n" + + "[sso-session foo]\n" + + "sso_region=region\n" + + "sso_start_url=https//d-abc123.awsapps.com/start"); + + // Mock SdkToken to return null from token() + SdkToken nullToken = mock(SdkToken.class); + when(nullToken.token()).thenReturn(null); + when(sdkTokenProvider.resolveToken()).thenReturn(nullToken); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + AwsCredentialsProvider credentialsProvider = factory.create( + ProfileProviderCredentialsContext.builder() + .profile(profileFile.profile("test").get()) + .profileFile(profileFile) + .build(), + sdkTokenProvider); + + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(ExpiredTokenException.class) + .hasMessageContaining("expired or is otherwise invalid"); + } + + @Test + public void tokenProviderThrowsOnSecondCall_staleCachedCredentialsReturned() { + SsoClient mockSsoClient = mock(SsoClient.class); + + ProfileFile profileFile = configFile("[profile test]\n" + + "sso_account_id=accountId\n" + + "sso_role_name=roleName\n" + + "sso_region=region\n" + + "sso_session=foo\n" + + "[sso-session foo]\n" + + "sso_region=region\n" + + "sso_start_url=https//d-abc123.awsapps.com/start"); + + RoleCredentials roleCredentials = RoleCredentials.builder() + .accessKeyId("AKID") + .secretAccessKey("secret") + .sessionToken("session") + .expiration(Instant.now().minusSeconds(1).toEpochMilli()) + .build(); + when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class))) + .thenReturn(GetRoleCredentialsResponse.builder().roleCredentials(roleCredentials).build()); + + RuntimeException secondCallError = new RuntimeException("Token refresh failed on second attempt"); + when(sdkTokenProvider.resolveToken()) + .thenReturn(SsoAccessToken.builder().accessToken("valid-token").expiresAt(Instant.now().plusSeconds(3600)).build()) + .thenThrow(secondCallError); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + AwsCredentialsProvider credentialsProvider = factory.create( + ProfileProviderCredentialsContext.builder() + .profile(profileFile.profile("test").get()) + .profileFile(profileFile) + .build(), + sdkTokenProvider, + mockSsoClient); + + // First call succeeds and caches credentials + credentialsProvider.resolveCredentials(); + + // Second call: token provider throws InvalidTokenException, but CachedSupplier with + // StaleValueBehavior.ALLOW returns stale cached credentials (static stability) + assertThat(credentialsProvider.resolveCredentials()).isNotNull(); + } + + @Test + public void fiveSequentialRefreshes_eachUsesCorrectToken() { + SsoClient mockSsoClient = mock(SsoClient.class); + SdkTokenProvider mockTokenProvider = mock(SdkTokenProvider.class); + + ProfileFile profileFile = configFile("[profile test]\n" + + "sso_account_id=111222333\n" + + "sso_role_name=MultiRefreshRole\n" + + "sso_region=us-east-1\n" + + "sso_session=foo\n" + + "[sso-session foo]\n" + + "sso_region=us-east-1\n" + + "sso_start_url=https//d-abc123.awsapps.com/start"); + + when(mockTokenProvider.resolveToken()).thenReturn( + SsoAccessToken.builder().accessToken("token-1").expiresAt(Instant.now().plusSeconds(3600)).build(), + SsoAccessToken.builder().accessToken("token-2").expiresAt(Instant.now().plusSeconds(3600)).build(), + SsoAccessToken.builder().accessToken("token-3").expiresAt(Instant.now().plusSeconds(3600)).build(), + SsoAccessToken.builder().accessToken("token-4").expiresAt(Instant.now().plusSeconds(3600)).build(), + SsoAccessToken.builder().accessToken("token-5").expiresAt(Instant.now().plusSeconds(3600)).build() + ); + + RoleCredentials roleCredentials = RoleCredentials.builder() + .accessKeyId("AKID") + .secretAccessKey("secret") + .sessionToken("session") + .expiration(Instant.now().minusSeconds(1).toEpochMilli()) + .build(); + when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class))) + .thenReturn(GetRoleCredentialsResponse.builder().roleCredentials(roleCredentials).build()); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + AwsCredentialsProvider credentialsProvider = factory.create( + ProfileProviderCredentialsContext.builder() + .profile(profileFile.profile("test").get()) + .profileFile(profileFile) + .build(), + mockTokenProvider, + mockSsoClient); + + for (int i = 0; i < 5; i++) { credentialsProvider.resolveCredentials(); - } catch (Exception e) { - // sso client created internally which cannot be mocked. } - Mockito.verify(sdkTokenProvider, times(1)).resolveToken(); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(GetRoleCredentialsRequest.class); + Mockito.verify(mockSsoClient, times(5)).getRoleCredentials(requestCaptor.capture()); + + assertThat(requestCaptor.getAllValues().get(0).accessToken()).isEqualTo("token-1"); + assertThat(requestCaptor.getAllValues().get(1).accessToken()).isEqualTo("token-2"); + assertThat(requestCaptor.getAllValues().get(2).accessToken()).isEqualTo("token-3"); + assertThat(requestCaptor.getAllValues().get(3).accessToken()).isEqualTo("token-4"); + assertThat(requestCaptor.getAllValues().get(4).accessToken()).isEqualTo("token-5"); + Mockito.verify(mockTokenProvider, times(5)).resolveToken(); + } + + @Test + public void profileWithSsoSessionPointingToMissingSection_throwsWithSessionName() { + ProfileFile profileFile = configFile("[profile test]\n" + + "sso_account_id=accountId\n" + + "sso_role_name=roleName\n" + + "sso_session=my-missing-session"); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + assertThatThrownBy(() -> factory.create(ProfileProviderCredentialsContext.builder() + .profileFile(profileFile) + .profile(profileFile.profile("test").get()) + .build())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("Sso-session section not found with sso-session title my-missing-session."); + } + + + @Test + public void close_cleansUpResourcesWithoutException() { + ProfileFile profileFile = configFile("[profile test]\n" + + "sso_account_id=accountId\n" + + "sso_role_name=roleName\n" + + "sso_region=region\n" + + "sso_session=foo\n" + + "[sso-session foo]\n" + + "sso_region=region\n" + + "sso_start_url=https//d-abc123.awsapps.com/start"); + + lenient().when(sdkTokenProvider.resolveToken()).thenReturn( + SsoAccessToken.builder().accessToken("close-test-token").expiresAt(Instant.now().plusSeconds(3600)).build()); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + AwsCredentialsProvider credentialsProvider = factory.create( + ProfileProviderCredentialsContext.builder() + .profile(profileFile.profile("test").get()) + .profileFile(profileFile) + .build(), + sdkTokenProvider); + + // Verify the returned provider implements SdkAutoCloseable + assertThat(credentialsProvider).isInstanceOf(SdkAutoCloseable.class); + + // Calling close() should not throw any exceptions + ((SdkAutoCloseable) credentialsProvider).close(); + } + + @Test + public void close_calledMultipleTimes_doesNotThrow() { + ProfileFile profileFile = configFile("[profile test]\n" + + "sso_account_id=accountId\n" + + "sso_role_name=roleName\n" + + "sso_region=region\n" + + "sso_session=foo\n" + + "[sso-session foo]\n" + + "sso_region=region\n" + + "sso_start_url=https//d-abc123.awsapps.com/start"); + + lenient().when(sdkTokenProvider.resolveToken()).thenReturn( + SsoAccessToken.builder().accessToken("close-test-token").expiresAt(Instant.now().plusSeconds(3600)).build()); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + AwsCredentialsProvider credentialsProvider = factory.create( + ProfileProviderCredentialsContext.builder() + .profile(profileFile.profile("test").get()) + .profileFile(profileFile) + .build(), + sdkTokenProvider); + + SdkAutoCloseable closeable = (SdkAutoCloseable) credentialsProvider; + + // First close + closeable.close(); + // Second close - should not throw + closeable.close(); + } + + @Test + public void factoryCreateWithLegacyProfile_constructsProviderSuccessfully() throws IOException { + String startUrl = "https//d-abc123.awsapps.com/start"; + String generatedTokenFileName = "6a888bdb653a4ba345dd68f21b896ec2e218c6f4.json"; + + ProfileFile profileFile = configFile("[profile foo]\n" + + "sso_account_id=accountId\n" + + "sso_region=region\n" + + "sso_role_name=roleName\n" + + "sso_start_url=https//d-abc123.awsapps.com/start"); + + String tokenFile = "{\n" + + "\"accessToken\": \"base64string\",\n" + + "\"expiresAt\": \"2090-01-01T00:00:00Z\",\n" + + "\"region\": \"us-west-2\", \n" + + "\"startUrl\": \"" + startUrl + "\"\n" + + "}"; + Path cachedTokenFilePath = prepareTestCachedTokenFile(tokenFile, generatedTokenFileName); + SsoAccessTokenProvider tokenProvider = new SsoAccessTokenProvider(cachedTokenFilePath); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + AwsCredentialsProvider credentialsProvider = factory.create( + ProfileProviderCredentialsContext.builder() + .profile(profileFile.profile("foo").get()) + .profileFile(profileFile) + .build(), + tokenProvider); + + assertThat(credentialsProvider).isNotNull(); + assertThat(credentialsProvider).isInstanceOf(AwsCredentialsProvider.class); + assertThat(credentialsProvider).isInstanceOf(SdkAutoCloseable.class); + + // Verify close works properly on the fully-constructed provider + ((SdkAutoCloseable) credentialsProvider).close(); } } \ No newline at end of file diff --git a/services/sso/src/test/java/software/amazon/awssdk/services/sso/internal/SsoAccessTokenProviderTest.java b/services/sso/src/test/java/software/amazon/awssdk/services/sso/internal/SsoAccessTokenProviderTest.java index b4a58cc081f8..265737a98a98 100644 --- a/services/sso/src/test/java/software/amazon/awssdk/services/sso/internal/SsoAccessTokenProviderTest.java +++ b/services/sso/src/test/java/software/amazon/awssdk/services/sso/internal/SsoAccessTokenProviderTest.java @@ -65,11 +65,13 @@ void cachedTokenFile_accessTokenMissing_throwNullPointerException() throws IOExc "}"; SsoAccessTokenProvider provider = new SsoAccessTokenProvider( prepareTestCachedTokenFile(tokenFile, GENERATED_TOKEN_FILE_NAME)); - assertThatThrownBy(() -> provider.resolveToken().token()).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> provider.resolveToken().token()) + .hasMessageContaining("expired or is otherwise invalid") + .hasCauseInstanceOf(NullPointerException.class); } @Test - void cachedTokenFile_expiresAtMissing_throwNullPointerException() throws IOException { + void cachedTokenFile_expiresAtMissing_throwsExpiredTokenException() throws IOException { String tokenFile = "{\n" + "\"accessToken\": \"base64string\",\n" + "\"region\": \"us-west-2\", \n" + @@ -78,7 +80,8 @@ void cachedTokenFile_expiresAtMissing_throwNullPointerException() throws IOExcep SsoAccessTokenProvider provider = new SsoAccessTokenProvider( prepareTestCachedTokenFile(tokenFile, GENERATED_TOKEN_FILE_NAME)); - assertThatThrownBy(() -> provider.resolveToken().token()).isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> provider.resolveToken().token()) + .hasMessageContaining("expired or is otherwise invalid"); } @Test @@ -128,7 +131,9 @@ void cachedTokenFile_tokenFileNotExist_throwNullPointerException() throws IOExce prepareTestCachedTokenFile(tokenFile, WRONG_TOKEN_FILE_NAME); SsoAccessTokenProvider provider = new SsoAccessTokenProvider(createTestCachedTokenFilePath( Jimfs.newFileSystem(Configuration.unix()).getPath("./foo"), GENERATED_TOKEN_FILE_NAME)); - assertThatThrownBy(() -> provider.resolveToken().token()).isInstanceOf(UncheckedIOException.class); + assertThatThrownBy(() -> provider.resolveToken().token()) + .hasMessageContaining("expired or is otherwise invalid") + .hasCauseInstanceOf(UncheckedIOException.class); } @Test