Skip to content

Commit a49acc3

Browse files
committed
Unify exception handling
1 parent 5fba49e commit a49acc3

5 files changed

Lines changed: 112 additions & 96 deletions

File tree

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

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,10 @@
3131
@SdkPublicApi
3232
public final class ExpiredTokenException extends SdkClientException {
3333

34+
public static final String DEFAULT_MESSAGE =
35+
"The SSO session associated with this profile has expired or is otherwise invalid."
36+
+ " To refresh this SSO session run aws sso login with the corresponding profile.";
37+
3438
private static final List<SdkField<?>> SDK_FIELDS = Collections.unmodifiableList(Arrays.asList());
3539

3640
private ExpiredTokenException(Builder b) {
@@ -88,6 +92,9 @@ public BuilderImpl writableStackTrace(Boolean writableStackTrace) {
8892

8993
@Override
9094
public ExpiredTokenException build() {
95+
if (this.message == null) {
96+
this.message = DEFAULT_MESSAGE;
97+
}
9198
return new ExpiredTokenException(this);
9299
}
93100

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

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
import software.amazon.awssdk.auth.token.credentials.SdkToken;
3333
import software.amazon.awssdk.auth.token.credentials.SdkTokenProvider;
3434
import software.amazon.awssdk.auth.token.internal.LazyTokenProvider;
35+
import software.amazon.awssdk.core.exception.SdkServiceException;
3536
import software.amazon.awssdk.profiles.Profile;
3637
import software.amazon.awssdk.profiles.ProfileFile;
3738
import software.amazon.awssdk.profiles.ProfileProperty;
@@ -117,7 +118,7 @@ private SsoProfileCredentialsProvider(ProfileProviderCredentialsContext credenti
117118
.roleName(ssoRoleName)
118119
.build();
119120
Supplier<GetRoleCredentialsRequest> supplier = () -> {
120-
SdkToken token = tokenProvider.resolveToken();
121+
SdkToken token = resolveTokenOrThrow(tokenProvider);
121122
return request.toBuilder()
122123
.accessToken(token.token())
123124
.build();
@@ -142,6 +143,23 @@ public void close() {
142143
IoUtils.closeQuietly(ssoClient, null);
143144
}
144145

146+
private static SdkToken resolveTokenOrThrow(SdkTokenProvider tokenProvider) {
147+
SdkToken token;
148+
try {
149+
token = tokenProvider.resolveToken();
150+
} catch (ExpiredTokenException | SdkServiceException e) {
151+
throw e;
152+
} catch (RuntimeException e) {
153+
throw ExpiredTokenException.builder()
154+
.cause(e)
155+
.build();
156+
}
157+
if (token == null || token.token() == null) {
158+
throw ExpiredTokenException.builder().build();
159+
}
160+
return token;
161+
}
162+
145163
private static String regionFromProfileOrSession(Profile profile, ProfileFile profileFile) {
146164
Optional<String> ssoSession = profile.property(ProfileSection.SSO_SESSION.getPropertyKeyName());
147165
String profileRegion = profile.properties().get(ProfileProperty.SSO_REGION);

services/sso/src/main/java/software/amazon/awssdk/services/sso/internal/SsoAccessTokenProvider.java

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
import software.amazon.awssdk.services.sso.auth.ExpiredTokenException;
3030
import software.amazon.awssdk.services.sso.auth.SsoCredentialsProvider;
3131
import software.amazon.awssdk.utils.IoUtils;
32-
import software.amazon.awssdk.utils.Validate;
3332

3433
/**
3534
* 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) {
4847

4948
@Override
5049
public SdkToken resolveToken() {
51-
return tokenFromFile();
50+
try {
51+
return tokenFromFile();
52+
} catch (ExpiredTokenException e) {
53+
throw e;
54+
} catch (Exception e) {
55+
throw ExpiredTokenException.builder()
56+
.cause(e)
57+
.build();
58+
}
5259
}
5360

5461
private SdkToken tokenFromFile() {
@@ -61,25 +68,22 @@ private SdkToken tokenFromFile() {
6168

6269
private SdkToken getTokenFromJson(String json) {
6370
JsonNode jsonNode = PARSER.parse(json);
64-
String expiration = jsonNode.field("expiresAt").map(JsonNode::text).orElse(null);
71+
String expirationStr = jsonNode.field("expiresAt").map(JsonNode::text).orElse(null);
72+
73+
if (expirationStr == null) {
74+
throw ExpiredTokenException.builder().build();
75+
}
6576

66-
Validate.notNull(expiration,
67-
"The SSO session's expiration time could not be determined. Please refresh your SSO session.");
77+
Instant expiration = Instant.parse(expirationStr);
6878

69-
if (tokenIsInvalid(expiration)) {
70-
throw ExpiredTokenException.builder().message("The SSO session associated with this profile has expired or is"
71-
+ " otherwise invalid. To refresh this SSO session run aws sso"
72-
+ " login with the corresponding profile.").build();
79+
if (Instant.now().isAfter(expiration)) {
80+
throw ExpiredTokenException.builder().build();
7381
}
7482

7583
return SsoAccessToken.builder()
7684
.accessToken(jsonNode.asObject().get("accessToken").text())
77-
.expiresAt(Instant.parse(expiration)).build();
78-
85+
.expiresAt(expiration).build();
7986
}
8087

81-
private boolean tokenIsInvalid(String expirationTime) {
82-
return Instant.now().isAfter(Instant.parse(expirationTime));
83-
}
8488

8589
}

services/sso/src/test/java/software/amazon/awssdk/services/sso/auth/SsoProfileCredentialsProviderFactoryTest.java

Lines changed: 59 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@
4848
import software.amazon.awssdk.auth.token.credentials.SdkTokenProvider;
4949
import software.amazon.awssdk.profiles.ProfileFile;
5050
import software.amazon.awssdk.services.sso.SsoClient;
51+
import software.amazon.awssdk.services.sso.auth.ExpiredTokenException;
5152
import software.amazon.awssdk.services.sso.internal.SsoAccessToken;
5253
import software.amazon.awssdk.services.sso.internal.SsoAccessTokenProvider;
5354
import software.amazon.awssdk.services.sso.model.GetRoleCredentialsRequest;
@@ -172,6 +173,8 @@ private static Stream<Arguments> ssoErrorValues() {
172173

173174
@Test
174175
public void tokenResolvedFromTokenProvider(@Mock SdkTokenProvider sdkTokenProvider){
176+
SsoClient mockSsoClient = mock(SsoClient.class);
177+
175178
ProfileFile profileFile = configFile("[profile test]\n" +
176179
"sso_account_id=accountId\n" +
177180
"sso_role_name=roleName\n" +
@@ -182,20 +185,25 @@ public void tokenResolvedFromTokenProvider(@Mock SdkTokenProvider sdkTokenProvid
182185
"sso_start_url=https//d-abc123.awsapps.com/start");
183186
SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory();
184187
when(sdkTokenProvider.resolveToken()).thenReturn(SsoAccessToken.builder().accessToken("sample").expiresAt(Instant.now()).build());
188+
189+
RoleCredentials roleCredentials = RoleCredentials.builder()
190+
.accessKeyId("AKID")
191+
.secretAccessKey("secret")
192+
.sessionToken("session")
193+
.expiration(Instant.now().minusSeconds(1).toEpochMilli())
194+
.build();
195+
when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class)))
196+
.thenReturn(GetRoleCredentialsResponse.builder().roleCredentials(roleCredentials).build());
197+
185198
AwsCredentialsProvider credentialsProvider = factory.create(ProfileProviderCredentialsContext.builder()
186199
.profile(profileFile.profile("test").get())
187200
.profileFile(profileFile)
188-
.build(), sdkTokenProvider);
189-
// Call resolveCredentials() twice to verify token is re-resolved on each call
190-
for (int i = 0; i < 2; i++) {
191-
try {
192-
credentialsProvider.resolveCredentials();
193-
} catch (Exception e) {
194-
// sso client created internally which cannot be mocked.
195-
}
196-
}
197-
// The first call triggers the supplier (which calls resolveToken()), and the second call
198-
// also triggers the supplier since credentials from the first call expired immediately.
201+
.build(),
202+
sdkTokenProvider,
203+
mockSsoClient);
204+
credentialsProvider.resolveCredentials();
205+
credentialsProvider.resolveCredentials();
206+
199207
Mockito.verify(sdkTokenProvider, times(2)).resolveToken();
200208
}
201209

@@ -306,6 +314,7 @@ public void validProfileWithTokenProvider_createsProviderSuccessfully() {
306314
@Test
307315
public void tokenIsReResolvedOnEachCredentialRefresh() {
308316
int numberOfRefreshCalls = 3;
317+
SsoClient mockSsoClient = mock(SsoClient.class);
309318

310319
ProfileFile profileFile = configFile("[profile test]\n" +
311320
"sso_account_id=accountId\n" +
@@ -323,22 +332,26 @@ public void tokenIsReResolvedOnEachCredentialRefresh() {
323332
SsoAccessToken.builder().accessToken("token-4").expiresAt(Instant.now().plusSeconds(3600)).build()
324333
);
325334

335+
RoleCredentials roleCredentials = RoleCredentials.builder()
336+
.accessKeyId("AKID")
337+
.secretAccessKey("secret")
338+
.sessionToken("session")
339+
.expiration(Instant.now().minusSeconds(1).toEpochMilli())
340+
.build();
341+
when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class)))
342+
.thenReturn(GetRoleCredentialsResponse.builder().roleCredentials(roleCredentials).build());
343+
326344
SsoProfileCredentialsProviderFactory factory = new SsoProfileCredentialsProviderFactory();
327345
AwsCredentialsProvider credentialsProvider = factory.create(
328346
ProfileProviderCredentialsContext.builder()
329347
.profile(profileFile.profile("test").get())
330348
.profileFile(profileFile)
331349
.build(),
332-
sdkTokenProvider);
350+
sdkTokenProvider,
351+
mockSsoClient);
333352

334-
// Call resolveCredentials() multiple times to trigger the supplier
335353
for (int i = 0; i < numberOfRefreshCalls; i++) {
336-
try {
337-
credentialsProvider.resolveCredentials();
338-
} catch (Exception e) {
339-
// Expected: SsoClient created internally cannot reach the SSO service.
340-
// The supplier IS still invoked before the SsoClient call fails.
341-
}
354+
credentialsProvider.resolveCredentials();
342355
}
343356

344357
Mockito.verify(sdkTokenProvider, Mockito.atLeast(numberOfRefreshCalls)).resolveToken();
@@ -464,16 +477,9 @@ public void errorPropagation_resolveTokenThrowsUncheckedIOException_propagatesTo
464477
sdkTokenProvider);
465478

466479
assertThatThrownBy(credentialsProvider::resolveCredentials)
467-
.isInstanceOfAny(UncheckedIOException.class, RuntimeException.class)
468-
.satisfies(thrown -> {
469-
// The error must surface - either directly or wrapped
470-
if (thrown instanceof UncheckedIOException) {
471-
assertThat(thrown.getCause().getMessage()).contains("Token file not found");
472-
} else {
473-
// May be wrapped in another exception; verify the root cause is present
474-
assertThat(thrown).hasRootCauseMessage("Token file not found");
475-
}
476-
});
480+
.isInstanceOf(ExpiredTokenException.class)
481+
.hasMessageContaining("expired or is otherwise invalid")
482+
.hasCauseInstanceOf(UncheckedIOException.class);
477483
}
478484

479485
@Test
@@ -499,22 +505,9 @@ public void errorPropagation_resolveTokenThrowsRuntimeException_propagatesToCall
499505
sdkTokenProvider);
500506

501507
assertThatThrownBy(credentialsProvider::resolveCredentials)
502-
.isInstanceOf(RuntimeException.class)
503-
.satisfies(thrown -> {
504-
// The error must surface - verify the original message is reachable
505-
boolean messageFound = false;
506-
Throwable current = thrown;
507-
while (current != null) {
508-
if (current.getMessage() != null && current.getMessage().contains("Token is expired")) {
509-
messageFound = true;
510-
break;
511-
}
512-
current = current.getCause();
513-
}
514-
assertThat(messageFound)
515-
.as("Expected 'Token is expired' somewhere in the exception chain")
516-
.isTrue();
517-
});
508+
.isInstanceOf(ExpiredTokenException.class)
509+
.hasMessageContaining("expired or is otherwise invalid")
510+
.hasCauseInstanceOf(RuntimeException.class);
518511
}
519512

520513
@Test
@@ -541,15 +534,15 @@ public void errorPropagation_resolveTokenReturnsNullTokenValue_errorPropagates()
541534
.build(),
542535
sdkTokenProvider);
543536

544-
// The null token value should cause an error when resolveCredentials is called.
545-
// This may manifest as NullPointerException, SdkClientException, or similar.
546-
// The critical assertion is that the error is NOT silently swallowed.
547537
assertThatThrownBy(credentialsProvider::resolveCredentials)
548-
.isInstanceOf(Exception.class);
538+
.isInstanceOf(ExpiredTokenException.class)
539+
.hasMessageContaining("expired or is otherwise invalid");
549540
}
550541

551542
@Test
552-
public void errorPropagation_tokenProviderThrowsOnSecondCall_errorPropagates() {
543+
public void tokenProviderThrowsOnSecondCall_staleCachedCredentialsReturned() {
544+
SsoClient mockSsoClient = mock(SsoClient.class);
545+
553546
ProfileFile profileFile = configFile("[profile test]\n" +
554547
"sso_account_id=accountId\n" +
555548
"sso_role_name=roleName\n" +
@@ -559,6 +552,15 @@ public void errorPropagation_tokenProviderThrowsOnSecondCall_errorPropagates() {
559552
"sso_region=region\n" +
560553
"sso_start_url=https//d-abc123.awsapps.com/start");
561554

555+
RoleCredentials roleCredentials = RoleCredentials.builder()
556+
.accessKeyId("AKID")
557+
.secretAccessKey("secret")
558+
.sessionToken("session")
559+
.expiration(Instant.now().minusSeconds(1).toEpochMilli())
560+
.build();
561+
when(mockSsoClient.getRoleCredentials(Mockito.any(GetRoleCredentialsRequest.class)))
562+
.thenReturn(GetRoleCredentialsResponse.builder().roleCredentials(roleCredentials).build());
563+
562564
RuntimeException secondCallError = new RuntimeException("Token refresh failed on second attempt");
563565
when(sdkTokenProvider.resolveToken())
564566
.thenReturn(SsoAccessToken.builder().accessToken("valid-token").expiresAt(Instant.now().plusSeconds(3600)).build())
@@ -570,35 +572,15 @@ public void errorPropagation_tokenProviderThrowsOnSecondCall_errorPropagates() {
570572
.profile(profileFile.profile("test").get())
571573
.profileFile(profileFile)
572574
.build(),
573-
sdkTokenProvider);
575+
sdkTokenProvider,
576+
mockSsoClient);
574577

575-
// First call: token resolves successfully, but SsoClient call will fail
576-
// (since it's a real client with no endpoint)
577-
try {
578-
credentialsProvider.resolveCredentials();
579-
} catch (Exception e) {
580-
// Expected: internal SsoClient cannot reach the SSO service
581-
}
578+
// First call succeeds and caches credentials
579+
credentialsProvider.resolveCredentials();
582580

583-
// Second call: token provider throws, this error must propagate
584-
assertThatThrownBy(credentialsProvider::resolveCredentials)
585-
.isInstanceOf(RuntimeException.class)
586-
.satisfies(thrown -> {
587-
// Verify the second call's error surfaces somewhere in the chain
588-
boolean messageFound = false;
589-
Throwable current = thrown;
590-
while (current != null) {
591-
if (current.getMessage() != null &&
592-
current.getMessage().contains("Token refresh failed on second attempt")) {
593-
messageFound = true;
594-
break;
595-
}
596-
current = current.getCause();
597-
}
598-
assertThat(messageFound)
599-
.as("Expected 'Token refresh failed on second attempt' in the exception chain")
600-
.isTrue();
601-
});
581+
// Second call: token provider throws InvalidTokenException, but CachedSupplier with
582+
// StaleValueBehavior.ALLOW returns stale cached credentials (static stability)
583+
assertThat(credentialsProvider.resolveCredentials()).isNotNull();
602584
}
603585

604586
@Test

0 commit comments

Comments
 (0)