From e35f5fa8de603ad024b8faccb5fa960027cc7663 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Thu, 2 Jul 2026 12:27:35 -0700 Subject: [PATCH 1/4] Re-resolve SSO access token on each credential refresh instead of caching it at construction time --- .../bugfix-AWSSDKforJavav2-0f86113.json | 6 + .../SsoProfileCredentialsProviderFactory.java | 11 +- ...ProfileCredentialsProviderFactoryTest.java | 614 +++++++++++++++++- 3 files changed, 624 insertions(+), 7 deletions(-) create mode 100644 .changes/next-release/bugfix-AWSSDKforJavav2-0f86113.json 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/SsoProfileCredentialsProviderFactory.java b/services/sso/src/main/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactory.java index 40a95b35f9ce..a67c98359b8a 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 @@ -102,11 +102,12 @@ private SsoProfileCredentialsProvider(ProfileProviderCredentialsContext credenti .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 = tokenProvider.resolveToken(); + return request.toBuilder() + .accessToken(token.token()) + .build(); + }; this.credentialsProvider = SsoCredentialsProvider.builder() 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..859f988b45b9 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,26 +26,35 @@ 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; import java.nio.file.Path; import java.time.Instant; +import java.util.function.Supplier; import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.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.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; /** @@ -176,11 +187,610 @@ public void tokenResolvedFromTokenProvider(@Mock SdkTokenProvider sdkTokenProvid .profile(profileFile.profile("test").get()) .profileFile(profileFile) .build(), sdkTokenProvider); + // Call resolveCredentials() twice to verify token is re-resolved on each call + for (int i = 0; i < 2; i++) { + try { + credentialsProvider.resolveCredentials(); + } catch (Exception e) { + // sso client created internally which cannot be mocked. + } + } + // The first call triggers the supplier (which calls resolveToken()), and the second call + // also triggers the supplier since credentials from the first call expired immediately. + 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; + + 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() + ); + + SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); + AwsCredentialsProvider credentialsProvider = factory.create( + ProfileProviderCredentialsContext.builder() + .profile(profileFile.profile("test").get()) + .profileFile(profileFile) + .build(), + sdkTokenProvider); + + // Call resolveCredentials() multiple times to trigger the supplier + for (int i = 0; i < numberOfRefreshCalls; i++) { + try { + credentialsProvider.resolveCredentials(); + } catch (Exception e) { + // Expected: SsoClient created internally cannot reach the SSO service. + // The supplier IS still invoked before the SsoClient call fails. + } + } + + Mockito.verify(sdkTokenProvider, Mockito.atLeast(numberOfRefreshCalls)).resolveToken(); + } + + @Test + public void ssoSessionPath_eachRefreshUsesLatestToken() { + SsoClient mockSsoClient = mock(SsoClient.class); + SdkTokenProvider mockTokenProvider = mock(SdkTokenProvider.class); + + // Token provider returns different tokens on successive calls + 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() + ); + + // Set up GetRoleCredentialsResponse with short-lived expiration to force re-fetch each time + RoleCredentials roleCredentials = RoleCredentials.builder() + .accessKeyId("AKID") + .secretAccessKey("secret") + .sessionToken("session") + .expiration(Instant.now().minusSeconds(1).toEpochMilli()) + .build(); + GetRoleCredentialsResponse response = GetRoleCredentialsResponse.builder() + .roleCredentials(roleCredentials) + .build(); + when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class))).thenReturn(response); + + // Build supplier using the same pattern as the fixed SsoProfileCredentialsProvider + GetRoleCredentialsRequest baseRequest = GetRoleCredentialsRequest.builder() + .accountId("123456789") + .roleName("TestRole") + .build(); + Supplier supplier = () -> { + SdkToken token = mockTokenProvider.resolveToken(); + return baseRequest.toBuilder() + .accessToken(token.token()) + .build(); + }; + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .ssoClient(mockSsoClient) + .refreshRequest(supplier) + .build()) { + // Call resolveCredentials() three times + credentialsProvider.resolveCredentials(); + credentialsProvider.resolveCredentials(); + credentialsProvider.resolveCredentials(); + } + + // Capture the requests sent to SsoClient + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(GetRoleCredentialsRequest.class); + Mockito.verify(mockSsoClient, Mockito.atLeast(3)).getRoleCredentials(requestCaptor.capture()); + + // Verify each call used a different (latest) token + 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); + + // Simulate SsoAccessTokenProvider behavior: returns different tokens on successive calls + // (as if the token file on disk has been updated between calls) + 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() + ); + + // Set up GetRoleCredentialsResponse with expired credentials to force re-fetch + RoleCredentials roleCredentials = RoleCredentials.builder() + .accessKeyId("AKID") + .secretAccessKey("secret") + .sessionToken("session") + .expiration(Instant.now().minusSeconds(1).toEpochMilli()) + .build(); + GetRoleCredentialsResponse response = GetRoleCredentialsResponse.builder() + .roleCredentials(roleCredentials) + .build(); + when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class))).thenReturn(response); + + // Build supplier mimicking the legacy path: reads token from provider on each call + GetRoleCredentialsRequest baseRequest = GetRoleCredentialsRequest.builder() + .accountId("987654321") + .roleName("LegacyRole") + .build(); + Supplier supplier = () -> { + SdkToken token = mockTokenProvider.resolveToken(); + return baseRequest.toBuilder() + .accessToken(token.token()) + .build(); + }; + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .ssoClient(mockSsoClient) + .refreshRequest(supplier) + .build()) { + // First call uses "disk-token-1" + credentialsProvider.resolveCredentials(); + // Second call (after simulated disk update) uses "disk-token-2" + credentialsProvider.resolveCredentials(); + } + + // Capture and verify the requests + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(GetRoleCredentialsRequest.class); + Mockito.verify(mockSsoClient, times(2)).getRoleCredentials(requestCaptor.capture()); + + // Verify the first call used the first token and second call used the updated token + assertThat(requestCaptor.getAllValues().get(0).accessToken()).isEqualTo("disk-token-1"); + assertThat(requestCaptor.getAllValues().get(1).accessToken()).isEqualTo("disk-token-2"); + + // Also verify the token provider was called exactly twice (once per refresh) + 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) + .isInstanceOfAny(UncheckedIOException.class, RuntimeException.class) + .satisfies(thrown -> { + // The error must surface - either directly or wrapped + if (thrown instanceof UncheckedIOException) { + assertThat(thrown.getCause().getMessage()).contains("Token file not found"); + } else { + // May be wrapped in another exception; verify the root cause is present + assertThat(thrown).hasRootCauseMessage("Token file not found"); + } + }); + } + + @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(RuntimeException.class) + .satisfies(thrown -> { + // The error must surface - verify the original message is reachable + boolean messageFound = false; + Throwable current = thrown; + while (current != null) { + if (current.getMessage() != null && current.getMessage().contains("Token is expired")) { + messageFound = true; + break; + } + current = current.getCause(); + } + assertThat(messageFound) + .as("Expected 'Token is expired' somewhere in the exception chain") + .isTrue(); + }); + } + + @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); + + // The null token value should cause an error when resolveCredentials is called. + // This may manifest as NullPointerException, SdkClientException, or similar. + // The critical assertion is that the error is NOT silently swallowed. + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(Exception.class); + } + + @Test + public void errorPropagation_tokenProviderThrowsOnSecondCall_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"); + + 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); + + // First call: token resolves successfully, but SsoClient call will fail + // (since it's a real client with no endpoint) try { credentialsProvider.resolveCredentials(); } catch (Exception e) { - // sso client created internally which cannot be mocked. + // Expected: internal SsoClient cannot reach the SSO service + } + + // Second call: token provider throws, this error must propagate + assertThatThrownBy(credentialsProvider::resolveCredentials) + .isInstanceOf(RuntimeException.class) + .satisfies(thrown -> { + // Verify the second call's error surfaces somewhere in the chain + boolean messageFound = false; + Throwable current = thrown; + while (current != null) { + if (current.getMessage() != null && + current.getMessage().contains("Token refresh failed on second attempt")) { + messageFound = true; + break; + } + current = current.getCause(); + } + assertThat(messageFound) + .as("Expected 'Token refresh failed on second attempt' in the exception chain") + .isTrue(); + }); + } + + @Test + public void fiveSequentialRefreshes_eachUsesCorrectToken() { + SsoClient mockSsoClient = mock(SsoClient.class); + SdkTokenProvider mockTokenProvider = mock(SdkTokenProvider.class); + + // Token provider returns 5 different tokens on successive calls + 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() + ); + + // Set up already-expired credentials to force re-fetch every time + RoleCredentials roleCredentials = RoleCredentials.builder() + .accessKeyId("AKID") + .secretAccessKey("secret") + .sessionToken("session") + .expiration(Instant.now().minusSeconds(1).toEpochMilli()) + .build(); + GetRoleCredentialsResponse response = GetRoleCredentialsResponse.builder() + .roleCredentials(roleCredentials) + .build(); + when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class))).thenReturn(response); + + GetRoleCredentialsRequest baseRequest = GetRoleCredentialsRequest.builder() + .accountId("111222333") + .roleName("MultiRefreshRole") + .build(); + Supplier supplier = () -> { + SdkToken token = mockTokenProvider.resolveToken(); + return baseRequest.toBuilder() + .accessToken(token.token()) + .build(); + }; + + try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() + .ssoClient(mockSsoClient) + .refreshRequest(supplier) + .build()) { + for (int i = 0; i < 5; i++) { + credentialsProvider.resolveCredentials(); + } } - Mockito.verify(sdkTokenProvider, times(1)).resolveToken(); + + ArgumentCaptor requestCaptor = + ArgumentCaptor.forClass(GetRoleCredentialsRequest.class); + Mockito.verify(mockSsoClient, times(5)).getRoleCredentials(requestCaptor.capture()); + + // Verify each request used the correct token in order + 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"); + + // Verify token provider was called exactly 5 times + 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 From 5fba49eca05f79713bf2b8bfe3420ac91e59e901 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Thu, 2 Jul 2026 12:53:39 -0700 Subject: [PATCH 2/4] Add ability to inject ssoClient to improve testing --- .../SsoProfileCredentialsProviderFactory.java | 30 +++- ...ProfileCredentialsProviderFactoryTest.java | 159 ++++++++---------- 2 files changed, 89 insertions(+), 100 deletions(-) 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 a67c98359b8a..78cc9db9edbd 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 @@ -63,7 +63,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 +73,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,16 +98,19 @@ 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) @@ -111,7 +125,7 @@ private SsoProfileCredentialsProvider(ProfileProviderCredentialsContext credenti this.credentialsProvider = SsoCredentialsProvider.builder() - .ssoClient(ssoClient) + .ssoClient(this.ssoClient) .refreshRequest(supplier) .sourceChain(credentialsContext.sourceChain()) .build(); 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 859f988b45b9..87da43b56e6b 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 @@ -32,7 +32,6 @@ import java.nio.file.Files; import java.nio.file.Path; import java.time.Instant; -import java.util.function.Supplier; import java.util.stream.Stream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -350,53 +349,47 @@ public void ssoSessionPath_eachRefreshUsesLatestToken() { SsoClient mockSsoClient = mock(SsoClient.class); SdkTokenProvider mockTokenProvider = mock(SdkTokenProvider.class); - // Token provider returns different tokens on successive calls + 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() ); - // Set up GetRoleCredentialsResponse with short-lived expiration to force re-fetch each time RoleCredentials roleCredentials = RoleCredentials.builder() .accessKeyId("AKID") .secretAccessKey("secret") .sessionToken("session") .expiration(Instant.now().minusSeconds(1).toEpochMilli()) .build(); - GetRoleCredentialsResponse response = GetRoleCredentialsResponse.builder() - .roleCredentials(roleCredentials) - .build(); - when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class))).thenReturn(response); - - // Build supplier using the same pattern as the fixed SsoProfileCredentialsProvider - GetRoleCredentialsRequest baseRequest = GetRoleCredentialsRequest.builder() - .accountId("123456789") - .roleName("TestRole") - .build(); - Supplier supplier = () -> { - SdkToken token = mockTokenProvider.resolveToken(); - return baseRequest.toBuilder() - .accessToken(token.token()) - .build(); - }; - - try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() - .ssoClient(mockSsoClient) - .refreshRequest(supplier) - .build()) { - // Call resolveCredentials() three times - credentialsProvider.resolveCredentials(); - credentialsProvider.resolveCredentials(); - credentialsProvider.resolveCredentials(); - } + 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(); - // Capture the requests sent to SsoClient ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(GetRoleCredentialsRequest.class); Mockito.verify(mockSsoClient, Mockito.atLeast(3)).getRoleCredentials(requestCaptor.capture()); - // Verify each call used a different (latest) token 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"); @@ -407,57 +400,44 @@ public void legacyPath_tokenReReadFromDiskOnEachRefresh() { SsoClient mockSsoClient = mock(SsoClient.class); SdkTokenProvider mockTokenProvider = mock(SdkTokenProvider.class); - // Simulate SsoAccessTokenProvider behavior: returns different tokens on successive calls - // (as if the token file on disk has been updated between calls) + 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() ); - // Set up GetRoleCredentialsResponse with expired credentials to force re-fetch RoleCredentials roleCredentials = RoleCredentials.builder() .accessKeyId("AKID") .secretAccessKey("secret") .sessionToken("session") .expiration(Instant.now().minusSeconds(1).toEpochMilli()) .build(); - GetRoleCredentialsResponse response = GetRoleCredentialsResponse.builder() - .roleCredentials(roleCredentials) - .build(); - when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class))).thenReturn(response); - - // Build supplier mimicking the legacy path: reads token from provider on each call - GetRoleCredentialsRequest baseRequest = GetRoleCredentialsRequest.builder() - .accountId("987654321") - .roleName("LegacyRole") - .build(); - Supplier supplier = () -> { - SdkToken token = mockTokenProvider.resolveToken(); - return baseRequest.toBuilder() - .accessToken(token.token()) - .build(); - }; - - try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() - .ssoClient(mockSsoClient) - .refreshRequest(supplier) - .build()) { - // First call uses "disk-token-1" - credentialsProvider.resolveCredentials(); - // Second call (after simulated disk update) uses "disk-token-2" - credentialsProvider.resolveCredentials(); - } + 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(); - // Capture and verify the requests ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(GetRoleCredentialsRequest.class); Mockito.verify(mockSsoClient, times(2)).getRoleCredentials(requestCaptor.capture()); - // Verify the first call used the first token and second call used the updated token assertThat(requestCaptor.getAllValues().get(0).accessToken()).isEqualTo("disk-token-1"); assertThat(requestCaptor.getAllValues().get(1).accessToken()).isEqualTo("disk-token-2"); - - // Also verify the token provider was called exactly twice (once per refresh) Mockito.verify(mockTokenProvider, times(2)).resolveToken(); } @@ -626,7 +606,15 @@ public void fiveSequentialRefreshes_eachUsesCorrectToken() { SsoClient mockSsoClient = mock(SsoClient.class); SdkTokenProvider mockTokenProvider = mock(SdkTokenProvider.class); - // Token provider returns 5 different tokens on successive calls + 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(), @@ -635,50 +623,37 @@ public void fiveSequentialRefreshes_eachUsesCorrectToken() { SsoAccessToken.builder().accessToken("token-5").expiresAt(Instant.now().plusSeconds(3600)).build() ); - // Set up already-expired credentials to force re-fetch every time RoleCredentials roleCredentials = RoleCredentials.builder() .accessKeyId("AKID") .secretAccessKey("secret") .sessionToken("session") .expiration(Instant.now().minusSeconds(1).toEpochMilli()) .build(); - GetRoleCredentialsResponse response = GetRoleCredentialsResponse.builder() - .roleCredentials(roleCredentials) - .build(); - when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class))).thenReturn(response); - - GetRoleCredentialsRequest baseRequest = GetRoleCredentialsRequest.builder() - .accountId("111222333") - .roleName("MultiRefreshRole") - .build(); - Supplier supplier = () -> { - SdkToken token = mockTokenProvider.resolveToken(); - return baseRequest.toBuilder() - .accessToken(token.token()) - .build(); - }; - - try (SsoCredentialsProvider credentialsProvider = SsoCredentialsProvider.builder() - .ssoClient(mockSsoClient) - .refreshRequest(supplier) - .build()) { - for (int i = 0; i < 5; i++) { - credentialsProvider.resolveCredentials(); - } + 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(); } ArgumentCaptor requestCaptor = ArgumentCaptor.forClass(GetRoleCredentialsRequest.class); Mockito.verify(mockSsoClient, times(5)).getRoleCredentials(requestCaptor.capture()); - // Verify each request used the correct token in order 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"); - - // Verify token provider was called exactly 5 times Mockito.verify(mockTokenProvider, times(5)).resolveToken(); } From a49acc37bbfbb8eb7dffc0c05d9f213675346481 Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Thu, 2 Jul 2026 13:18:58 -0700 Subject: [PATCH 3/4] Unify exception handling --- .../sso/auth/ExpiredTokenException.java | 7 + .../SsoProfileCredentialsProviderFactory.java | 20 ++- .../sso/internal/SsoAccessTokenProvider.java | 32 +++-- ...ProfileCredentialsProviderFactoryTest.java | 136 ++++++++---------- .../internal/SsoAccessTokenProviderTest.java | 13 +- 5 files changed, 112 insertions(+), 96 deletions(-) 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 78cc9db9edbd..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; @@ -117,7 +118,7 @@ private SsoProfileCredentialsProvider(ProfileProviderCredentialsContext credenti .roleName(ssoRoleName) .build(); Supplier supplier = () -> { - SdkToken token = tokenProvider.resolveToken(); + SdkToken token = resolveTokenOrThrow(tokenProvider); return request.toBuilder() .accessToken(token.token()) .build(); @@ -142,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 87da43b56e6b..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 @@ -48,6 +48,7 @@ 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; @@ -172,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" + @@ -182,20 +185,25 @@ 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); - // Call resolveCredentials() twice to verify token is re-resolved on each call - for (int i = 0; i < 2; i++) { - try { - credentialsProvider.resolveCredentials(); - } catch (Exception e) { - // sso client created internally which cannot be mocked. - } - } - // The first call triggers the supplier (which calls resolveToken()), and the second call - // also triggers the supplier since credentials from the first call expired immediately. + .build(), + sdkTokenProvider, + mockSsoClient); + credentialsProvider.resolveCredentials(); + credentialsProvider.resolveCredentials(); + Mockito.verify(sdkTokenProvider, times(2)).resolveToken(); } @@ -306,6 +314,7 @@ public void validProfileWithTokenProvider_createsProviderSuccessfully() { @Test public void tokenIsReResolvedOnEachCredentialRefresh() { int numberOfRefreshCalls = 3; + SsoClient mockSsoClient = mock(SsoClient.class); ProfileFile profileFile = configFile("[profile test]\n" + "sso_account_id=accountId\n" + @@ -323,22 +332,26 @@ public void tokenIsReResolvedOnEachCredentialRefresh() { 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); + sdkTokenProvider, + mockSsoClient); - // Call resolveCredentials() multiple times to trigger the supplier for (int i = 0; i < numberOfRefreshCalls; i++) { - try { - credentialsProvider.resolveCredentials(); - } catch (Exception e) { - // Expected: SsoClient created internally cannot reach the SSO service. - // The supplier IS still invoked before the SsoClient call fails. - } + credentialsProvider.resolveCredentials(); } Mockito.verify(sdkTokenProvider, Mockito.atLeast(numberOfRefreshCalls)).resolveToken(); @@ -464,16 +477,9 @@ public void errorPropagation_resolveTokenThrowsUncheckedIOException_propagatesTo sdkTokenProvider); assertThatThrownBy(credentialsProvider::resolveCredentials) - .isInstanceOfAny(UncheckedIOException.class, RuntimeException.class) - .satisfies(thrown -> { - // The error must surface - either directly or wrapped - if (thrown instanceof UncheckedIOException) { - assertThat(thrown.getCause().getMessage()).contains("Token file not found"); - } else { - // May be wrapped in another exception; verify the root cause is present - assertThat(thrown).hasRootCauseMessage("Token file not found"); - } - }); + .isInstanceOf(ExpiredTokenException.class) + .hasMessageContaining("expired or is otherwise invalid") + .hasCauseInstanceOf(UncheckedIOException.class); } @Test @@ -499,22 +505,9 @@ public void errorPropagation_resolveTokenThrowsRuntimeException_propagatesToCall sdkTokenProvider); assertThatThrownBy(credentialsProvider::resolveCredentials) - .isInstanceOf(RuntimeException.class) - .satisfies(thrown -> { - // The error must surface - verify the original message is reachable - boolean messageFound = false; - Throwable current = thrown; - while (current != null) { - if (current.getMessage() != null && current.getMessage().contains("Token is expired")) { - messageFound = true; - break; - } - current = current.getCause(); - } - assertThat(messageFound) - .as("Expected 'Token is expired' somewhere in the exception chain") - .isTrue(); - }); + .isInstanceOf(ExpiredTokenException.class) + .hasMessageContaining("expired or is otherwise invalid") + .hasCauseInstanceOf(RuntimeException.class); } @Test @@ -541,15 +534,15 @@ public void errorPropagation_resolveTokenReturnsNullTokenValue_errorPropagates() .build(), sdkTokenProvider); - // The null token value should cause an error when resolveCredentials is called. - // This may manifest as NullPointerException, SdkClientException, or similar. - // The critical assertion is that the error is NOT silently swallowed. assertThatThrownBy(credentialsProvider::resolveCredentials) - .isInstanceOf(Exception.class); + .isInstanceOf(ExpiredTokenException.class) + .hasMessageContaining("expired or is otherwise invalid"); } @Test - public void errorPropagation_tokenProviderThrowsOnSecondCall_errorPropagates() { + 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" + @@ -559,6 +552,15 @@ public void errorPropagation_tokenProviderThrowsOnSecondCall_errorPropagates() { "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()) @@ -570,35 +572,15 @@ public void errorPropagation_tokenProviderThrowsOnSecondCall_errorPropagates() { .profile(profileFile.profile("test").get()) .profileFile(profileFile) .build(), - sdkTokenProvider); + sdkTokenProvider, + mockSsoClient); - // First call: token resolves successfully, but SsoClient call will fail - // (since it's a real client with no endpoint) - try { - credentialsProvider.resolveCredentials(); - } catch (Exception e) { - // Expected: internal SsoClient cannot reach the SSO service - } + // First call succeeds and caches credentials + credentialsProvider.resolveCredentials(); - // Second call: token provider throws, this error must propagate - assertThatThrownBy(credentialsProvider::resolveCredentials) - .isInstanceOf(RuntimeException.class) - .satisfies(thrown -> { - // Verify the second call's error surfaces somewhere in the chain - boolean messageFound = false; - Throwable current = thrown; - while (current != null) { - if (current.getMessage() != null && - current.getMessage().contains("Token refresh failed on second attempt")) { - messageFound = true; - break; - } - current = current.getCause(); - } - assertThat(messageFound) - .as("Expected 'Token refresh failed on second attempt' in the exception chain") - .isTrue(); - }); + // Second call: token provider throws InvalidTokenException, but CachedSupplier with + // StaleValueBehavior.ALLOW returns stale cached credentials (static stability) + assertThat(credentialsProvider.resolveCredentials()).isNotNull(); } @Test 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 From 14523b2d7d3702f2d7db56bf61c815cdd35085fd Mon Sep 17 00:00:00 2001 From: Alex Woods Date: Tue, 7 Jul 2026 08:28:43 -0700 Subject: [PATCH 4/4] Fixes from PR --- .../sso/auth/ExpiredTokenException.java | 2 +- .../SsoProfileCredentialsProviderFactory.java | 4 +++- .../sso/internal/SsoAccessTokenProvider.java | 2 ++ ...soProfileCredentialsProviderFactoryTest.java | 4 ++-- .../auth/sso/ProfileCredentialProviderTest.java | 17 ++++++++++------- 5 files changed, 18 insertions(+), 11 deletions(-) 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 4c72d9d45db0..099a759eb5ad 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,7 +31,7 @@ @SdkPublicApi public final class ExpiredTokenException extends SdkClientException { - public static final String DEFAULT_MESSAGE = + private 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."; 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 4204c8559d12..0e7f93219c43 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 @@ -147,9 +147,11 @@ private static SdkToken resolveTokenOrThrow(SdkTokenProvider tokenProvider) { SdkToken token; try { token = tokenProvider.resolveToken(); - } catch (ExpiredTokenException | SdkServiceException e) { + } catch (ExpiredTokenException | SdkServiceException | IllegalArgumentException e) { throw e; } catch (RuntimeException e) { + // Any exception raised while trying to read the token file (invalid file, unable to access, does not exist, ect) + // should be treated as an invalid/expired token and requires the user to re-authenticate. throw ExpiredTokenException.builder() .cause(e) .build(); 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 5c732a3d2b0b..fbd468aea5fb 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 @@ -52,6 +52,8 @@ public SdkToken resolveToken() { } catch (ExpiredTokenException e) { throw e; } catch (Exception e) { + // Any exception raised while trying to read the token file (invalid file, unable to access, does not exist, ect) + // should be treated as an invalid/expired token and requires the user to re-authenticate throw ExpiredTokenException.builder() .cause(e) .build(); 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 e1277e0a80f9..d6956ac87022 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 @@ -578,8 +578,8 @@ public void tokenProviderThrowsOnSecondCall_staleCachedCredentialsReturned() { // 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) + // Second call: token provider throws, but CachedSupplier returns the previously + // cached credentials since they haven't reached their stale time yet. assertThat(credentialsProvider.resolveCredentials()).isNotNull(); } diff --git a/test/auth-tests/src/it/java/software/amazon/awssdk/auth/sso/ProfileCredentialProviderTest.java b/test/auth-tests/src/it/java/software/amazon/awssdk/auth/sso/ProfileCredentialProviderTest.java index 94f01a90ef02..5033f319c36b 100644 --- a/test/auth-tests/src/it/java/software/amazon/awssdk/auth/sso/ProfileCredentialProviderTest.java +++ b/test/auth-tests/src/it/java/software/amazon/awssdk/auth/sso/ProfileCredentialProviderTest.java @@ -40,20 +40,20 @@ private static Stream ssoTokenErrorValues() { "[sso-session foo]\n" + "sso_start_url=https//d-abc123.awsapps.com/start\n" + "sso_region=region") - , "Unable to load SSO token"), + , "expired or is otherwise invalid"), Arguments.of(configFile("[profile test]\n" + "sso_account_id=accountId\n" + "sso_role_name=roleName\n" + "sso_session=foo\n" + "[sso-session foo]\n" + "sso_region=region") - , "Property 'sso_start_url' was not configured for profile 'test'"), + , "Property 'sso_start_url' was not configured for profile"), Arguments.of(configFile("[profile test]\n" + "sso_account_id=accountId\n" + "sso_role_name=roleName\n" + "sso_region=region\n" + "sso_start_url=https//non-existing-Token/start") - , "java.nio.file.NoSuchFileException") + , "expired or is otherwise invalid") ); @@ -71,10 +71,13 @@ private static ProfileFile configFile(String configFile) { void validateSsoFactoryErrorWithIncorrectProfiles(ProfileFile profiles, String expectedValue) { assertThat(profiles.profile("test")).hasValueSatisfying(profile -> { SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory(); - assertThatThrownBy(() -> factory.create(ProfileProviderCredentialsContext.builder() - .profile(profile) - .profileFile(profiles) - .build())).hasMessageContaining(expectedValue); + assertThatThrownBy(() -> { + factory.create(ProfileProviderCredentialsContext.builder() + .profile(profile) + .profileFile(profiles) + .build()) + .resolveCredentials(); + }).hasMessageContaining(expectedValue); }); }